produzione 1 #9
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,86 @@
|
||||
package com.printcalculator.controller.admin;
|
||||
|
||||
import com.printcalculator.dto.AdminContactRequestDto;
|
||||
import com.printcalculator.dto.AdminContactRequestAttachmentDto;
|
||||
import com.printcalculator.dto.AdminContactRequestDetailDto;
|
||||
import com.printcalculator.dto.AdminFilamentStockDto;
|
||||
import com.printcalculator.dto.AdminQuoteSessionDto;
|
||||
import com.printcalculator.entity.CustomQuoteRequest;
|
||||
import com.printcalculator.entity.CustomQuoteRequestAttachment;
|
||||
import com.printcalculator.entity.FilamentVariant;
|
||||
import com.printcalculator.entity.FilamentVariantStockKg;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
|
||||
import com.printcalculator.repository.CustomQuoteRequestRepository;
|
||||
import com.printcalculator.repository.FilamentVariantRepository;
|
||||
import com.printcalculator.repository.FilamentVariantStockKgRepository;
|
||||
import com.printcalculator.repository.OrderRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@Transactional(readOnly = true)
|
||||
public class AdminOperationsController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.class);
|
||||
private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
|
||||
|
||||
private final FilamentVariantStockKgRepository filamentStockRepo;
|
||||
private final FilamentVariantRepository filamentVariantRepo;
|
||||
private final CustomQuoteRequestRepository customQuoteRequestRepo;
|
||||
private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo;
|
||||
private final QuoteSessionRepository quoteSessionRepo;
|
||||
private final OrderRepository orderRepo;
|
||||
|
||||
public AdminOperationsController(
|
||||
FilamentVariantStockKgRepository filamentStockRepo,
|
||||
FilamentVariantRepository filamentVariantRepo,
|
||||
CustomQuoteRequestRepository customQuoteRequestRepo,
|
||||
QuoteSessionRepository quoteSessionRepo
|
||||
CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo,
|
||||
QuoteSessionRepository quoteSessionRepo,
|
||||
OrderRepository orderRepo
|
||||
) {
|
||||
this.filamentStockRepo = filamentStockRepo;
|
||||
this.filamentVariantRepo = filamentVariantRepo;
|
||||
this.customQuoteRequestRepo = customQuoteRequestRepo;
|
||||
this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo;
|
||||
this.quoteSessionRepo = quoteSessionRepo;
|
||||
this.orderRepo = orderRepo;
|
||||
}
|
||||
|
||||
@GetMapping("/filament-stock")
|
||||
@@ -103,6 +140,101 @@ public class AdminOperationsController {
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/contact-requests/{requestId}")
|
||||
public ResponseEntity<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")
|
||||
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
|
||||
List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll(
|
||||
@@ -115,6 +247,21 @@ public class AdminOperationsController {
|
||||
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) {
|
||||
AdminContactRequestDto dto = new AdminContactRequestDto();
|
||||
dto.setId(request.getId());
|
||||
@@ -129,6 +276,16 @@ public class AdminOperationsController {
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) {
|
||||
AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto();
|
||||
dto.setId(attachment.getId());
|
||||
dto.setOriginalFilename(attachment.getOriginalFilename());
|
||||
dto.setMimeType(attachment.getMimeType());
|
||||
dto.setFileSizeBytes(attachment.getFileSizeBytes());
|
||||
dto.setCreatedAt(attachment.getCreatedAt());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) {
|
||||
AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
|
||||
dto.setId(session.getId());
|
||||
@@ -139,4 +296,24 @@ public class AdminOperationsController {
|
||||
dto.setConvertedOrderId(session.getConvertedOrderId());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void deleteSessionFiles(UUID sessionId) {
|
||||
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
|
||||
if (!Files.exists(sessionDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Stream<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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
||||
import com.printcalculator.entity.CustomQuoteRequestAttachment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> {
|
||||
}
|
||||
List<CustomQuoteRequestAttachment> findByRequest_IdOrderByCreatedAtAsc(UUID requestId);
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ import java.util.Optional;
|
||||
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
|
||||
// We try to match by color name if possible, or get first active
|
||||
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
|
||||
Optional<FilamentVariant> findByFilamentMaterialTypeAndVariantDisplayName(FilamentMaterialType type, String variantDisplayName);
|
||||
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,6 @@ import java.util.UUID;
|
||||
|
||||
public interface OrderRepository extends JpaRepository<Order, UUID> {
|
||||
List<Order> findAllByOrderByCreatedAtDesc();
|
||||
|
||||
boolean existsBySourceQuoteSession_Id(UUID sourceQuoteSessionId);
|
||||
}
|
||||
|
||||
1
db.sql
1
db.sql
@@ -59,7 +59,6 @@ create table filament_variant
|
||||
unique (filament_material_type_id, variant_display_name)
|
||||
);
|
||||
|
||||
-- (opzionale) kg disponibili calcolati
|
||||
create view filament_variant_stock_kg as
|
||||
select filament_variant_id,
|
||||
stock_spools,
|
||||
|
||||
@@ -20,10 +20,6 @@ export const ADMIN_ROUTES: Routes = [
|
||||
path: 'orders',
|
||||
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent)
|
||||
},
|
||||
{
|
||||
path: 'orders-past',
|
||||
loadComponent: () => import('./pages/admin-orders-past.component').then(m => m.AdminOrdersPastComponent)
|
||||
},
|
||||
{
|
||||
path: 'filament-stock',
|
||||
loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent)
|
||||
|
||||
@@ -1,40 +1,119 @@
|
||||
<section class="section-card">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<div class="header-copy">
|
||||
<h2>Richieste di contatto</h2>
|
||||
<p>Richieste preventivo personalizzato ricevute dal sito.</p>
|
||||
<span class="total-pill">{{ requests.length }} richieste</span>
|
||||
</div>
|
||||
<button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button>
|
||||
</header>
|
||||
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
|
||||
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Nome / Azienda</th>
|
||||
<th>Email</th>
|
||||
<th>Tipo richiesta</th>
|
||||
<th>Tipo cliente</th>
|
||||
<th>Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let request of requests">
|
||||
<td>{{ request.createdAt | date:'short' }}</td>
|
||||
<td>{{ request.name || request.companyName || '-' }}</td>
|
||||
<td>{{ request.email }}</td>
|
||||
<td>{{ request.requestType }}</td>
|
||||
<td>{{ request.customerType }}</td>
|
||||
<td>{{ request.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="workspace" *ngIf="!loading; else loadingTpl">
|
||||
<section class="list-panel">
|
||||
<h3>Lista richieste</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="requests-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Nome / Azienda</th>
|
||||
<th>Email</th>
|
||||
<th>Tipo richiesta</th>
|
||||
<th>Tipo cliente</th>
|
||||
<th>Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let request of requests"
|
||||
[class.selected]="isSelected(request.id)"
|
||||
(click)="openDetails(request.id)"
|
||||
>
|
||||
<td class="created-at">{{ request.createdAt | date:'short' }}</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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
<p>Caricamento richieste...</p>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noAttachmentsTpl>
|
||||
<p class="muted">Nessun allegato disponibile.</p>
|
||||
</ng-template>
|
||||
|
||||
@@ -11,18 +11,44 @@
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
p {
|
||||
.section-header p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.header-copy {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.total-pill {
|
||||
width: fit-content;
|
||||
margin-top: var(--space-1);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(500px, 1.25fr) minmax(420px, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -31,15 +57,31 @@ button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.list-panel h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
max-height: 72vh;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -47,13 +89,300 @@ table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.92rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.name-cell .primary {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name-cell .secondary {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.email-cell,
|
||||
.created-at {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #fff9d9;
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: #fff5b8;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.empty-row:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
padding: var(--space-4);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.detail-panel.empty {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-panel.empty h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.request-id {
|
||||
margin: var(--space-2) 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.request-id code {
|
||||
display: inline-block;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
background: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 7px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.loading-detail {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-neutral-100);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-item dt {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.meta-item dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
|
||||
.message-box {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.message-box h4 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 0.86rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-box p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.attachments h4 {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.filename {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 2px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger-500);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
padding: 5px 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chip-neutral {
|
||||
background: #e9f4ff;
|
||||
border-color: #c8def4;
|
||||
color: #1e4d78;
|
||||
}
|
||||
|
||||
.chip-light {
|
||||
background: #f4f5f8;
|
||||
border-color: #dde1e8;
|
||||
color: #4a5567;
|
||||
}
|
||||
|
||||
.chip-warning {
|
||||
background: #fff4cd;
|
||||
border-color: #f7dd85;
|
||||
color: #684b00;
|
||||
}
|
||||
|
||||
.chip-success {
|
||||
background: #dff6ea;
|
||||
border-color: #b6e2cb;
|
||||
color: #14543a;
|
||||
}
|
||||
|
||||
.chip-danger {
|
||||
background: #fde4e2;
|
||||
border-color: #f3c0ba;
|
||||
color: #812924;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.68;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 1060px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.section-card {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-chips {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { AdminContactRequest, AdminOperationsService } from '../services/admin-operations.service';
|
||||
import {
|
||||
AdminContactRequest,
|
||||
AdminContactRequestAttachment,
|
||||
AdminContactRequestDetail,
|
||||
AdminOperationsService
|
||||
} from '../services/admin-operations.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-contact-requests',
|
||||
@@ -13,7 +18,10 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
|
||||
requests: AdminContactRequest[] = [];
|
||||
selectedRequest: AdminContactRequestDetail | null = null;
|
||||
selectedRequestId: string | null = null;
|
||||
loading = false;
|
||||
detailLoading = false;
|
||||
errorMessage: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -26,6 +34,14 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
this.adminOperationsService.getContactRequests().subscribe({
|
||||
next: (requests) => {
|
||||
this.requests = requests;
|
||||
if (requests.length === 0) {
|
||||
this.selectedRequest = null;
|
||||
this.selectedRequestId = null;
|
||||
} else if (this.selectedRequestId && requests.some(r => r.id === this.selectedRequestId)) {
|
||||
this.openDetails(this.selectedRequestId);
|
||||
} else {
|
||||
this.openDetails(requests[0].id);
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
@@ -34,4 +50,73 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openDetails(requestId: string): void {
|
||||
this.selectedRequestId = requestId;
|
||||
this.detailLoading = true;
|
||||
this.adminOperationsService.getContactRequestDetail(requestId).subscribe({
|
||||
next: (detail) => {
|
||||
this.selectedRequest = detail;
|
||||
this.detailLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.detailLoading = false;
|
||||
this.errorMessage = 'Impossibile caricare il dettaglio richiesta.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isSelected(requestId: string): boolean {
|
||||
return this.selectedRequestId === requestId;
|
||||
}
|
||||
|
||||
downloadAttachment(attachment: AdminContactRequestAttachment): void {
|
||||
if (!this.selectedRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOperationsService.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id).subscribe({
|
||||
next: (blob) => this.downloadBlob(blob, attachment.originalFilename || `attachment-${attachment.id}`),
|
||||
error: () => {
|
||||
this.errorMessage = 'Download allegato non riuscito.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatFileSize(bytes?: number): string {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return '-';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
getStatusChipClass(status?: string): string {
|
||||
const normalized = (status || '').trim().toUpperCase();
|
||||
if (['PENDING', 'NEW', 'OPEN', 'IN_PROGRESS'].includes(normalized)) {
|
||||
return 'chip-warning';
|
||||
}
|
||||
if (['DONE', 'COMPLETED', 'RESOLVED', 'CLOSED'].includes(normalized)) {
|
||||
return 'chip-success';
|
||||
}
|
||||
if (['REJECTED', 'FAILED', 'ERROR', 'SPAM'].includes(normalized)) {
|
||||
return 'chip-danger';
|
||||
}
|
||||
return 'chip-light';
|
||||
}
|
||||
|
||||
private downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,36 @@
|
||||
<section class="list-panel">
|
||||
<h2>Lista ordini</h2>
|
||||
<div class="list-toolbar">
|
||||
<label for="order-search">Cerca UUID</label>
|
||||
<input
|
||||
id="order-search"
|
||||
type="search"
|
||||
[ngModel]="orderSearchTerm"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="UUID completo o prefisso (es. 738131d8)"
|
||||
/>
|
||||
<label class="toolbar-field" for="order-search">
|
||||
<span>Cerca UUID</span>
|
||||
<input
|
||||
id="order-search"
|
||||
type="search"
|
||||
[ngModel]="orderSearchTerm"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
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 class="table-wrap">
|
||||
<table>
|
||||
@@ -31,6 +53,7 @@
|
||||
<th>Ordine</th>
|
||||
<th>Email</th>
|
||||
<th>Pagamento</th>
|
||||
<th>Stato ordine</th>
|
||||
<th>Totale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -43,10 +66,11 @@
|
||||
<td>{{ order.orderNumber }}</td>
|
||||
<td>{{ order.customerEmail }}</td>
|
||||
<td>{{ order.paymentStatus || 'PENDING' }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
|
||||
</tr>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(400px, 0.95fr) minmax(560px, 1.45fr);
|
||||
grid-template-columns: minmax(540px, 1.35fr) minmax(420px, 0.95fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -70,17 +70,24 @@ button:disabled {
|
||||
|
||||
.list-toolbar {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(190px, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.list-toolbar label {
|
||||
.toolbar-field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.toolbar-field span {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.list-toolbar input {
|
||||
.toolbar-field input,
|
||||
.toolbar-field select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -383,6 +390,10 @@ h4 {
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.list-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export class AdminDashboardComponent implements OnInit {
|
||||
selectedStatus = '';
|
||||
selectedPaymentMethod = 'OTHER';
|
||||
orderSearchTerm = '';
|
||||
paymentStatusFilter = 'ALL';
|
||||
orderStatusFilter = 'ALL';
|
||||
showPrintDetails = false;
|
||||
loading = false;
|
||||
detailLoading = false;
|
||||
@@ -34,6 +36,16 @@ export class AdminDashboardComponent implements OnInit {
|
||||
'CANCELLED'
|
||||
];
|
||||
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER'];
|
||||
readonly paymentStatusFilterOptions = ['ALL', 'PENDING', 'REPORTED', 'COMPLETED'];
|
||||
readonly orderStatusFilterOptions = [
|
||||
'ALL',
|
||||
'PENDING_PAYMENT',
|
||||
'PAID',
|
||||
'IN_PRODUCTION',
|
||||
'SHIPPED',
|
||||
'COMPLETED',
|
||||
'CANCELLED'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadOrders();
|
||||
@@ -72,17 +84,17 @@ export class AdminDashboardComponent implements OnInit {
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.orderSearchTerm = value;
|
||||
this.refreshFilteredOrders();
|
||||
this.applyListFiltersAndSelection();
|
||||
}
|
||||
|
||||
if (this.filteredOrders.length === 0) {
|
||||
this.selectedOrder = null;
|
||||
this.selectedStatus = '';
|
||||
return;
|
||||
}
|
||||
onPaymentStatusFilterChange(value: string): void {
|
||||
this.paymentStatusFilter = value || 'ALL';
|
||||
this.applyListFiltersAndSelection();
|
||||
}
|
||||
|
||||
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) {
|
||||
this.openDetails(this.filteredOrders[0].id);
|
||||
}
|
||||
onOrderStatusFilterChange(value: string): void {
|
||||
this.orderStatusFilter = value || 'ALL';
|
||||
this.applyListFiltersAndSelection();
|
||||
}
|
||||
|
||||
openDetails(orderId: string): void {
|
||||
@@ -225,23 +237,39 @@ export class AdminDashboardComponent implements OnInit {
|
||||
|
||||
private applyOrderUpdate(updatedOrder: AdminOrder): void {
|
||||
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
|
||||
this.refreshFilteredOrders();
|
||||
this.applyListFiltersAndSelection();
|
||||
this.selectedOrder = updatedOrder;
|
||||
this.selectedStatus = updatedOrder.status;
|
||||
this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod;
|
||||
}
|
||||
|
||||
private refreshFilteredOrders(): void {
|
||||
const term = this.orderSearchTerm.trim().toLowerCase();
|
||||
if (!term) {
|
||||
this.filteredOrders = [...this.orders];
|
||||
private applyListFiltersAndSelection(): void {
|
||||
this.refreshFilteredOrders();
|
||||
|
||||
if (this.filteredOrders.length === 0) {
|
||||
this.selectedOrder = null;
|
||||
this.selectedStatus = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) {
|
||||
this.openDetails(this.filteredOrders[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshFilteredOrders(): void {
|
||||
const term = this.orderSearchTerm.trim().toLowerCase();
|
||||
this.filteredOrders = this.orders.filter((order) => {
|
||||
const fullUuid = order.id.toLowerCase();
|
||||
const shortUuid = (order.orderNumber || '').toLowerCase();
|
||||
return fullUuid.includes(term) || shortUuid.includes(term);
|
||||
const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase();
|
||||
const orderStatus = (order.status || '').toUpperCase();
|
||||
|
||||
const matchesSearch = !term || fullUuid.includes(term) || shortUuid.includes(term);
|
||||
const matchesPayment = this.paymentStatusFilter === 'ALL' || paymentStatus === this.paymentStatusFilter;
|
||||
const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter;
|
||||
|
||||
return matchesSearch && matchesPayment && matchesOrderStatus;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,42 +2,229 @@
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<h2>Stock filamenti</h2>
|
||||
<p>Monitoraggio quantità disponibili per variante.</p>
|
||||
<p>Gestione materiali, varianti e stock per il calcolatore.</p>
|
||||
</div>
|
||||
<button type="button" (click)="loadStock()" [disabled]="loading">Aggiorna</button>
|
||||
<button type="button" (click)="loadData()" [disabled]="loading">Aggiorna</button>
|
||||
</header>
|
||||
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
<div class="alerts">
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Materiale</th>
|
||||
<th>Variante</th>
|
||||
<th>Colore</th>
|
||||
<th>Spool</th>
|
||||
<th>Kg totali</th>
|
||||
<th>Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let row of rows">
|
||||
<td>{{ row.materialCode }}</td>
|
||||
<td>{{ row.variantDisplayName }}</td>
|
||||
<td>{{ row.colorName }}</td>
|
||||
<td>{{ row.stockSpools | number:'1.0-3' }}</td>
|
||||
<td>{{ row.stockKg | number:'1.0-3' }} kg</td>
|
||||
<td>
|
||||
<span class="badge low" *ngIf="isLowStock(row)">Basso</span>
|
||||
<span class="badge ok" *ngIf="!isLowStock(row)">OK</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="content" *ngIf="!loading; else loadingTpl">
|
||||
<section class="panel">
|
||||
<h3>Inserimento rapido</h3>
|
||||
<div class="create-grid">
|
||||
<section class="subpanel">
|
||||
<h4>Nuovo materiale</h4>
|
||||
<div class="form-grid">
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Codice materiale</span>
|
||||
<input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." />
|
||||
</label>
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Etichetta tecnico</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newMaterial.technicalTypeLabel"
|
||||
[disabled]="!newMaterial.isTechnical"
|
||||
placeholder="alta temperatura, rinforzato..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [(ngModel)]="newMaterial.isFlexible" />
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -11,18 +11,168 @@
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
.section-header p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.alerts {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.panel > h3 {
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.subpanel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.subpanel h4 {
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2) var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.form-field--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-field > span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-bg-card);
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.65rem;
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle span {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.material-grid,
|
||||
.variant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.material-card,
|
||||
.variant-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.variant-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.variant-header strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.variant-meta {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -38,27 +188,26 @@ button:hover:not(:disabled) {
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
button:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
.panel-toggle {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.panel-toggle:hover:not(:disabled) {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -74,4 +223,27 @@ td {
|
||||
|
||||
.error {
|
||||
color: var(--color-danger-500);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #157347;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.create-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,277 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { AdminFilamentStockRow, AdminOperationsService } from '../services/admin-operations.service';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
AdminFilamentMaterialType,
|
||||
AdminFilamentVariant,
|
||||
AdminOperationsService,
|
||||
AdminUpsertFilamentMaterialTypePayload,
|
||||
AdminUpsertFilamentVariantPayload
|
||||
} from '../services/admin-operations.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-filament-stock',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-filament-stock.component.html',
|
||||
styleUrl: './admin-filament-stock.component.scss'
|
||||
})
|
||||
export class AdminFilamentStockComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
|
||||
rows: AdminFilamentStockRow[] = [];
|
||||
materials: AdminFilamentMaterialType[] = [];
|
||||
variants: AdminFilamentVariant[] = [];
|
||||
loading = false;
|
||||
materialsCollapsed = true;
|
||||
creatingMaterial = false;
|
||||
creatingVariant = false;
|
||||
savingMaterialIds = new Set<number>();
|
||||
savingVariantIds = new Set<number>();
|
||||
errorMessage: string | null = null;
|
||||
successMessage: string | null = null;
|
||||
|
||||
newMaterial: AdminUpsertFilamentMaterialTypePayload = {
|
||||
materialCode: '',
|
||||
isFlexible: false,
|
||||
isTechnical: false,
|
||||
technicalTypeLabel: ''
|
||||
};
|
||||
|
||||
newVariant: AdminUpsertFilamentVariantPayload = {
|
||||
materialTypeId: 0,
|
||||
variantDisplayName: '',
|
||||
colorName: '',
|
||||
isMatte: false,
|
||||
isSpecial: false,
|
||||
costChfPerKg: 0,
|
||||
stockSpools: 0,
|
||||
spoolNetKg: 1,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStock();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadStock(): void {
|
||||
loadData(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = null;
|
||||
this.adminOperationsService.getFilamentStock().subscribe({
|
||||
next: (rows) => {
|
||||
this.rows = rows;
|
||||
this.successMessage = null;
|
||||
|
||||
forkJoin({
|
||||
materials: this.adminOperationsService.getFilamentMaterials(),
|
||||
variants: this.adminOperationsService.getFilamentVariants()
|
||||
}).subscribe({
|
||||
next: ({ materials, variants }) => {
|
||||
this.materials = this.sortMaterials(materials);
|
||||
this.variants = this.sortVariants(variants);
|
||||
if (!this.newVariant.materialTypeId && this.materials.length > 0) {
|
||||
this.newVariant.materialTypeId = this.materials[0].id;
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Impossibile caricare lo stock filamenti.';
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare i filamenti.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isLowStock(row: AdminFilamentStockRow): boolean {
|
||||
return Number(row.stockKg) < 1;
|
||||
createMaterial(): void {
|
||||
if (this.creatingMaterial) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.creatingMaterial = true;
|
||||
|
||||
const payload: AdminUpsertFilamentMaterialTypePayload = {
|
||||
materialCode: (this.newMaterial.materialCode || '').trim(),
|
||||
isFlexible: !!this.newMaterial.isFlexible,
|
||||
isTechnical: !!this.newMaterial.isTechnical,
|
||||
technicalTypeLabel: this.newMaterial.isTechnical
|
||||
? (this.newMaterial.technicalTypeLabel || '').trim()
|
||||
: ''
|
||||
};
|
||||
|
||||
this.adminOperationsService.createFilamentMaterial(payload).subscribe({
|
||||
next: (created) => {
|
||||
this.materials = this.sortMaterials([...this.materials, created]);
|
||||
if (!this.newVariant.materialTypeId) {
|
||||
this.newVariant.materialTypeId = created.id;
|
||||
}
|
||||
this.newMaterial = {
|
||||
materialCode: '',
|
||||
isFlexible: false,
|
||||
isTechnical: false,
|
||||
technicalTypeLabel: ''
|
||||
};
|
||||
this.creatingMaterial = false;
|
||||
this.successMessage = 'Materiale aggiunto.';
|
||||
},
|
||||
error: (err) => {
|
||||
this.creatingMaterial = false;
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Creazione materiale non riuscita.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveMaterial(material: AdminFilamentMaterialType): void {
|
||||
if (this.savingMaterialIds.has(material.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.savingMaterialIds.add(material.id);
|
||||
|
||||
const payload: AdminUpsertFilamentMaterialTypePayload = {
|
||||
materialCode: (material.materialCode || '').trim(),
|
||||
isFlexible: !!material.isFlexible,
|
||||
isTechnical: !!material.isTechnical,
|
||||
technicalTypeLabel: material.isTechnical ? (material.technicalTypeLabel || '').trim() : ''
|
||||
};
|
||||
|
||||
this.adminOperationsService.updateFilamentMaterial(material.id, payload).subscribe({
|
||||
next: (updated) => {
|
||||
this.materials = this.sortMaterials(
|
||||
this.materials.map((m) => (m.id === updated.id ? updated : m))
|
||||
);
|
||||
this.variants = this.variants.map((variant) => {
|
||||
if (variant.materialTypeId !== updated.id) {
|
||||
return variant;
|
||||
}
|
||||
return {
|
||||
...variant,
|
||||
materialCode: updated.materialCode,
|
||||
materialIsFlexible: updated.isFlexible,
|
||||
materialIsTechnical: updated.isTechnical,
|
||||
materialTechnicalTypeLabel: updated.technicalTypeLabel
|
||||
};
|
||||
});
|
||||
this.savingMaterialIds.delete(material.id);
|
||||
this.successMessage = 'Materiale aggiornato.';
|
||||
},
|
||||
error: (err) => {
|
||||
this.savingMaterialIds.delete(material.id);
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento materiale non riuscito.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createVariant(): void {
|
||||
if (this.creatingVariant) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.creatingVariant = true;
|
||||
|
||||
const payload = this.toVariantPayload(this.newVariant);
|
||||
this.adminOperationsService.createFilamentVariant(payload).subscribe({
|
||||
next: (created) => {
|
||||
this.variants = this.sortVariants([...this.variants, created]);
|
||||
this.newVariant = {
|
||||
materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0,
|
||||
variantDisplayName: '',
|
||||
colorName: '',
|
||||
isMatte: false,
|
||||
isSpecial: false,
|
||||
costChfPerKg: 0,
|
||||
stockSpools: 0,
|
||||
spoolNetKg: 1,
|
||||
isActive: true
|
||||
};
|
||||
this.creatingVariant = false;
|
||||
this.successMessage = 'Variante aggiunta.';
|
||||
},
|
||||
error: (err) => {
|
||||
this.creatingVariant = false;
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Creazione variante non riuscita.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveVariant(variant: AdminFilamentVariant): void {
|
||||
if (this.savingVariantIds.has(variant.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.savingVariantIds.add(variant.id);
|
||||
|
||||
const payload = this.toVariantPayload(variant);
|
||||
this.adminOperationsService.updateFilamentVariant(variant.id, payload).subscribe({
|
||||
next: (updated) => {
|
||||
this.variants = this.sortVariants(
|
||||
this.variants.map((v) => (v.id === updated.id ? updated : v))
|
||||
);
|
||||
this.savingVariantIds.delete(variant.id);
|
||||
this.successMessage = 'Variante aggiornata.';
|
||||
},
|
||||
error: (err) => {
|
||||
this.savingVariantIds.delete(variant.id);
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento variante non riuscito.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isLowStock(variant: AdminFilamentVariant): boolean {
|
||||
return this.computeStockKg(variant.stockSpools, variant.spoolNetKg) < 1;
|
||||
}
|
||||
|
||||
computeStockKg(stockSpools?: number, spoolNetKg?: number): number {
|
||||
const spools = Number(stockSpools ?? 0);
|
||||
const netKg = Number(spoolNetKg ?? 0);
|
||||
|
||||
if (!Number.isFinite(spools) || !Number.isFinite(netKg) || spools < 0 || netKg < 0) {
|
||||
return 0;
|
||||
}
|
||||
return spools * netKg;
|
||||
}
|
||||
|
||||
trackById(index: number, item: { id: number }): number {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
toggleMaterialsCollapsed(): void {
|
||||
this.materialsCollapsed = !this.materialsCollapsed;
|
||||
}
|
||||
|
||||
private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload {
|
||||
return {
|
||||
materialTypeId: Number(source.materialTypeId),
|
||||
variantDisplayName: (source.variantDisplayName || '').trim(),
|
||||
colorName: (source.colorName || '').trim(),
|
||||
isMatte: !!source.isMatte,
|
||||
isSpecial: !!source.isSpecial,
|
||||
costChfPerKg: Number(source.costChfPerKg ?? 0),
|
||||
stockSpools: Number(source.stockSpools ?? 0),
|
||||
spoolNetKg: Number(source.spoolNetKg ?? 0),
|
||||
isActive: source.isActive !== false
|
||||
};
|
||||
}
|
||||
|
||||
private sortMaterials(materials: AdminFilamentMaterialType[]): AdminFilamentMaterialType[] {
|
||||
return [...materials].sort((a, b) => a.materialCode.localeCompare(b.materialCode));
|
||||
}
|
||||
|
||||
private sortVariants(variants: AdminFilamentVariant[]): AdminFilamentVariant[] {
|
||||
return [...variants].sort((a, b) => {
|
||||
const byMaterial = (a.materialCode || '').localeCompare(b.materialCode || '');
|
||||
if (byMaterial !== 0) {
|
||||
return byMaterial;
|
||||
}
|
||||
return (a.variantDisplayName || '').localeCompare(b.variantDisplayName || '');
|
||||
});
|
||||
}
|
||||
|
||||
private extractErrorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as { error?: { message?: string } };
|
||||
return err?.error?.message || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
<h2>Sessioni quote</h2>
|
||||
<p>Sessioni create dal configuratore con stato e conversione ordine.</p>
|
||||
</div>
|
||||
<button type="button" (click)="loadSessions()" [disabled]="loading">Aggiorna</button>
|
||||
<button type="button" class="btn-primary" (click)="loadSessions()" [disabled]="loading">Aggiorna</button>
|
||||
</header>
|
||||
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
|
||||
|
||||
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
|
||||
<table>
|
||||
@@ -19,17 +20,75 @@
|
||||
<th>Materiale</th>
|
||||
<th>Stato</th>
|
||||
<th>Ordine convertito</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let session of sessions">
|
||||
<td>{{ session.id }}</td>
|
||||
<td>{{ session.createdAt | date:'short' }}</td>
|
||||
<td>{{ session.expiresAt | date:'short' }}</td>
|
||||
<td>{{ session.materialCode }}</td>
|
||||
<td>{{ session.status }}</td>
|
||||
<td>{{ session.convertedOrderId || '-' }}</td>
|
||||
</tr>
|
||||
<ng-container *ngFor="let session of sessions">
|
||||
<tr>
|
||||
<td [title]="session.id">{{ session.id | slice:0:8 }}</td>
|
||||
<td>{{ session.createdAt | date:'short' }}</td>
|
||||
<td>{{ session.expiresAt | date:'short' }}</td>
|
||||
<td>{{ session.materialCode }}</td>
|
||||
<td>{{ session.status }}</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 *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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -26,18 +26,40 @@ p {
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-neutral-900);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
.btn-primary {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger-500);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -57,3 +79,48 @@ td {
|
||||
.error {
|
||||
color: var(--color-danger-500);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--color-success-500);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-box {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table th,
|
||||
.detail-table td {
|
||||
text-align: left;
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { AdminOperationsService, AdminQuoteSession } from '../services/admin-operations.service';
|
||||
import {
|
||||
AdminOperationsService,
|
||||
AdminQuoteSession,
|
||||
AdminQuoteSessionDetail
|
||||
} from '../services/admin-operations.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-sessions',
|
||||
@@ -13,8 +17,13 @@ export class AdminSessionsComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
|
||||
sessions: AdminQuoteSession[] = [];
|
||||
sessionDetailsById: Record<string, AdminQuoteSessionDetail | undefined> = {};
|
||||
loading = false;
|
||||
deletingSessionIds = new Set<string>();
|
||||
loadingDetailSessionIds = new Set<string>();
|
||||
expandedSessionId: string | null = null;
|
||||
errorMessage: string | null = null;
|
||||
successMessage: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSessions();
|
||||
@@ -23,6 +32,7 @@ export class AdminSessionsComponent implements OnInit {
|
||||
loadSessions(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.adminOperationsService.getSessions().subscribe({
|
||||
next: (sessions) => {
|
||||
this.sessions = sessions;
|
||||
@@ -34,4 +44,90 @@ export class AdminSessionsComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteSession(session: AdminQuoteSession): void {
|
||||
if (this.deletingSessionIds.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.deletingSessionIds.add(session.id);
|
||||
|
||||
this.adminOperationsService.deleteSession(session.id).subscribe({
|
||||
next: () => {
|
||||
this.sessions = this.sessions.filter((item) => item.id !== session.id);
|
||||
this.deletingSessionIds.delete(session.id);
|
||||
this.successMessage = 'Sessione eliminata.';
|
||||
},
|
||||
error: (err) => {
|
||||
this.deletingSessionIds.delete(session.id);
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Impossibile eliminare la sessione.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isDeletingSession(sessionId: string): boolean {
|
||||
return this.deletingSessionIds.has(sessionId);
|
||||
}
|
||||
|
||||
toggleSessionDetail(session: AdminQuoteSession): void {
|
||||
if (this.expandedSessionId === session.id) {
|
||||
this.expandedSessionId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.expandedSessionId = session.id;
|
||||
if (this.sessionDetailsById[session.id] || this.loadingDetailSessionIds.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadingDetailSessionIds.add(session.id);
|
||||
this.adminOperationsService.getSessionDetail(session.id).subscribe({
|
||||
next: (detail) => {
|
||||
this.sessionDetailsById = {
|
||||
...this.sessionDetailsById,
|
||||
[session.id]: detail
|
||||
};
|
||||
this.loadingDetailSessionIds.delete(session.id);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loadingDetailSessionIds.delete(session.id);
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare il dettaglio sessione.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isDetailOpen(sessionId: string): boolean {
|
||||
return this.expandedSessionId === sessionId;
|
||||
}
|
||||
|
||||
isLoadingDetail(sessionId: string): boolean {
|
||||
return this.loadingDetailSessionIds.has(sessionId);
|
||||
}
|
||||
|
||||
getSessionDetail(sessionId: string): AdminQuoteSessionDetail | undefined {
|
||||
return this.sessionDetailsById[sessionId];
|
||||
}
|
||||
|
||||
formatPrintTime(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return '-';
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
private extractErrorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as { error?: { message?: string } };
|
||||
return err?.error?.message || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<nav class="menu">
|
||||
<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="contact-requests" routerLinkActive="active">Richieste contatto</a>
|
||||
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>
|
||||
|
||||
@@ -14,6 +14,52 @@ export interface AdminFilamentStockRow {
|
||||
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 {
|
||||
id: string;
|
||||
requestType: string;
|
||||
@@ -26,6 +72,30 @@ export interface AdminContactRequest {
|
||||
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 {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -35,6 +105,33 @@ export interface AdminQuoteSession {
|
||||
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({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -46,11 +143,57 @@ export class AdminOperationsService {
|
||||
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[]> {
|
||||
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[]> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user