feat(back-end and front-end): back-office
This commit is contained in:
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
import com.printcalculator.dto.AdminLoginRequest;
|
import com.printcalculator.dto.AdminLoginRequest;
|
||||||
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -13,26 +15,52 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.OptionalLong;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/auth")
|
@RequestMapping("/api/admin/auth")
|
||||||
public class AdminAuthController {
|
public class AdminAuthController {
|
||||||
|
|
||||||
private final AdminSessionService adminSessionService;
|
private final AdminSessionService adminSessionService;
|
||||||
|
private final AdminLoginThrottleService adminLoginThrottleService;
|
||||||
|
|
||||||
public AdminAuthController(AdminSessionService adminSessionService) {
|
public AdminAuthController(
|
||||||
|
AdminSessionService adminSessionService,
|
||||||
|
AdminLoginThrottleService adminLoginThrottleService
|
||||||
|
) {
|
||||||
this.adminSessionService = adminSessionService;
|
this.adminSessionService = adminSessionService;
|
||||||
|
this.adminLoginThrottleService = adminLoginThrottleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<Map<String, Object>> login(
|
public ResponseEntity<Map<String, Object>> login(
|
||||||
@Valid @RequestBody AdminLoginRequest request,
|
@Valid @RequestBody AdminLoginRequest request,
|
||||||
|
HttpServletRequest httpRequest,
|
||||||
HttpServletResponse response
|
HttpServletResponse response
|
||||||
) {
|
) {
|
||||||
if (!adminSessionService.isPasswordValid(request.getPassword())) {
|
String clientKey = adminLoginThrottleService.resolveClientKey(httpRequest);
|
||||||
return ResponseEntity.status(401).body(Map.of("authenticated", false));
|
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();
|
String token = adminSessionService.createSessionToken();
|
||||||
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString());
|
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString());
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@ public class OrderDto {
|
|||||||
private BigDecimal subtotalChf;
|
private BigDecimal subtotalChf;
|
||||||
private BigDecimal totalChf;
|
private BigDecimal totalChf;
|
||||||
private OffsetDateTime createdAt;
|
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;
|
private List<OrderItemDto> items;
|
||||||
|
|
||||||
// Getters and Setters
|
// Getters and Setters
|
||||||
@@ -85,6 +91,24 @@ public class OrderDto {
|
|||||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = 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 List<OrderItemDto> getItems() { return items; }
|
||||||
public void setItems(List<OrderItemDto> items) { this.items = items; }
|
public void setItems(List<OrderItemDto> items) { this.items = items; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,6 @@ app.mail.enabled=false
|
|||||||
app.mail.admin.enabled=false
|
app.mail.admin.enabled=false
|
||||||
|
|
||||||
# Admin back-office local test credentials
|
# Admin back-office local test credentials
|
||||||
admin.password=local-admin-password
|
admin.password=ciaociao
|
||||||
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789
|
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789
|
||||||
admin.session.ttl-minutes=480
|
admin.session.ttl-minutes=480
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
import com.printcalculator.config.SecurityConfig;
|
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.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
import jakarta.servlet.http.Cookie;
|
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;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@WebMvcTest(controllers = AdminAuthController.class)
|
@WebMvcTest(controllers = AdminAuthController.class)
|
||||||
@Import({SecurityConfig.class, AdminSessionAuthenticationFilter.class, AdminSessionService.class})
|
@Import({
|
||||||
|
SecurityConfig.class,
|
||||||
|
AdminSessionAuthenticationFilter.class,
|
||||||
|
AdminSessionService.class,
|
||||||
|
AdminLoginThrottleService.class
|
||||||
|
})
|
||||||
@TestPropertySource(properties = {
|
@TestPropertySource(properties = {
|
||||||
"admin.password=test-admin-password",
|
"admin.password=test-admin-password",
|
||||||
"admin.session.secret=0123456789abcdef0123456789abcdef",
|
"admin.session.secret=0123456789abcdef0123456789abcdef",
|
||||||
@@ -36,6 +43,10 @@ class AdminAuthSecurityTest {
|
|||||||
@Test
|
@Test
|
||||||
void loginOk_ShouldReturnCookie() throws Exception {
|
void loginOk_ShouldReturnCookie() throws Exception {
|
||||||
MvcResult result = mockMvc.perform(post("/api/admin/auth/login")
|
MvcResult result = mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.1");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -53,9 +64,37 @@ class AdminAuthSecurityTest {
|
|||||||
@Test
|
@Test
|
||||||
void loginKo_ShouldReturnUnauthorized() throws Exception {
|
void loginKo_ShouldReturnUnauthorized() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/auth/login")
|
mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.2");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.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));
|
.andExpect(jsonPath("$.authenticated").value(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +107,10 @@ class AdminAuthSecurityTest {
|
|||||||
@Test
|
@Test
|
||||||
void adminAccessWithValidCookie_ShouldReturn200() throws Exception {
|
void adminAccessWithValidCookie_ShouldReturn200() throws Exception {
|
||||||
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
|
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.4");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,33 @@ export const ADMIN_ROUTES: Routes = [
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
canActivate: [adminAuthGuard],
|
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)
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +1,167 @@
|
|||||||
<section class="admin-dashboard">
|
<section class="admin-dashboard">
|
||||||
<header class="dashboard-header">
|
<header class="dashboard-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Back-office ordini</h1>
|
<h1>Ordini</h1>
|
||||||
<p>Gestione pagamenti e dettaglio ordini</p>
|
<p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
|
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
|
||||||
<button type="button" class="ghost" (click)="logout()">Logout</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h2>Lista ordini</h2>
|
||||||
<tr>
|
<div class="list-toolbar">
|
||||||
<th>Ordine</th>
|
<label for="order-search">Cerca UUID</label>
|
||||||
<th>Email</th>
|
<input
|
||||||
<th>Stato</th>
|
id="order-search"
|
||||||
<th>Pagamento</th>
|
type="search"
|
||||||
<th>Totale</th>
|
[ngModel]="orderSearchTerm"
|
||||||
<th>Azioni</th>
|
(ngModelChange)="onSearchChange($event)"
|
||||||
</tr>
|
placeholder="UUID completo o prefisso (es. 738131d8)"
|
||||||
</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>
|
</div>
|
||||||
</div>
|
<div class="table-wrap">
|
||||||
</section>
|
<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>
|
</section>
|
||||||
|
|
||||||
<ng-template #loadingTpl>
|
<ng-template #loadingTpl>
|
||||||
<p>Caricamento ordini...</p>
|
<p>Caricamento ordini...</p>
|
||||||
</ng-template>
|
</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>
|
||||||
|
|||||||
@@ -1,43 +1,61 @@
|
|||||||
.admin-dashboard {
|
.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 {
|
.dashboard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: var(--space-4);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header h1 {
|
.dashboard-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.6rem;
|
font-size: 1.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header p {
|
.dashboard-header p {
|
||||||
margin: 0.35rem 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
color: #4b5a70;
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
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 {
|
button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-md);
|
||||||
background: #0f3f6f;
|
background: var(--color-brand);
|
||||||
color: #fff;
|
color: var(--color-neutral-900);
|
||||||
padding: 0.55rem 0.8rem;
|
padding: var(--space-2) var(--space-4);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.ghost {
|
button.ghost {
|
||||||
background: #eef2f8;
|
background: var(--color-bg-card);
|
||||||
color: #163a5f;
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -45,11 +63,38 @@ button:disabled {
|
|||||||
cursor: default;
|
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 {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #d8e0ec;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-md);
|
||||||
background: #fff;
|
background: var(--color-bg-card);
|
||||||
|
max-height: 72vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -58,62 +103,296 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
background: #f3f6fa;
|
background: var(--color-neutral-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.75rem;
|
padding: var(--space-3);
|
||||||
border-bottom: 1px solid #e5ebf4;
|
border-bottom: 1px solid var(--color-border);
|
||||||
vertical-align: top;
|
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;
|
display: flex;
|
||||||
gap: 0.5rem;
|
flex-wrap: wrap;
|
||||||
min-width: 210px;
|
gap: var(--space-3);
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
tr.selected {
|
.status-editor {
|
||||||
background: #f4f9ff;
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.details {
|
.status-editor label {
|
||||||
margin-top: 1rem;
|
font-size: 0.8rem;
|
||||||
background: #fff;
|
font-weight: 600;
|
||||||
border: 1px solid #d8e0ec;
|
color: var(--color-text-muted);
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.details h2 {
|
.status-editor select {
|
||||||
margin-top: 0;
|
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 {
|
.items {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
border: 1px solid #e5ebf4;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-md);
|
||||||
padding: 0.65rem;
|
padding: var(--space-3);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item p {
|
.item-main {
|
||||||
margin: 0.2rem 0;
|
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 {
|
.error {
|
||||||
color: #b4232c;
|
color: var(--color-danger-500);
|
||||||
margin-bottom: 0.9rem;
|
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) {
|
@media (max-width: 820px) {
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-grid,
|
||||||
|
.modal-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,39 @@
|
|||||||
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 { ActivatedRoute, Router } from '@angular/router';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AdminAuthService } from '../services/admin-auth.service';
|
|
||||||
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
|
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
|
||||||
|
|
||||||
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-dashboard',
|
selector: 'app-admin-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, FormsModule],
|
||||||
templateUrl: './admin-dashboard.component.html',
|
templateUrl: './admin-dashboard.component.html',
|
||||||
styleUrl: './admin-dashboard.component.scss'
|
styleUrl: './admin-dashboard.component.scss'
|
||||||
})
|
})
|
||||||
export class AdminDashboardComponent implements OnInit {
|
export class AdminDashboardComponent implements OnInit {
|
||||||
private readonly adminOrdersService = inject(AdminOrdersService);
|
private readonly adminOrdersService = inject(AdminOrdersService);
|
||||||
private readonly adminAuthService = inject(AdminAuthService);
|
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
|
|
||||||
orders: AdminOrder[] = [];
|
orders: AdminOrder[] = [];
|
||||||
|
filteredOrders: AdminOrder[] = [];
|
||||||
selectedOrder: AdminOrder | null = null;
|
selectedOrder: AdminOrder | null = null;
|
||||||
|
selectedStatus = '';
|
||||||
|
selectedPaymentMethod = 'OTHER';
|
||||||
|
orderSearchTerm = '';
|
||||||
|
showPrintDetails = false;
|
||||||
loading = false;
|
loading = false;
|
||||||
detailLoading = false;
|
detailLoading = false;
|
||||||
confirmingOrderId: string | null = null;
|
confirmingPayment = false;
|
||||||
|
updatingStatus = false;
|
||||||
errorMessage: string | null = null;
|
errorMessage: string | null = null;
|
||||||
|
readonly orderStatusOptions = [
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PAID',
|
||||||
|
'IN_PRODUCTION',
|
||||||
|
'SHIPPED',
|
||||||
|
'COMPLETED',
|
||||||
|
'CANCELLED'
|
||||||
|
];
|
||||||
|
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER'];
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadOrders();
|
this.loadOrders();
|
||||||
@@ -36,6 +45,22 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
this.adminOrdersService.listOrders().subscribe({
|
this.adminOrdersService.listOrders().subscribe({
|
||||||
next: (orders) => {
|
next: (orders) => {
|
||||||
this.orders = 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;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
error: () => {
|
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 {
|
openDetails(orderId: string): void {
|
||||||
this.detailLoading = true;
|
this.detailLoading = true;
|
||||||
this.adminOrdersService.getOrder(orderId).subscribe({
|
this.adminOrdersService.getOrder(orderId).subscribe({
|
||||||
next: (order) => {
|
next: (order) => {
|
||||||
this.selectedOrder = order;
|
this.selectedOrder = order;
|
||||||
|
this.selectedStatus = order.status;
|
||||||
|
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
|
||||||
this.detailLoading = false;
|
this.detailLoading = false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -59,41 +101,156 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmPayment(orderId: string): void {
|
confirmPayment(): void {
|
||||||
this.confirmingOrderId = orderId;
|
if (!this.selectedOrder || this.confirmingPayment) {
|
||||||
this.adminOrdersService.confirmPayment(orderId).subscribe({
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.confirmingPayment = true;
|
||||||
|
this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({
|
||||||
next: (updatedOrder) => {
|
next: (updatedOrder) => {
|
||||||
this.confirmingOrderId = null;
|
this.confirmingPayment = false;
|
||||||
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
|
this.applyOrderUpdate(updatedOrder);
|
||||||
if (this.selectedOrder?.id === updatedOrder.id) {
|
|
||||||
this.selectedOrder = updatedOrder;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.confirmingOrderId = null;
|
this.confirmingPayment = false;
|
||||||
this.errorMessage = 'Conferma pagamento non riuscita.';
|
this.errorMessage = 'Conferma pagamento non riuscita.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
updateStatus(): void {
|
||||||
this.adminAuthService.logout().subscribe({
|
if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) {
|
||||||
next: () => {
|
return;
|
||||||
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
|
}
|
||||||
|
|
||||||
|
this.updatingStatus = true;
|
||||||
|
this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, {
|
||||||
|
status: this.selectedStatus.trim()
|
||||||
|
}).subscribe({
|
||||||
|
next: (updatedOrder) => {
|
||||||
|
this.updatingStatus = false;
|
||||||
|
this.applyOrderUpdate(updatedOrder);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
|
this.updatingStatus = false;
|
||||||
|
this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveLang(): string {
|
downloadItemFile(itemId: string, filename: string): void {
|
||||||
for (const level of this.route.pathFromRoot) {
|
if (!this.selectedOrder) {
|
||||||
const lang = level.snapshot.paramMap.get('lang');
|
return;
|
||||||
if (lang && SUPPORTED_LANGS.has(lang)) {
|
|
||||||
return lang;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,31 @@
|
|||||||
<section class="admin-login-page">
|
<div class="container">
|
||||||
<div class="admin-login-card">
|
<section class="admin-login-page">
|
||||||
<h1>Back-office</h1>
|
<div class="admin-login-card">
|
||||||
<p>Inserisci la password condivisa.</p>
|
<h1>Back-office</h1>
|
||||||
|
|
||||||
<form (ngSubmit)="submit()">
|
<form (ngSubmit)="submit()">
|
||||||
<label for="admin-password">Password</label>
|
<label for="admin-password">Password</label>
|
||||||
<input
|
<input
|
||||||
id="admin-password"
|
id="admin-password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
[(ngModel)]="password"
|
[(ngModel)]="password"
|
||||||
[disabled]="loading"
|
[disabled]="loading || lockSecondsRemaining > 0"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit" [disabled]="loading || !password.trim()">
|
<button type="submit" [disabled]="loading || !password.trim() || lockSecondsRemaining > 0">
|
||||||
{{ loading ? 'Accesso...' : 'Accedi' }}
|
{{ loading ? 'Accesso...' : 'Accedi' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if (errorMessage) {
|
@if (errorMessage) {
|
||||||
<p class="error">{{ errorMessage }}</p>
|
<p class="error">{{ errorMessage }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
@if (lockSecondsRemaining > 0) {
|
||||||
</section>
|
<p class="hint">Riprova tra {{ lockSecondsRemaining }}s.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -3,33 +3,33 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 70vh;
|
min-height: 70vh;
|
||||||
padding: 2rem 1rem;
|
padding: var(--space-8) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-login-card {
|
.admin-login-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
background: #fff;
|
background: var(--color-bg-card);
|
||||||
border: 1px solid #d6dde8;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.5rem;
|
padding: var(--space-6);
|
||||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.6rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0.5rem 0 1.25rem;
|
margin: var(--space-2) 0 var(--space-5);
|
||||||
color: #46546a;
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
@@ -37,20 +37,25 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
border: 1px solid #c3cedd;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-md);
|
||||||
padding: 0.75rem;
|
padding: var(--space-3);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-md);
|
||||||
background: #0f3f6f;
|
background: var(--color-brand);
|
||||||
color: #fff;
|
color: var(--color-neutral-900);
|
||||||
padding: 0.75rem 0.9rem;
|
padding: var(--space-3) var(--space-4);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -59,6 +64,12 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
margin-top: 1rem;
|
margin-top: var(--space-4);
|
||||||
color: #b0182a;
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--color-danger-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, OnDestroy } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
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']);
|
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',
|
templateUrl: './admin-login.component.html',
|
||||||
styleUrl: './admin-login.component.scss'
|
styleUrl: './admin-login.component.scss'
|
||||||
})
|
})
|
||||||
export class AdminLoginComponent {
|
export class AdminLoginComponent implements OnDestroy {
|
||||||
private readonly authService = inject(AdminAuthService);
|
private readonly authService = inject(AdminAuthService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
@@ -21,9 +22,11 @@ export class AdminLoginComponent {
|
|||||||
password = '';
|
password = '';
|
||||||
loading = false;
|
loading = false;
|
||||||
errorMessage: string | null = null;
|
errorMessage: string | null = null;
|
||||||
|
lockSecondsRemaining = 0;
|
||||||
|
private lockTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.password.trim() || this.loading) {
|
if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,24 +34,25 @@ export class AdminLoginComponent {
|
|||||||
this.errorMessage = null;
|
this.errorMessage = null;
|
||||||
|
|
||||||
this.authService.login(this.password).subscribe({
|
this.authService.login(this.password).subscribe({
|
||||||
next: (isAuthenticated) => {
|
next: (response: AdminAuthResponse) => {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
if (!isAuthenticated) {
|
if (!response?.authenticated) {
|
||||||
this.errorMessage = 'Password non valida.';
|
this.handleLoginFailure(response?.retryAfterSeconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearLock();
|
||||||
const redirect = this.route.snapshot.queryParamMap.get('redirect');
|
const redirect = this.route.snapshot.queryParamMap.get('redirect');
|
||||||
if (redirect && redirect.startsWith('/')) {
|
if (redirect && redirect.startsWith('/')) {
|
||||||
void this.router.navigateByUrl(redirect);
|
void this.router.navigateByUrl(redirect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.router.navigate(['/', this.resolveLang(), 'admin']);
|
void this.router.navigate(['/', this.resolveLang(), 'admin', 'orders']);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (error: HttpErrorResponse) => {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.errorMessage = 'Password non valida.';
|
this.handleLoginFailure(this.extractRetryAfterSeconds(error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,4 +66,59 @@ export class AdminLoginComponent {
|
|||||||
}
|
}
|
||||||
return 'it';
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
120
frontend/src/app/features/admin/pages/admin-shell.component.scss
Normal file
120
frontend/src/app/features/admin/pages/admin-shell.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
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';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
interface AdminAuthResponse {
|
export interface AdminAuthResponse {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
expiresInMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -14,10 +17,8 @@ export class AdminAuthService {
|
|||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`;
|
private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`;
|
||||||
|
|
||||||
login(password: string): Observable<boolean> {
|
login(password: string): Observable<AdminAuthResponse> {
|
||||||
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true }).pipe(
|
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true });
|
||||||
map((response) => Boolean(response?.authenticated))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): Observable<void> {
|
logout(): Observable<void> {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,19 @@ export interface AdminOrder {
|
|||||||
customerEmail: string;
|
customerEmail: string;
|
||||||
totalChf: number;
|
totalChf: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
printMaterialCode?: string;
|
||||||
|
printNozzleDiameterMm?: number;
|
||||||
|
printLayerHeightMm?: number;
|
||||||
|
printInfillPattern?: string;
|
||||||
|
printInfillPercent?: number;
|
||||||
|
printSupportsEnabled?: boolean;
|
||||||
items: AdminOrderItem[];
|
items: AdminOrderItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminUpdateOrderStatusPayload {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -42,7 +52,32 @@ export class AdminOrdersService {
|
|||||||
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, { withCredentials: true });
|
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, { withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmPayment(orderId: string): Observable<AdminOrder> {
|
confirmPayment(orderId: string, method: string): Observable<AdminOrder> {
|
||||||
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, {}, { withCredentials: true });
|
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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user