dev #8

Closed
JoeKung wants to merge 72 commits from dev into int
38 changed files with 2558 additions and 345 deletions
Showing only changes of commit 949770a741 - Show all commits

View File

@@ -1,156 +0,0 @@
package com.printcalculator.controller;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.PaymentService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/admin/orders")
public class AdminOrderController {
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final PaymentService paymentService;
public AdminOrderController(
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
PaymentService paymentService
) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.paymentService = paymentService;
}
@GetMapping
public ResponseEntity<List<OrderDto>> listOrders() {
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/payments/confirm")
@Transactional
public ResponseEntity<OrderDto> confirmPayment(
@PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload
) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
paymentService.confirmPayment(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).toList();
dto.setItems(itemDtos);
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
}

View File

@@ -1,7 +1,9 @@
package com.printcalculator.controller;
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminLoginRequest;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
@@ -13,26 +15,52 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.OptionalLong;
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
private final AdminSessionService adminSessionService;
private final AdminLoginThrottleService adminLoginThrottleService;
public AdminAuthController(AdminSessionService adminSessionService) {
public AdminAuthController(
AdminSessionService adminSessionService,
AdminLoginThrottleService adminLoginThrottleService
) {
this.adminSessionService = adminSessionService;
this.adminLoginThrottleService = adminLoginThrottleService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(
@Valid @RequestBody AdminLoginRequest request,
HttpServletRequest httpRequest,
HttpServletResponse response
) {
if (!adminSessionService.isPasswordValid(request.getPassword())) {
return ResponseEntity.status(401).body(Map.of("authenticated", false));
String clientKey = adminLoginThrottleService.resolveClientKey(httpRequest);
OptionalLong remainingLock = adminLoginThrottleService.getRemainingLockSeconds(clientKey);
if (remainingLock.isPresent()) {
long retryAfter = remainingLock.getAsLong();
return ResponseEntity.status(429)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
if (!adminSessionService.isPasswordValid(request.getPassword())) {
long retryAfter = adminLoginThrottleService.registerFailure(clientKey);
return ResponseEntity.status(401)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
adminLoginThrottleService.reset(clientKey);
String token = adminSessionService.createSessionToken();
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString());

View File

@@ -0,0 +1,142 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantStockKg;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.FilamentVariantStockKgRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin")
@Transactional(readOnly = true)
public class AdminOperationsController {
private final FilamentVariantStockKgRepository filamentStockRepo;
private final FilamentVariantRepository filamentVariantRepo;
private final CustomQuoteRequestRepository customQuoteRequestRepo;
private final QuoteSessionRepository quoteSessionRepo;
public AdminOperationsController(
FilamentVariantStockKgRepository filamentStockRepo,
FilamentVariantRepository filamentVariantRepo,
CustomQuoteRequestRepository customQuoteRequestRepo,
QuoteSessionRepository quoteSessionRepo
) {
this.filamentStockRepo = filamentStockRepo;
this.filamentVariantRepo = filamentVariantRepo;
this.customQuoteRequestRepo = customQuoteRequestRepo;
this.quoteSessionRepo = quoteSessionRepo;
}
@GetMapping("/filament-stock")
public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() {
List<FilamentVariantStockKg> stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg"));
Set<Long> variantIds = stocks.stream()
.map(FilamentVariantStockKg::getFilamentVariantId)
.collect(Collectors.toSet());
Map<Long, FilamentVariant> variantsById;
if (variantIds.isEmpty()) {
variantsById = Collections.emptyMap();
} else {
variantsById = filamentVariantRepo.findAllById(variantIds).stream()
.collect(Collectors.toMap(FilamentVariant::getId, variant -> variant));
}
List<AdminFilamentStockDto> response = stocks.stream().map(stock -> {
FilamentVariant variant = variantsById.get(stock.getFilamentVariantId());
AdminFilamentStockDto dto = new AdminFilamentStockDto();
dto.setFilamentVariantId(stock.getFilamentVariantId());
dto.setStockSpools(stock.getStockSpools());
dto.setSpoolNetKg(stock.getSpoolNetKg());
dto.setStockKg(stock.getStockKg());
if (variant != null) {
dto.setMaterialCode(
variant.getFilamentMaterialType() != null
? variant.getFilamentMaterialType().getMaterialCode()
: "UNKNOWN"
);
dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName());
dto.setActive(variant.getIsActive());
} else {
dto.setMaterialCode("UNKNOWN");
dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId());
dto.setColorName("-");
dto.setActive(false);
}
return dto;
}).toList();
return ResponseEntity.ok(response);
}
@GetMapping("/contact-requests")
public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() {
List<AdminContactRequestDto> response = customQuoteRequestRepo.findAll(
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toContactRequestDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll(
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toQuoteSessionDto)
.toList();
return ResponseEntity.ok(response);
}
private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) {
AdminContactRequestDto dto = new AdminContactRequestDto();
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.setStatus(request.getStatus());
dto.setCreatedAt(request.getCreatedAt());
return dto;
}
private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) {
AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
dto.setId(session.getId());
dto.setStatus(session.getStatus());
dto.setMaterialCode(session.getMaterialCode());
dto.setCreatedAt(session.getCreatedAt());
dto.setExpiresAt(session.getExpiresAt());
dto.setConvertedOrderId(session.getConvertedOrderId());
return dto;
}
}

View File

@@ -0,0 +1,301 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/admin/orders")
@Transactional(readOnly = true)
public class AdminOrderController {
private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT",
"PAID",
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED",
"CANCELLED"
);
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
public AdminOrderController(
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService
) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
}
@GetMapping
public ResponseEntity<List<OrderDto>> listOrders() {
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/payments/confirm")
@Transactional
public ResponseEntity<OrderDto> confirmPayment(
@PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload
) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
paymentService.confirmPayment(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/status")
@Transactional
public ResponseEntity<OrderDto> updateOrderStatus(
@PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload
) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Status is required");
}
Order order = getOrderOrThrow(orderId);
String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
);
}
order.setStatus(normalizedStatus);
orderRepo.save(order);
return ResponseEntity.ok(toOrderDto(order));
}
@GetMapping("/{orderId}/items/{orderItemId}/file")
public ResponseEntity<Resource> downloadOrderItemFile(
@PathVariable UUID orderId,
@PathVariable UUID orderItemId
) {
OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found"));
if (!item.getOrder().getId().equals(orderId)) {
throw new ResponseStatusException(NOT_FOUND, "Order item not found for order");
}
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
Resource resource = storageService.loadAsResource(Paths.get(relativePath));
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(item.getMimeType());
} catch (Exception ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank()
? item.getOriginalFilename()
: "order-item-" + orderItemId;
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
@GetMapping("/{orderId}/documents/confirmation")
public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true);
}
@GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), false);
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
QuoteSession sourceSession = order.getSourceQuoteSession();
if (sourceSession != null) {
dto.setPrintMaterialCode(sourceSession.getMaterialCode());
dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm());
dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm());
dto.setPrintInfillPattern(sourceSession.getInfillPattern());
dto.setPrintInfillPercent(sourceSession.getInfillPercent());
dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled());
}
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).toList();
dto.setItems(itemDtos);
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) {
String relativePath = "orders/" + order.getId() + "/documents/confirmation-" + displayOrderNumber + ".pdf";
try {
byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// fallback to generated confirmation document
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String prefix = isConfirmation ? "confirmation-" : "invoice-";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
}

View File

@@ -0,0 +1,88 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminContactRequestDto {
private UUID id;
private String requestType;
private String customerType;
private String email;
private String phone;
private String name;
private String companyName;
private String status;
private OffsetDateTime createdAt;
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 getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,78 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public class AdminFilamentStockDto {
private Long filamentVariantId;
private String materialCode;
private String variantDisplayName;
private String colorName;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private BigDecimal stockKg;
private Boolean active;
public Long getFilamentVariantId() {
return filamentVariantId;
}
public void setFilamentVariantId(Long filamentVariantId) {
this.filamentVariantId = filamentVariantId;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
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 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 getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.dto;
public class AdminOrderStatusUpdateRequest {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,61 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminQuoteSessionDto {
private UUID id;
private String status;
private String materialCode;
private OffsetDateTime createdAt;
private OffsetDateTime expiresAt;
private UUID convertedOrderId;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(OffsetDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public UUID getConvertedOrderId() {
return convertedOrderId;
}
public void setConvertedOrderId(UUID convertedOrderId) {
this.convertedOrderId = convertedOrderId;
}
}

View File

@@ -25,6 +25,12 @@ public class OrderDto {
private BigDecimal subtotalChf;
private BigDecimal totalChf;
private OffsetDateTime createdAt;
private String printMaterialCode;
private BigDecimal printNozzleDiameterMm;
private BigDecimal printLayerHeightMm;
private String printInfillPattern;
private Integer printInfillPercent;
private Boolean printSupportsEnabled;
private List<OrderItemDto> items;
// Getters and Setters
@@ -85,6 +91,24 @@ public class OrderDto {
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public String getPrintMaterialCode() { return printMaterialCode; }
public void setPrintMaterialCode(String printMaterialCode) { this.printMaterialCode = printMaterialCode; }
public BigDecimal getPrintNozzleDiameterMm() { return printNozzleDiameterMm; }
public void setPrintNozzleDiameterMm(BigDecimal printNozzleDiameterMm) { this.printNozzleDiameterMm = printNozzleDiameterMm; }
public BigDecimal getPrintLayerHeightMm() { return printLayerHeightMm; }
public void setPrintLayerHeightMm(BigDecimal printLayerHeightMm) { this.printLayerHeightMm = printLayerHeightMm; }
public String getPrintInfillPattern() { return printInfillPattern; }
public void setPrintInfillPattern(String printInfillPattern) { this.printInfillPattern = printInfillPattern; }
public Integer getPrintInfillPercent() { return printInfillPercent; }
public void setPrintInfillPercent(Integer printInfillPercent) { this.printInfillPercent = printInfillPercent; }
public Boolean getPrintSupportsEnabled() { return printSupportsEnabled; }
public void setPrintSupportsEnabled(Boolean printSupportsEnabled) { this.printSupportsEnabled = printSupportsEnabled; }
public List<OrderItemDto> getItems() { return items; }
public void setItems(List<OrderItemDto> items) { this.items = items; }
}

View File

@@ -0,0 +1,91 @@
package com.printcalculator.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.OptionalLong;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AdminLoginThrottleService {
private static final long BASE_DELAY_SECONDS = 2L;
private static final long MAX_DELAY_SECONDS = 3600L;
private final ConcurrentHashMap<String, LoginAttemptState> attemptsByClient = new ConcurrentHashMap<>();
public OptionalLong getRemainingLockSeconds(String clientKey) {
LoginAttemptState state = attemptsByClient.get(clientKey);
if (state == null) {
return OptionalLong.empty();
}
long now = Instant.now().getEpochSecond();
long remaining = state.blockedUntilEpochSeconds - now;
if (remaining <= 0) {
attemptsByClient.remove(clientKey, state);
return OptionalLong.empty();
}
return OptionalLong.of(remaining);
}
public long registerFailure(String clientKey) {
long now = Instant.now().getEpochSecond();
LoginAttemptState state = attemptsByClient.compute(clientKey, (key, current) -> {
int nextFailures = current == null ? 1 : current.failures + 1;
long delay = calculateDelaySeconds(nextFailures);
return new LoginAttemptState(nextFailures, now + delay);
});
return calculateDelaySeconds(state.failures);
}
public void reset(String clientKey) {
attemptsByClient.remove(clientKey);
}
public String resolveClientKey(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isBlank()) {
String[] parts = forwardedFor.split(",");
if (parts.length > 0 && !parts[0].trim().isEmpty()) {
return parts[0].trim();
}
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
String remoteAddress = request.getRemoteAddr();
if (remoteAddress != null && !remoteAddress.isBlank()) {
return remoteAddress.trim();
}
return "unknown";
}
private long calculateDelaySeconds(int failures) {
long delay = BASE_DELAY_SECONDS;
for (int i = 1; i < failures; i++) {
if (delay >= MAX_DELAY_SECONDS) {
return MAX_DELAY_SECONDS;
}
delay *= 2;
}
return Math.min(delay, MAX_DELAY_SECONDS);
}
private static class LoginAttemptState {
private final int failures;
private final long blockedUntilEpochSeconds;
private LoginAttemptState(int failures, long blockedUntilEpochSeconds) {
this.failures = failures;
this.blockedUntilEpochSeconds = blockedUntilEpochSeconds;
}
}
}

View File

@@ -2,6 +2,6 @@ app.mail.enabled=false
app.mail.admin.enabled=false
# Admin back-office local test credentials
admin.password=local-admin-password
admin.password=ciaociao
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789
admin.session.ttl-minutes=480

View File

@@ -1,6 +1,8 @@
package com.printcalculator.controller;
import com.printcalculator.config.SecurityConfig;
import com.printcalculator.controller.admin.AdminAuthController;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.Cookie;
@@ -22,7 +24,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = AdminAuthController.class)
@Import({SecurityConfig.class, AdminSessionAuthenticationFilter.class, AdminSessionService.class})
@Import({
SecurityConfig.class,
AdminSessionAuthenticationFilter.class,
AdminSessionService.class,
AdminLoginThrottleService.class
})
@TestPropertySource(properties = {
"admin.password=test-admin-password",
"admin.session.secret=0123456789abcdef0123456789abcdef",
@@ -36,6 +43,10 @@ class AdminAuthSecurityTest {
@Test
void loginOk_ShouldReturnCookie() throws Exception {
MvcResult result = mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.1");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())
@@ -53,9 +64,37 @@ class AdminAuthSecurityTest {
@Test
void loginKo_ShouldReturnUnauthorized() throws Exception {
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.2");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.authenticated").value(false))
.andExpect(jsonPath("$.retryAfterSeconds").value(2));
}
@Test
void loginKoSecondAttemptDuringLock_ShouldReturnTooManyRequests() throws Exception {
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.3");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.retryAfterSeconds").value(2));
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.3");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.authenticated").value(false));
}
@@ -68,6 +107,10 @@ class AdminAuthSecurityTest {
@Test
void adminAccessWithValidCookie_ShouldReturn200() throws Exception {
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.4");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())

View File

@@ -0,0 +1,17 @@
package com.printcalculator.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AdminLoginThrottleServiceTest {
private final AdminLoginThrottleService service = new AdminLoginThrottleService();
@Test
void registerFailure_ShouldDoubleDelay() {
assertEquals(2L, service.registerFailure("127.0.0.1"));
assertEquals(4L, service.registerFailure("127.0.0.1"));
assertEquals(8L, service.registerFailure("127.0.0.1"));
}
}

View File

@@ -9,6 +9,33 @@ export const ADMIN_ROUTES: Routes = [
{
path: '',
canActivate: [adminAuthGuard],
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent)
loadComponent: () => import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
children: [
{
path: '',
pathMatch: 'full',
redirectTo: 'orders'
},
{
path: 'orders',
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent)
},
{
path: 'orders-past',
loadComponent: () => import('./pages/admin-orders-past.component').then(m => m.AdminOrdersPastComponent)
},
{
path: 'filament-stock',
loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent)
},
{
path: 'contact-requests',
loadComponent: () => import('./pages/admin-contact-requests.component').then(m => m.AdminContactRequestsComponent)
},
{
path: 'sessions',
loadComponent: () => import('./pages/admin-sessions.component').then(m => m.AdminSessionsComponent)
}
]
}
];

View File

@@ -0,0 +1,40 @@
<section class="section-card">
<header class="section-header">
<div>
<h2>Richieste di contatto</h2>
<p>Richieste preventivo personalizzato ricevute dal sito.</p>
</div>
<button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Data</th>
<th>Nome / Azienda</th>
<th>Email</th>
<th>Tipo richiesta</th>
<th>Tipo cliente</th>
<th>Stato</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let request of requests">
<td>{{ request.createdAt | date:'short' }}</td>
<td>{{ request.name || request.companyName || '-' }}</td>
<td>{{ request.email }}</td>
<td>{{ request.requestType }}</td>
<td>{{ request.customerType }}</td>
<td>{{ request.status }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento richieste...</p>
</ng-template>

View File

@@ -0,0 +1,59 @@
.section-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
h2 {
margin: 0;
}
p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
button {
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.error {
color: var(--color-danger-500);
}

View File

@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminContactRequest, AdminOperationsService } from '../services/admin-operations.service';
@Component({
selector: 'app-admin-contact-requests',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-contact-requests.component.html',
styleUrl: './admin-contact-requests.component.scss'
})
export class AdminContactRequestsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
requests: AdminContactRequest[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadRequests();
}
loadRequests(): void {
this.loading = true;
this.errorMessage = null;
this.adminOperationsService.getContactRequests().subscribe({
next: (requests) => {
this.requests = requests;
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare le richieste di contatto.';
}
});
}
}

View File

@@ -1,67 +1,167 @@
<section class="admin-dashboard">
<header class="dashboard-header">
<div>
<h1>Back-office ordini</h1>
<p>Gestione pagamenti e dettaglio ordini</p>
<h1>Ordini</h1>
<p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p>
</div>
<div class="header-actions">
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
<button type="button" class="ghost" (click)="logout()">Logout</button>
</div>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Ordine</th>
<th>Email</th>
<th>Stato</th>
<th>Pagamento</th>
<th>Totale</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let order of orders" [class.selected]="selectedOrder?.id === order.id">
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.status }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
<td class="actions">
<button type="button" class="ghost" (click)="openDetails(order.id)">Dettaglio</button>
<button
type="button"
(click)="confirmPayment(order.id)"
[disabled]="confirmingOrderId === order.id || order.paymentStatus === 'COMPLETED'"
>
{{ confirmingOrderId === order.id ? 'Invio...' : 'Conferma pagamento' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<section class="details" *ngIf="selectedOrder">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<p *ngIf="detailLoading">Caricamento dettaglio...</p>
<p><strong>Cliente:</strong> {{ selectedOrder.customerEmail }}</p>
<p><strong>Pagamento:</strong> {{ selectedOrder.paymentStatus || 'PENDING' }}</p>
<div class="items">
<div class="item" *ngFor="let item of selectedOrder.items">
<p><strong>File:</strong> {{ item.originalFilename }}</p>
<p><strong>Qta:</strong> {{ item.quantity }}</p>
<p><strong>Prezzo riga:</strong> {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}</p>
<div class="workspace" *ngIf="!loading; else loadingTpl">
<section class="list-panel">
<h2>Lista ordini</h2>
<div class="list-toolbar">
<label for="order-search">Cerca UUID</label>
<input
id="order-search"
type="search"
[ngModel]="orderSearchTerm"
(ngModelChange)="onSearchChange($event)"
placeholder="UUID completo o prefisso (es. 738131d8)"
/>
</div>
</div>
</section>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ordine</th>
<th>Email</th>
<th>Pagamento</th>
<th>Totale</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let order of filteredOrders"
[class.selected]="isSelected(order.id)"
(click)="openDetails(order.id)"
>
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
</tr>
<tr class="no-results" *ngIf="filteredOrders.length === 0">
<td colspan="4">Nessun ordine trovato per il filtro inserito.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="detail-panel" *ngIf="selectedOrder">
<div class="detail-header">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<p class="order-uuid">UUID: <code>{{ selectedOrder.id }}</code></p>
<p *ngIf="detailLoading">Caricamento dettaglio...</p>
</div>
<div class="meta-grid">
<div><strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</span></div>
<div><strong>Stato pagamento</strong><span>{{ selectedOrder.paymentStatus || 'PENDING' }}</span></div>
<div><strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span></div>
<div><strong>Totale</strong><span>{{ selectedOrder.totalChf | currency:'CHF':'symbol':'1.2-2' }}</span></div>
</div>
<div class="actions-block">
<div class="status-editor">
<label for="order-status">Stato ordine</label>
<select id="order-status" [value]="selectedStatus" (change)="onStatusChange($event)">
<option *ngFor="let option of orderStatusOptions" [value]="option">{{ option }}</option>
</select>
<button type="button" (click)="updateStatus()" [disabled]="updatingStatus">
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }}
</button>
</div>
<div class="status-editor">
<label for="payment-method">Metodo pagamento</label>
<select id="payment-method" [value]="selectedPaymentMethod" (change)="onPaymentMethodChange($event)">
<option *ngFor="let option of paymentMethodOptions" [value]="option">{{ option }}</option>
</select>
<button
type="button"
(click)="confirmPayment()"
[disabled]="confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'"
>
{{ confirmingPayment ? 'Invio...' : 'Conferma pagamento' }}
</button>
</div>
</div>
<div class="doc-actions">
<button type="button" class="ghost" (click)="downloadConfirmation()">
Scarica conferma + QR bill
</button>
<button type="button" class="ghost" (click)="downloadInvoice()">
Scarica fattura
</button>
<button type="button" class="ghost" (click)="openPrintDetails()">
Dettagli stampa
</button>
</div>
<div class="items">
<div class="item" *ngFor="let item of selectedOrder.items">
<div class="item-main">
<p class="file-name"><strong>{{ item.originalFilename }}</strong></p>
<p class="item-meta">
Qta: {{ item.quantity }} |
Colore:
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
<span>{{ item.colorCode || '-' }}</span>
|
Riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}
</p>
</div>
<button type="button" class="ghost" (click)="downloadItemFile(item.id, item.originalFilename)">
Scarica file
</button>
</div>
</div>
</section>
<section class="detail-panel empty" *ngIf="!selectedOrder">
<h2>Nessun ordine selezionato</h2>
<p>Seleziona un ordine dalla lista per vedere i dettagli.</p>
</section>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento ordini...</p>
</ng-template>
<div class="modal-backdrop" *ngIf="showPrintDetails && selectedOrder" (click)="closePrintDetails()">
<div class="modal-card" (click)="$event.stopPropagation()">
<header class="modal-header">
<h3>Dettagli stampa ordine {{ selectedOrder.orderNumber }}</h3>
<button type="button" class="ghost close-btn" (click)="closePrintDetails()">Chiudi</button>
</header>
<div class="modal-grid">
<div><strong>Qualità</strong><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span></div>
<div><strong>Materiale</strong><span>{{ selectedOrder.printMaterialCode || '-' }}</span></div>
<div><strong>Layer height</strong><span>{{ selectedOrder.printLayerHeightMm || '-' }} mm</span></div>
<div><strong>Nozzle</strong><span>{{ selectedOrder.printNozzleDiameterMm || '-' }} mm</span></div>
<div><strong>Infill pattern</strong><span>{{ selectedOrder.printInfillPattern || '-' }}</span></div>
<div><strong>Infill %</strong><span>{{ selectedOrder.printInfillPercent ?? '-' }}</span></div>
<div><strong>Supporti</strong><span>{{ selectedOrder.printSupportsEnabled ? 'Sì' : 'No' }}</span></div>
</div>
<h4>Colori file</h4>
<div class="file-color-list">
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
{{ item.colorCode || '-' }}
</span>
</div>
</div>
</div>
</div>

View File

@@ -1,43 +1,61 @@
.admin-dashboard {
padding: 1rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.dashboard-header h1 {
margin: 0;
font-size: 1.6rem;
font-size: 1.45rem;
}
.dashboard-header p {
margin: 0.35rem 0 0;
color: #4b5a70;
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
.header-actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
.workspace {
display: grid;
grid-template-columns: minmax(400px, 0.95fr) minmax(560px, 1.45fr);
gap: var(--space-4);
align-items: start;
}
button {
border: 0;
border-radius: 10px;
background: #0f3f6f;
color: #fff;
padding: 0.55rem 0.8rem;
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;
line-height: 1.2;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
button.ghost {
background: #eef2f8;
color: #163a5f;
background: var(--color-bg-card);
color: var(--color-text);
border: 1px solid var(--color-border);
}
button:disabled {
@@ -45,11 +63,38 @@ button:disabled {
cursor: default;
}
.list-panel h2 {
font-size: 1.05rem;
margin-bottom: var(--space-2);
}
.list-toolbar {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.list-toolbar label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted);
}
.list-toolbar input {
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;
}
.table-wrap {
overflow: auto;
border: 1px solid #d8e0ec;
border-radius: 12px;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
max-height: 72vh;
}
table {
@@ -58,62 +103,296 @@ table {
}
thead {
background: #f3f6fa;
background: var(--color-neutral-100);
}
th,
td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid #e5ebf4;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
vertical-align: top;
font-size: 0.93rem;
}
td.actions {
tbody tr {
cursor: pointer;
transition: background-color 0.15s ease;
}
tbody tr:hover {
background: #fff9d9;
}
tbody tr.selected {
background: #fff5b8;
}
tbody tr.no-results {
cursor: default;
}
tbody tr.no-results:hover {
background: transparent;
}
.detail-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
min-height: 520px;
}
.detail-panel.empty {
display: grid;
align-content: center;
justify-items: center;
text-align: center;
color: var(--color-text-muted);
}
.order-uuid {
font-size: 0.84rem;
color: var(--color-text-muted);
}
.order-uuid code {
font-size: 0.82rem;
color: var(--color-text);
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 2px 6px;
}
.detail-header h2 {
margin: 0 0 var(--space-2);
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.meta-grid > div {
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
display: grid;
gap: 2px;
}
.meta-grid strong {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.actions-block {
display: flex;
gap: 0.5rem;
min-width: 210px;
flex-wrap: wrap;
gap: var(--space-3);
align-items: flex-end;
margin-bottom: var(--space-3);
}
tr.selected {
background: #f4f9ff;
.status-editor {
display: grid;
gap: var(--space-2);
}
.details {
margin-top: 1rem;
background: #fff;
border: 1px solid #d8e0ec;
border-radius: 12px;
padding: 1rem;
.status-editor label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
}
.details h2 {
margin-top: 0;
.status-editor select {
min-width: 220px;
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;
}
.doc-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.items {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-3);
}
.item {
border: 1px solid #e5ebf4;
border-radius: 10px;
padding: 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-bg-card);
box-shadow: var(--shadow-sm);
display: grid;
gap: var(--space-3);
}
.item p {
margin: 0.2rem 0;
.item-main {
min-width: 0;
}
.file-name {
margin: 0;
font-size: 0.92rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
margin: var(--space-1) 0 0;
font-size: 0.84rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.item button {
justify-self: start;
}
.color-swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid var(--color-border);
}
.error {
color: #b4232c;
margin-bottom: 0.9rem;
color: var(--color-danger-500);
margin-bottom: var(--space-3);
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(16, 24, 32, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 4000;
padding: var(--space-4);
}
.modal-card {
width: min(860px, 100%);
max-height: 88vh;
overflow: auto;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: var(--space-4);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.modal-header h3 {
margin: 0;
}
.close-btn {
white-space: nowrap;
}
.modal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.modal-grid > div {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-neutral-100);
padding: var(--space-3);
display: grid;
gap: 2px;
}
.modal-grid strong {
font-size: 0.78rem;
color: var(--color-text-muted);
}
h4 {
margin: 0 0 var(--space-2);
}
.file-color-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.file-color-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-card);
}
.file-color-row:last-child {
border-bottom: 0;
}
.filename {
font-size: 0.9rem;
}
.file-color {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--color-text-muted);
font-size: 0.88rem;
}
@media (max-width: 1060px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 820px) {
.dashboard-header {
flex-direction: column;
}
.meta-grid,
.modal-grid {
grid-template-columns: 1fr;
}
.item {
align-items: flex-start;
}
}

View File

@@ -1,30 +1,39 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service';
import { FormsModule } from '@angular/forms';
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@Component({
selector: 'app-admin-dashboard',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, FormsModule],
templateUrl: './admin-dashboard.component.html',
styleUrl: './admin-dashboard.component.scss'
})
export class AdminDashboardComponent implements OnInit {
private readonly adminOrdersService = inject(AdminOrdersService);
private readonly adminAuthService = inject(AdminAuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
orders: AdminOrder[] = [];
filteredOrders: AdminOrder[] = [];
selectedOrder: AdminOrder | null = null;
selectedStatus = '';
selectedPaymentMethod = 'OTHER';
orderSearchTerm = '';
showPrintDetails = false;
loading = false;
detailLoading = false;
confirmingOrderId: string | null = null;
confirmingPayment = false;
updatingStatus = false;
errorMessage: string | null = null;
readonly orderStatusOptions = [
'PENDING_PAYMENT',
'PAID',
'IN_PRODUCTION',
'SHIPPED',
'COMPLETED',
'CANCELLED'
];
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER'];
ngOnInit(): void {
this.loadOrders();
@@ -36,6 +45,22 @@ export class AdminDashboardComponent implements OnInit {
this.adminOrdersService.listOrders().subscribe({
next: (orders) => {
this.orders = orders;
this.refreshFilteredOrders();
if (!this.selectedOrder && this.filteredOrders.length > 0) {
this.openDetails(this.filteredOrders[0].id);
} else if (this.selectedOrder) {
const exists = orders.find(order => order.id === this.selectedOrder?.id);
const selectedIsVisible = this.filteredOrders.some(order => order.id === this.selectedOrder?.id);
if (exists && selectedIsVisible) {
this.openDetails(exists.id);
} else if (this.filteredOrders.length > 0) {
this.openDetails(this.filteredOrders[0].id);
} else {
this.selectedOrder = null;
this.selectedStatus = '';
}
}
this.loading = false;
},
error: () => {
@@ -45,11 +70,28 @@ export class AdminDashboardComponent implements OnInit {
});
}
onSearchChange(value: string): void {
this.orderSearchTerm = value;
this.refreshFilteredOrders();
if (this.filteredOrders.length === 0) {
this.selectedOrder = null;
this.selectedStatus = '';
return;
}
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) {
this.openDetails(this.filteredOrders[0].id);
}
}
openDetails(orderId: string): void {
this.detailLoading = true;
this.adminOrdersService.getOrder(orderId).subscribe({
next: (order) => {
this.selectedOrder = order;
this.selectedStatus = order.status;
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
this.detailLoading = false;
},
error: () => {
@@ -59,41 +101,156 @@ export class AdminDashboardComponent implements OnInit {
});
}
confirmPayment(orderId: string): void {
this.confirmingOrderId = orderId;
this.adminOrdersService.confirmPayment(orderId).subscribe({
confirmPayment(): void {
if (!this.selectedOrder || this.confirmingPayment) {
return;
}
this.confirmingPayment = true;
this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({
next: (updatedOrder) => {
this.confirmingOrderId = null;
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
if (this.selectedOrder?.id === updatedOrder.id) {
this.selectedOrder = updatedOrder;
}
this.confirmingPayment = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
this.confirmingOrderId = null;
this.confirmingPayment = false;
this.errorMessage = 'Conferma pagamento non riuscita.';
}
});
}
logout(): void {
this.adminAuthService.logout().subscribe({
next: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
updateStatus(): void {
if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) {
return;
}
this.updatingStatus = true;
this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, {
status: this.selectedStatus.trim()
}).subscribe({
next: (updatedOrder) => {
this.updatingStatus = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
this.updatingStatus = false;
this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
}
});
}
private resolveLang(): string {
for (const level of this.route.pathFromRoot) {
const lang = level.snapshot.paramMap.get('lang');
if (lang && SUPPORTED_LANGS.has(lang)) {
return lang;
}
downloadItemFile(itemId: string, filename: string): void {
if (!this.selectedOrder) {
return;
}
return 'it';
this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({
next: (blob) => {
this.downloadBlob(blob, filename || `order-item-${itemId}`);
},
error: () => {
this.errorMessage = 'Download file non riuscito.';
}
});
}
downloadConfirmation(): void {
if (!this.selectedOrder) {
return;
}
this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({
next: (blob) => {
this.downloadBlob(blob, `conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
},
error: () => {
this.errorMessage = 'Download conferma ordine non riuscito.';
}
});
}
downloadInvoice(): void {
if (!this.selectedOrder) {
return;
}
this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({
next: (blob) => {
this.downloadBlob(blob, `fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
},
error: () => {
this.errorMessage = 'Download fattura non riuscito.';
}
});
}
onStatusChange(event: Event): void {
const value = (event.target as HTMLSelectElement | null)?.value ?? '';
this.selectedStatus = value;
}
onPaymentMethodChange(event: Event): void {
const value = (event.target as HTMLSelectElement | null)?.value ?? 'OTHER';
this.selectedPaymentMethod = value;
}
openPrintDetails(): void {
this.showPrintDetails = true;
}
closePrintDetails(): void {
this.showPrintDetails = false;
}
getQualityLabel(layerHeight?: number): string {
if (!layerHeight || Number.isNaN(layerHeight)) {
return '-';
}
if (layerHeight <= 0.12) {
return 'Alta';
}
if (layerHeight <= 0.2) {
return 'Standard';
}
return 'Bozza';
}
isHexColor(value?: string): boolean {
return typeof value === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
}
isSelected(orderId: string): boolean {
return this.selectedOrder?.id === orderId;
}
private applyOrderUpdate(updatedOrder: AdminOrder): void {
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
this.refreshFilteredOrders();
this.selectedOrder = updatedOrder;
this.selectedStatus = updatedOrder.status;
this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod;
}
private refreshFilteredOrders(): void {
const term = this.orderSearchTerm.trim().toLowerCase();
if (!term) {
this.filteredOrders = [...this.orders];
return;
}
this.filteredOrders = this.orders.filter((order) => {
const fullUuid = order.id.toLowerCase();
const shortUuid = (order.orderNumber || '').toLowerCase();
return fullUuid.includes(term) || shortUuid.includes(term);
});
}
private downloadBlob(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,43 @@
<section class="section-card">
<header class="section-header">
<div>
<h2>Stock filamenti</h2>
<p>Monitoraggio quantità disponibili per variante.</p>
</div>
<button type="button" (click)="loadStock()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Materiale</th>
<th>Variante</th>
<th>Colore</th>
<th>Spool</th>
<th>Kg totali</th>
<th>Stato</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of rows">
<td>{{ row.materialCode }}</td>
<td>{{ row.variantDisplayName }}</td>
<td>{{ row.colorName }}</td>
<td>{{ row.stockSpools | number:'1.0-3' }}</td>
<td>{{ row.stockKg | number:'1.0-3' }} kg</td>
<td>
<span class="badge low" *ngIf="isLowStock(row)">Basso</span>
<span class="badge ok" *ngIf="!isLowStock(row)">OK</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento stock...</p>
</ng-template>

View File

@@ -0,0 +1,77 @@
.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);
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.78rem;
font-weight: 700;
}
.badge.low {
background: #ffebee;
color: var(--color-danger-500);
}
.badge.ok {
background: #e6f5ea;
color: #157347;
}
.error {
color: var(--color-danger-500);
}

View File

@@ -0,0 +1,41 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminFilamentStockRow, AdminOperationsService } from '../services/admin-operations.service';
@Component({
selector: 'app-admin-filament-stock',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-filament-stock.component.html',
styleUrl: './admin-filament-stock.component.scss'
})
export class AdminFilamentStockComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
rows: AdminFilamentStockRow[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadStock();
}
loadStock(): void {
this.loading = true;
this.errorMessage = null;
this.adminOperationsService.getFilamentStock().subscribe({
next: (rows) => {
this.rows = rows;
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare lo stock filamenti.';
}
});
}
isLowStock(row: AdminFilamentStockRow): boolean {
return Number(row.stockKg) < 1;
}
}

View File

@@ -1,27 +1,31 @@
<section class="admin-login-page">
<div class="admin-login-card">
<h1>Back-office</h1>
<p>Inserisci la password condivisa.</p>
<div class="container">
<section class="admin-login-page">
<div class="admin-login-card">
<h1>Back-office</h1>
<form (ngSubmit)="submit()">
<label for="admin-password">Password</label>
<input
id="admin-password"
name="password"
type="password"
[(ngModel)]="password"
[disabled]="loading"
autocomplete="current-password"
required
/>
<form (ngSubmit)="submit()">
<label for="admin-password">Password</label>
<input
id="admin-password"
name="password"
type="password"
[(ngModel)]="password"
[disabled]="loading || lockSecondsRemaining > 0"
autocomplete="current-password"
required
/>
<button type="submit" [disabled]="loading || !password.trim()">
{{ loading ? 'Accesso...' : 'Accedi' }}
</button>
</form>
<button type="submit" [disabled]="loading || !password.trim() || lockSecondsRemaining > 0">
{{ loading ? 'Accesso...' : 'Accedi' }}
</button>
</form>
@if (errorMessage) {
<p class="error">{{ errorMessage }}</p>
}
</div>
</section>
@if (errorMessage) {
<p class="error">{{ errorMessage }}</p>
}
@if (lockSecondsRemaining > 0) {
<p class="hint">Riprova tra {{ lockSecondsRemaining }}s.</p>
}
</div>
</section>
</div>

View File

@@ -3,33 +3,33 @@
justify-content: center;
align-items: center;
min-height: 70vh;
padding: 2rem 1rem;
padding: var(--space-8) 0;
}
.admin-login-card {
width: 100%;
max-width: 420px;
background: #fff;
border: 1px solid #d6dde8;
border-radius: 14px;
padding: 1.5rem;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
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);
}
h1 {
margin: 0;
font-size: 1.6rem;
font-size: 1.5rem;
}
p {
margin: 0.5rem 0 1.25rem;
color: #46546a;
margin: var(--space-2) 0 var(--space-5);
color: var(--color-text-muted);
}
form {
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: var(--space-3);
}
label {
@@ -37,20 +37,25 @@ label {
}
input {
border: 1px solid #c3cedd;
border-radius: 10px;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: 1rem;
}
button {
border: 0;
border-radius: 10px;
background: #0f3f6f;
color: #fff;
padding: 0.75rem 0.9rem;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-3) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
button:disabled {
@@ -59,6 +64,12 @@ button:disabled {
}
.error {
margin-top: 1rem;
color: #b0182a;
margin-top: var(--space-4);
margin-bottom: var(--space-2);
color: var(--color-danger-500);
}
.hint {
margin: 0;
color: var(--color-text-muted);
}

View File

@@ -1,8 +1,9 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { Component, inject, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service';
import { HttpErrorResponse } from '@angular/common/http';
import { AdminAuthResponse, AdminAuthService } from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@@ -13,7 +14,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
templateUrl: './admin-login.component.html',
styleUrl: './admin-login.component.scss'
})
export class AdminLoginComponent {
export class AdminLoginComponent implements OnDestroy {
private readonly authService = inject(AdminAuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
@@ -21,9 +22,11 @@ export class AdminLoginComponent {
password = '';
loading = false;
errorMessage: string | null = null;
lockSecondsRemaining = 0;
private lockTimer: ReturnType<typeof setInterval> | null = null;
submit(): void {
if (!this.password.trim() || this.loading) {
if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) {
return;
}
@@ -31,24 +34,25 @@ export class AdminLoginComponent {
this.errorMessage = null;
this.authService.login(this.password).subscribe({
next: (isAuthenticated) => {
next: (response: AdminAuthResponse) => {
this.loading = false;
if (!isAuthenticated) {
this.errorMessage = 'Password non valida.';
if (!response?.authenticated) {
this.handleLoginFailure(response?.retryAfterSeconds);
return;
}
this.clearLock();
const redirect = this.route.snapshot.queryParamMap.get('redirect');
if (redirect && redirect.startsWith('/')) {
void this.router.navigateByUrl(redirect);
return;
}
void this.router.navigate(['/', this.resolveLang(), 'admin']);
void this.router.navigate(['/', this.resolveLang(), 'admin', 'orders']);
},
error: () => {
error: (error: HttpErrorResponse) => {
this.loading = false;
this.errorMessage = 'Password non valida.';
this.handleLoginFailure(this.extractRetryAfterSeconds(error));
}
});
}
@@ -62,4 +66,59 @@ export class AdminLoginComponent {
}
return 'it';
}
private handleLoginFailure(retryAfterSeconds: number | undefined): void {
const timeout = this.normalizeTimeout(retryAfterSeconds);
this.errorMessage = 'Password non valida.';
this.startLock(timeout);
}
private extractRetryAfterSeconds(error: HttpErrorResponse): number {
const fromBody = Number(error?.error?.retryAfterSeconds);
if (Number.isFinite(fromBody) && fromBody > 0) {
return Math.floor(fromBody);
}
const fromHeader = Number(error?.headers?.get('Retry-After'));
if (Number.isFinite(fromHeader) && fromHeader > 0) {
return Math.floor(fromHeader);
}
return 2;
}
private normalizeTimeout(value: number | undefined): number {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return Math.floor(parsed);
}
return 2;
}
private startLock(seconds: number): void {
this.lockSecondsRemaining = Math.max(this.lockSecondsRemaining, seconds);
this.stopTimer();
this.lockTimer = setInterval(() => {
this.lockSecondsRemaining = Math.max(0, this.lockSecondsRemaining - 1);
if (this.lockSecondsRemaining === 0) {
this.stopTimer();
}
}, 1000);
}
private clearLock(): void {
this.lockSecondsRemaining = 0;
this.stopTimer();
}
private stopTimer(): void {
if (this.lockTimer !== null) {
clearInterval(this.lockTimer);
this.lockTimer = null;
}
}
ngOnDestroy(): void {
this.stopTimer();
}
}

View File

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

View File

@@ -0,0 +1,59 @@
.section-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
h2 {
margin: 0;
}
p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
button {
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.error {
color: var(--color-danger-500);
}

View File

@@ -0,0 +1,39 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
@Component({
selector: 'app-admin-orders-past',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-orders-past.component.html',
styleUrl: './admin-orders-past.component.scss'
})
export class AdminOrdersPastComponent implements OnInit {
private readonly adminOrdersService = inject(AdminOrdersService);
orders: AdminOrder[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadOrders();
}
loadOrders(): void {
this.loading = true;
this.errorMessage = null;
this.adminOrdersService.listOrders().subscribe({
next: (orders) => {
this.orders = orders.filter((order) =>
order.paymentStatus === 'COMPLETED' || order.status !== 'PENDING_PAYMENT'
);
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare gli ordini passati.';
}
});
}
}

View File

@@ -0,0 +1,40 @@
<section class="section-card">
<header class="section-header">
<div>
<h2>Sessioni quote</h2>
<p>Sessioni create dal configuratore con stato e conversione ordine.</p>
</div>
<button type="button" (click)="loadSessions()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Sessione</th>
<th>Data creazione</th>
<th>Scadenza</th>
<th>Materiale</th>
<th>Stato</th>
<th>Ordine convertito</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let session of sessions">
<td>{{ session.id }}</td>
<td>{{ session.createdAt | date:'short' }}</td>
<td>{{ session.expiresAt | date:'short' }}</td>
<td>{{ session.materialCode }}</td>
<td>{{ session.status }}</td>
<td>{{ session.convertedOrderId || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento sessioni...</p>
</ng-template>

View File

@@ -0,0 +1,59 @@
.section-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
h2 {
margin: 0;
}
p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
button {
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.error {
color: var(--color-danger-500);
}

View File

@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminOperationsService, AdminQuoteSession } from '../services/admin-operations.service';
@Component({
selector: 'app-admin-sessions',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-sessions.component.html',
styleUrl: './admin-sessions.component.scss'
})
export class AdminSessionsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
sessions: AdminQuoteSession[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadSessions();
}
loadSessions(): void {
this.loading = true;
this.errorMessage = null;
this.adminOperationsService.getSessions().subscribe({
next: (sessions) => {
this.sessions = sessions;
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare le sessioni.';
}
});
}
}

View File

@@ -0,0 +1,24 @@
<div class="container admin-container">
<section class="admin-shell">
<aside class="sidebar">
<div class="brand">
<h1>Back-office</h1>
<p>Amministrazione operativa</p>
</div>
<nav class="menu">
<a routerLink="orders" routerLinkActive="active">Ordini</a>
<a routerLink="orders-past" routerLinkActive="active">Ordini passati</a>
<a routerLink="filament-stock" routerLinkActive="active">Stock filamenti</a>
<a routerLink="contact-requests" routerLinkActive="active">Richieste contatto</a>
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>
</nav>
<button type="button" class="logout" (click)="logout()">Logout</button>
</aside>
<main class="content">
<router-outlet></router-outlet>
</main>
</section>
</div>

View File

@@ -0,0 +1,120 @@
.admin-container {
margin-top: var(--space-8);
max-width: min(1720px, 96vw);
padding: 0 var(--space-6);
}
.admin-shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: calc(100vh - 220px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--color-bg-card);
box-shadow: var(--shadow-sm);
}
.sidebar {
background: var(--color-neutral-100);
color: var(--color-text);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
border-right: 1px solid var(--color-border);
}
.brand h1 {
margin: 0;
font-size: 1.15rem;
}
.brand p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.menu {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.menu a {
text-decoration: none;
color: var(--color-text-muted);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-weight: 600;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.menu a:hover {
border-color: var(--color-brand);
color: var(--color-text);
}
.menu a.active {
background: #fff5b8;
color: var(--color-text);
border-color: var(--color-brand);
}
.logout {
margin-top: auto;
border: 1px solid var(--color-border);
color: var(--color-text);
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.logout:hover {
border-color: var(--color-brand);
background: #fff8cc;
}
.content {
background: var(--color-bg);
padding: var(--space-6);
min-width: 0;
}
@media (max-width: 960px) {
.admin-container {
margin-top: var(--space-6);
padding: 0 var(--space-4);
}
.admin-shell {
grid-template-columns: 1fr;
min-height: unset;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--color-border);
padding: var(--space-4);
}
.menu {
flex-direction: row;
flex-wrap: wrap;
}
.logout {
margin-top: var(--space-2);
align-self: flex-start;
}
.content {
padding: var(--space-4);
}
}

View File

@@ -0,0 +1,40 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@Component({
selector: 'app-admin-shell',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './admin-shell.component.html',
styleUrl: './admin-shell.component.scss'
})
export class AdminShellComponent {
private readonly adminAuthService = inject(AdminAuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
logout(): void {
this.adminAuthService.logout().subscribe({
next: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
},
error: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
}
});
}
private resolveLang(): string {
for (const level of this.route.pathFromRoot) {
const lang = level.snapshot.paramMap.get('lang');
if (lang && SUPPORTED_LANGS.has(lang)) {
return lang;
}
}
return 'it';
}
}

View File

@@ -1,10 +1,13 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { environment } from '../../../../environments/environment';
interface AdminAuthResponse {
export interface AdminAuthResponse {
authenticated: boolean;
retryAfterSeconds?: number;
expiresInMinutes?: number;
}
@Injectable({
@@ -14,10 +17,8 @@ export class AdminAuthService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`;
login(password: string): Observable<boolean> {
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true }).pipe(
map((response) => Boolean(response?.authenticated))
);
login(password: string): Observable<AdminAuthResponse> {
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true });
}
logout(): Observable<void> {

View File

@@ -0,0 +1,56 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../../environments/environment';
export interface AdminFilamentStockRow {
filamentVariantId: number;
materialCode: string;
variantDisplayName: string;
colorName: string;
stockSpools: number;
spoolNetKg: number;
stockKg: number;
active: boolean;
}
export interface AdminContactRequest {
id: string;
requestType: string;
customerType: string;
email: string;
phone?: string;
name?: string;
companyName?: string;
status: string;
createdAt: string;
}
export interface AdminQuoteSession {
id: string;
status: string;
materialCode: string;
createdAt: string;
expiresAt: string;
convertedOrderId?: string;
}
@Injectable({
providedIn: 'root'
})
export class AdminOperationsService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin`;
getFilamentStock(): Observable<AdminFilamentStockRow[]> {
return this.http.get<AdminFilamentStockRow[]>(`${this.baseUrl}/filament-stock`, { withCredentials: true });
}
getContactRequests(): Observable<AdminContactRequest[]> {
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true });
}
getSessions(): Observable<AdminQuoteSession[]> {
return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, { withCredentials: true });
}
}

View File

@@ -24,9 +24,19 @@ export interface AdminOrder {
customerEmail: string;
totalChf: number;
createdAt: string;
printMaterialCode?: string;
printNozzleDiameterMm?: number;
printLayerHeightMm?: number;
printInfillPattern?: string;
printInfillPercent?: number;
printSupportsEnabled?: boolean;
items: AdminOrderItem[];
}
export interface AdminUpdateOrderStatusPayload {
status: string;
}
@Injectable({
providedIn: 'root'
})
@@ -42,7 +52,32 @@ export class AdminOrdersService {
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, { withCredentials: true });
}
confirmPayment(orderId: string): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, {}, { withCredentials: true });
confirmPayment(orderId: string, method: string): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, { method }, { withCredentials: true });
}
updateOrderStatus(orderId: string, payload: AdminUpdateOrderStatusPayload): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/status`, payload, { withCredentials: true });
}
downloadOrderItemFile(orderId: string, orderItemId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/items/${orderItemId}/file`, {
withCredentials: true,
responseType: 'blob'
});
}
downloadOrderConfirmation(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, {
withCredentials: true,
responseType: 'blob'
});
}
downloadOrderInvoice(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, {
withCredentials: true,
responseType: 'blob'
});
}
}