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