feat(back-end): bill and qr
This commit is contained in:
@@ -25,13 +25,21 @@ repositories {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
implementation 'xyz.capybara:clamav-client:2.1.2'
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testRuntimeOnly 'com.h2database:h2'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
implementation 'xyz.capybara:clamav-client:2.1.2'
|
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
implementation 'io.github.openhtmltopdf:openhtmltopdf-pdfbox:1.1.37'
|
||||||
|
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
|
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.dto.*;
|
||||||
import com.printcalculator.entity.*;
|
import com.printcalculator.entity.*;
|
||||||
import com.printcalculator.repository.*;
|
import com.printcalculator.repository.*;
|
||||||
|
import com.printcalculator.service.InvoicePdfRenderingService;
|
||||||
|
import com.printcalculator.service.OrderService;
|
||||||
|
import com.printcalculator.service.QrBillService;
|
||||||
|
import com.printcalculator.service.StorageService;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -15,209 +19,61 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.Optional;
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/orders")
|
@RequestMapping("/api/orders")
|
||||||
public class OrderController {
|
public class OrderController {
|
||||||
|
|
||||||
|
private final OrderService orderService;
|
||||||
private final OrderRepository orderRepo;
|
private final OrderRepository orderRepo;
|
||||||
private final OrderItemRepository orderItemRepo;
|
private final OrderItemRepository orderItemRepo;
|
||||||
private final QuoteSessionRepository quoteSessionRepo;
|
private final QuoteSessionRepository quoteSessionRepo;
|
||||||
private final QuoteLineItemRepository quoteLineItemRepo;
|
private final QuoteLineItemRepository quoteLineItemRepo;
|
||||||
private final CustomerRepository customerRepo;
|
private final CustomerRepository customerRepo;
|
||||||
private final com.printcalculator.service.ClamAVService clamAVService;
|
private final StorageService storageService;
|
||||||
|
private final InvoicePdfRenderingService invoiceService;
|
||||||
|
private final QrBillService qrBillService;
|
||||||
|
|
||||||
// TODO: Inject Storage Service or use a base path property
|
|
||||||
private static final String STORAGE_ROOT = "storage_orders";
|
|
||||||
|
|
||||||
public OrderController(OrderRepository orderRepo,
|
public OrderController(OrderService orderService,
|
||||||
|
OrderRepository orderRepo,
|
||||||
OrderItemRepository orderItemRepo,
|
OrderItemRepository orderItemRepo,
|
||||||
QuoteSessionRepository quoteSessionRepo,
|
QuoteSessionRepository quoteSessionRepo,
|
||||||
QuoteLineItemRepository quoteLineItemRepo,
|
QuoteLineItemRepository quoteLineItemRepo,
|
||||||
CustomerRepository customerRepo,
|
CustomerRepository customerRepo,
|
||||||
com.printcalculator.service.ClamAVService clamAVService) {
|
StorageService storageService,
|
||||||
|
InvoicePdfRenderingService invoiceService,
|
||||||
|
QrBillService qrBillService) {
|
||||||
|
this.orderService = orderService;
|
||||||
this.orderRepo = orderRepo;
|
this.orderRepo = orderRepo;
|
||||||
this.orderItemRepo = orderItemRepo;
|
this.orderItemRepo = orderItemRepo;
|
||||||
this.quoteSessionRepo = quoteSessionRepo;
|
this.quoteSessionRepo = quoteSessionRepo;
|
||||||
this.quoteLineItemRepo = quoteLineItemRepo;
|
this.quoteLineItemRepo = quoteLineItemRepo;
|
||||||
this.customerRepo = customerRepo;
|
this.customerRepo = customerRepo;
|
||||||
this.clamAVService = clamAVService;
|
this.storageService = storageService;
|
||||||
|
this.invoiceService = invoiceService;
|
||||||
|
this.qrBillService = qrBillService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 1. Create Order from Quote
|
// 1. Create Order from Quote
|
||||||
@PostMapping("/from-quote/{quoteSessionId}")
|
@PostMapping("/from-quote/{quoteSessionId}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<Order> createOrderFromQuote(
|
public ResponseEntity<OrderDto> createOrderFromQuote(
|
||||||
@PathVariable UUID quoteSessionId,
|
@PathVariable UUID quoteSessionId,
|
||||||
@RequestBody com.printcalculator.dto.CreateOrderRequest request
|
@RequestBody com.printcalculator.dto.CreateOrderRequest request
|
||||||
) {
|
) {
|
||||||
// 1. Fetch Quote Session
|
Order order = orderService.createOrderFromQuote(quoteSessionId, request);
|
||||||
QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
|
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
|
||||||
.orElseThrow(() -> new RuntimeException("Quote Session not found"));
|
return ResponseEntity.ok(convertToDto(order, items));
|
||||||
|
|
||||||
if (!"ACTIVE".equals(session.getStatus())) {
|
|
||||||
// Allow converting only active sessions? Or check if not already converted?
|
|
||||||
// checking convertedOrderId might be better
|
|
||||||
}
|
|
||||||
if (session.getConvertedOrderId() != null) {
|
|
||||||
return ResponseEntity.badRequest().body(null); // Already converted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle Customer (Find or Create)
|
|
||||||
Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail())
|
|
||||||
.orElseGet(() -> {
|
|
||||||
Customer newC = new Customer();
|
|
||||||
newC.setEmail(request.getCustomer().getEmail());
|
|
||||||
newC.setCreatedAt(OffsetDateTime.now());
|
|
||||||
return customerRepo.save(newC);
|
|
||||||
});
|
|
||||||
// Update customer details?
|
|
||||||
customer.setPhone(request.getCustomer().getPhone());
|
|
||||||
customer.setCustomerType(request.getCustomer().getCustomerType());
|
|
||||||
customer.setUpdatedAt(OffsetDateTime.now());
|
|
||||||
customerRepo.save(customer);
|
|
||||||
|
|
||||||
// 3. Create Order
|
|
||||||
Order order = new Order();
|
|
||||||
order.setSourceQuoteSession(session);
|
|
||||||
order.setCustomer(customer);
|
|
||||||
order.setCustomerEmail(request.getCustomer().getEmail());
|
|
||||||
order.setCustomerPhone(request.getCustomer().getPhone());
|
|
||||||
order.setStatus("PENDING_PAYMENT");
|
|
||||||
order.setCreatedAt(OffsetDateTime.now());
|
|
||||||
order.setUpdatedAt(OffsetDateTime.now());
|
|
||||||
order.setCurrency("CHF");
|
|
||||||
// Initialize all NOT NULL monetary fields before first persist.
|
|
||||||
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
|
|
||||||
order.setShippingCostChf(BigDecimal.ZERO);
|
|
||||||
order.setDiscountChf(BigDecimal.ZERO);
|
|
||||||
order.setSubtotalChf(BigDecimal.ZERO);
|
|
||||||
order.setTotalChf(BigDecimal.ZERO);
|
|
||||||
|
|
||||||
// Billing
|
|
||||||
order.setBillingCustomerType(request.getCustomer().getCustomerType());
|
|
||||||
if (request.getBillingAddress() != null) {
|
|
||||||
order.setBillingFirstName(request.getBillingAddress().getFirstName());
|
|
||||||
order.setBillingLastName(request.getBillingAddress().getLastName());
|
|
||||||
order.setBillingCompanyName(request.getBillingAddress().getCompanyName());
|
|
||||||
order.setBillingContactPerson(request.getBillingAddress().getContactPerson());
|
|
||||||
order.setBillingAddressLine1(request.getBillingAddress().getAddressLine1());
|
|
||||||
order.setBillingAddressLine2(request.getBillingAddress().getAddressLine2());
|
|
||||||
order.setBillingZip(request.getBillingAddress().getZip());
|
|
||||||
order.setBillingCity(request.getBillingAddress().getCity());
|
|
||||||
order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shipping
|
|
||||||
order.setShippingSameAsBilling(request.isShippingSameAsBilling());
|
|
||||||
if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) {
|
|
||||||
order.setShippingFirstName(request.getShippingAddress().getFirstName());
|
|
||||||
order.setShippingLastName(request.getShippingAddress().getLastName());
|
|
||||||
order.setShippingCompanyName(request.getShippingAddress().getCompanyName());
|
|
||||||
order.setShippingContactPerson(request.getShippingAddress().getContactPerson());
|
|
||||||
order.setShippingAddressLine1(request.getShippingAddress().getAddressLine1());
|
|
||||||
order.setShippingAddressLine2(request.getShippingAddress().getAddressLine2());
|
|
||||||
order.setShippingZip(request.getShippingAddress().getZip());
|
|
||||||
order.setShippingCity(request.getShippingAddress().getCity());
|
|
||||||
order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH");
|
|
||||||
} else {
|
|
||||||
// Copy billing to shipping? Or leave empty and rely on flag?
|
|
||||||
// Usually explicit copy is safer for queries
|
|
||||||
order.setShippingFirstName(order.getBillingFirstName());
|
|
||||||
order.setShippingLastName(order.getBillingLastName());
|
|
||||||
order.setShippingCompanyName(order.getBillingCompanyName());
|
|
||||||
order.setShippingContactPerson(order.getBillingContactPerson());
|
|
||||||
order.setShippingAddressLine1(order.getBillingAddressLine1());
|
|
||||||
order.setShippingAddressLine2(order.getBillingAddressLine2());
|
|
||||||
order.setShippingZip(order.getBillingZip());
|
|
||||||
order.setShippingCity(order.getBillingCity());
|
|
||||||
order.setShippingCountryCode(order.getBillingCountryCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Financials from Session (Assuming mocked/calculated in session)
|
|
||||||
// We re-calculate totals from line items to be safe
|
|
||||||
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
|
|
||||||
|
|
||||||
BigDecimal subtotal = BigDecimal.ZERO;
|
|
||||||
|
|
||||||
// Save Order first to get ID
|
|
||||||
order = orderRepo.save(order);
|
|
||||||
|
|
||||||
// 4. Create Order Items
|
|
||||||
for (QuoteLineItem qItem : quoteItems) {
|
|
||||||
OrderItem oItem = new OrderItem();
|
|
||||||
oItem.setOrder(order);
|
|
||||||
oItem.setOriginalFilename(qItem.getOriginalFilename());
|
|
||||||
oItem.setQuantity(qItem.getQuantity());
|
|
||||||
oItem.setColorCode(qItem.getColorCode());
|
|
||||||
oItem.setMaterialCode(session.getMaterialCode()); // Or per item if supported
|
|
||||||
|
|
||||||
// Pricing
|
|
||||||
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
|
|
||||||
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
|
||||||
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
|
|
||||||
oItem.setMaterialGrams(qItem.getMaterialGrams());
|
|
||||||
|
|
||||||
// File Handling Check
|
|
||||||
// "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}"
|
|
||||||
UUID fileUuid = UUID.randomUUID();
|
|
||||||
String ext = getExtension(qItem.getOriginalFilename());
|
|
||||||
String storedFilename = fileUuid.toString() + "." + ext;
|
|
||||||
|
|
||||||
oItem.setStoredFilename(storedFilename);
|
|
||||||
oItem.setStoredRelativePath("PENDING"); // Placeholder
|
|
||||||
oItem.setMimeType("application/octet-stream"); // specific type if known
|
|
||||||
|
|
||||||
oItem = orderItemRepo.save(oItem);
|
|
||||||
|
|
||||||
// Update Path now that we have ID
|
|
||||||
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
|
||||||
oItem.setStoredRelativePath(relativePath);
|
|
||||||
|
|
||||||
// COPY FILE from Quote to Order
|
|
||||||
if (qItem.getStoredPath() != null) {
|
|
||||||
try {
|
|
||||||
Path sourcePath = Paths.get(qItem.getStoredPath());
|
|
||||||
if (Files.exists(sourcePath)) {
|
|
||||||
Path targetPath = Paths.get(STORAGE_ROOT, relativePath);
|
|
||||||
Files.createDirectories(targetPath.getParent());
|
|
||||||
Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
|
|
||||||
oItem.setFileSizeBytes(Files.size(targetPath));
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace(); // Log error but allow order creation? Or fail?
|
|
||||||
// Ideally fail or mark as error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
orderItemRepo.save(oItem);
|
|
||||||
|
|
||||||
subtotal = subtotal.add(oItem.getLineTotalChf());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Order Totals
|
|
||||||
order.setSubtotalChf(subtotal);
|
|
||||||
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
|
|
||||||
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
|
|
||||||
// TODO: Calc implementation for shipping
|
|
||||||
|
|
||||||
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
|
||||||
order.setTotalChf(total);
|
|
||||||
|
|
||||||
// Link session
|
|
||||||
session.setConvertedOrderId(order.getId());
|
|
||||||
session.setStatus("CONVERTED"); // or CLOSED
|
|
||||||
quoteSessionRepo.save(session);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(orderRepo.save(order));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Upload file for Order Item
|
|
||||||
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<Void> uploadOrderItemFile(
|
public ResponseEntity<Void> uploadOrderItemFile(
|
||||||
@@ -233,42 +89,103 @@ public class OrderController {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan for virus
|
|
||||||
clamAVService.scan(file.getInputStream());
|
|
||||||
|
|
||||||
// Ensure path logic
|
|
||||||
String relativePath = item.getStoredRelativePath();
|
String relativePath = item.getStoredRelativePath();
|
||||||
if (relativePath == null || relativePath.equals("PENDING")) {
|
if (relativePath == null || relativePath.equals("PENDING")) {
|
||||||
// Should verify consistency
|
|
||||||
// If we used the logic above, it should have a path.
|
|
||||||
// If it's "PENDING", regen it.
|
|
||||||
String ext = getExtension(file.getOriginalFilename());
|
String ext = getExtension(file.getOriginalFilename());
|
||||||
String storedFilename = UUID.randomUUID().toString() + "." + ext;
|
String storedFilename = UUID.randomUUID().toString() + "." + ext;
|
||||||
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
|
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
|
||||||
item.setStoredRelativePath(relativePath);
|
item.setStoredRelativePath(relativePath);
|
||||||
item.setStoredFilename(storedFilename);
|
item.setStoredFilename(storedFilename);
|
||||||
// Update item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file to disk
|
storageService.store(file, Paths.get(relativePath));
|
||||||
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
|
|
||||||
Files.createDirectories(absolutePath.getParent());
|
|
||||||
|
|
||||||
if (Files.exists(absolutePath)) {
|
|
||||||
Files.delete(absolutePath); // Overwrite?
|
|
||||||
}
|
|
||||||
|
|
||||||
Files.copy(file.getInputStream(), absolutePath);
|
|
||||||
|
|
||||||
item.setFileSizeBytes(file.getSize());
|
item.setFileSizeBytes(file.getSize());
|
||||||
item.setMimeType(file.getContentType());
|
item.setMimeType(file.getContentType());
|
||||||
// Calculate SHA256? (Optional)
|
|
||||||
|
|
||||||
orderItemRepo.save(item);
|
orderItemRepo.save(item);
|
||||||
|
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{orderId}")
|
||||||
|
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
|
||||||
|
return orderRepo.findById(orderId)
|
||||||
|
.map(o -> {
|
||||||
|
List<OrderItem> items = orderItemRepo.findByOrder_Id(o.getId());
|
||||||
|
return ResponseEntity.ok(convertToDto(o, items));
|
||||||
|
})
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{orderId}/invoice")
|
||||||
|
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
|
||||||
|
Order order = orderRepo.findById(orderId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Order not found"));
|
||||||
|
|
||||||
|
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
|
||||||
|
|
||||||
|
Map<String, Object> vars = new HashMap<>();
|
||||||
|
vars.put("sellerDisplayName", "3D Fab Switzerland");
|
||||||
|
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
|
||||||
|
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
||||||
|
vars.put("sellerEmail", "info@3dfab.ch");
|
||||||
|
|
||||||
|
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
|
||||||
|
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
|
||||||
|
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
|
||||||
|
? order.getBillingCompanyName()
|
||||||
|
: order.getBillingFirstName() + " " + order.getBillingLastName();
|
||||||
|
vars.put("buyerDisplayName", buyerName);
|
||||||
|
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
|
||||||
|
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
|
||||||
|
|
||||||
|
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
|
||||||
|
Map<String, Object> line = new HashMap<>();
|
||||||
|
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
|
||||||
|
line.put("quantity", i.getQuantity());
|
||||||
|
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
|
||||||
|
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
|
||||||
|
return line;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<String, Object> setupLine = new HashMap<>();
|
||||||
|
setupLine.put("description", "Costo Setup");
|
||||||
|
setupLine.put("quantity", 1);
|
||||||
|
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||||
|
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||||
|
invoiceLineItems.add(setupLine);
|
||||||
|
|
||||||
|
Map<String, Object> shippingLine = new HashMap<>();
|
||||||
|
shippingLine.put("description", "Spedizione");
|
||||||
|
shippingLine.put("quantity", 1);
|
||||||
|
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||||
|
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||||
|
invoiceLineItems.add(shippingLine);
|
||||||
|
|
||||||
|
vars.put("invoiceLineItems", invoiceLineItems);
|
||||||
|
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
|
||||||
|
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
|
||||||
|
vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie.");
|
||||||
|
|
||||||
|
String qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
|
||||||
|
if (qrBillSvg.contains("<?xml")) {
|
||||||
|
int svgStartIndex = qrBillSvg.indexOf("<svg");
|
||||||
|
if (svgStartIndex != -1) {
|
||||||
|
qrBillSvg = qrBillSvg.substring(svgStartIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
private String getExtension(String filename) {
|
private String getExtension(String filename) {
|
||||||
if (filename == null) return "stl";
|
if (filename == null) return "stl";
|
||||||
int i = filename.lastIndexOf('.');
|
int i = filename.lastIndexOf('.');
|
||||||
@@ -278,4 +195,64 @@ public class OrderController {
|
|||||||
return "stl";
|
return "stl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private OrderDto convertToDto(Order order, List<OrderItem> items) {
|
||||||
|
OrderDto dto = new OrderDto();
|
||||||
|
dto.setId(order.getId());
|
||||||
|
dto.setStatus(order.getStatus());
|
||||||
|
dto.setCustomerEmail(order.getCustomerEmail());
|
||||||
|
dto.setCustomerPhone(order.getCustomerPhone());
|
||||||
|
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 (!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;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
dto.setItems(itemDtos);
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
74
backend/src/main/java/com/printcalculator/dto/OrderDto.java
Normal file
74
backend/src/main/java/com/printcalculator/dto/OrderDto.java
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class OrderDto {
|
||||||
|
private UUID id;
|
||||||
|
private String status;
|
||||||
|
private String customerEmail;
|
||||||
|
private String customerPhone;
|
||||||
|
private String billingCustomerType;
|
||||||
|
private AddressDto billingAddress;
|
||||||
|
private AddressDto shippingAddress;
|
||||||
|
private Boolean shippingSameAsBilling;
|
||||||
|
private String currency;
|
||||||
|
private BigDecimal setupCostChf;
|
||||||
|
private BigDecimal shippingCostChf;
|
||||||
|
private BigDecimal discountChf;
|
||||||
|
private BigDecimal subtotalChf;
|
||||||
|
private BigDecimal totalChf;
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
private List<OrderItemDto> items;
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
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 getCustomerEmail() { return customerEmail; }
|
||||||
|
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
|
||||||
|
|
||||||
|
public String getCustomerPhone() { return customerPhone; }
|
||||||
|
public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; }
|
||||||
|
|
||||||
|
public String getBillingCustomerType() { return billingCustomerType; }
|
||||||
|
public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; }
|
||||||
|
|
||||||
|
public AddressDto getBillingAddress() { return billingAddress; }
|
||||||
|
public void setBillingAddress(AddressDto billingAddress) { this.billingAddress = billingAddress; }
|
||||||
|
|
||||||
|
public AddressDto getShippingAddress() { return shippingAddress; }
|
||||||
|
public void setShippingAddress(AddressDto shippingAddress) { this.shippingAddress = shippingAddress; }
|
||||||
|
|
||||||
|
public Boolean getShippingSameAsBilling() { return shippingSameAsBilling; }
|
||||||
|
public void setShippingSameAsBilling(Boolean shippingSameAsBilling) { this.shippingSameAsBilling = shippingSameAsBilling; }
|
||||||
|
|
||||||
|
public String getCurrency() { return currency; }
|
||||||
|
public void setCurrency(String currency) { this.currency = currency; }
|
||||||
|
|
||||||
|
public BigDecimal getSetupCostChf() { return setupCostChf; }
|
||||||
|
public void setSetupCostChf(BigDecimal setupCostChf) { this.setupCostChf = setupCostChf; }
|
||||||
|
|
||||||
|
public BigDecimal getShippingCostChf() { return shippingCostChf; }
|
||||||
|
public void setShippingCostChf(BigDecimal shippingCostChf) { this.shippingCostChf = shippingCostChf; }
|
||||||
|
|
||||||
|
public BigDecimal getDiscountChf() { return discountChf; }
|
||||||
|
public void setDiscountChf(BigDecimal discountChf) { this.discountChf = discountChf; }
|
||||||
|
|
||||||
|
public BigDecimal getSubtotalChf() { return subtotalChf; }
|
||||||
|
public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; }
|
||||||
|
|
||||||
|
public BigDecimal getTotalChf() { return totalChf; }
|
||||||
|
public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; }
|
||||||
|
|
||||||
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
public List<OrderItemDto> getItems() { return items; }
|
||||||
|
public void setItems(List<OrderItemDto> items) { this.items = items; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class OrderItemDto {
|
||||||
|
private UUID id;
|
||||||
|
private String originalFilename;
|
||||||
|
private String materialCode;
|
||||||
|
private String colorCode;
|
||||||
|
private Integer quantity;
|
||||||
|
private Integer printTimeSeconds;
|
||||||
|
private BigDecimal materialGrams;
|
||||||
|
private BigDecimal unitPriceChf;
|
||||||
|
private BigDecimal lineTotalChf;
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getOriginalFilename() { return originalFilename; }
|
||||||
|
public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; }
|
||||||
|
|
||||||
|
public String getMaterialCode() { return materialCode; }
|
||||||
|
public void setMaterialCode(String materialCode) { this.materialCode = materialCode; }
|
||||||
|
|
||||||
|
public String getColorCode() { return colorCode; }
|
||||||
|
public void setColorCode(String colorCode) { this.colorCode = colorCode; }
|
||||||
|
|
||||||
|
public Integer getQuantity() { return quantity; }
|
||||||
|
public void setQuantity(Integer quantity) { this.quantity = quantity; }
|
||||||
|
|
||||||
|
public Integer getPrintTimeSeconds() { return printTimeSeconds; }
|
||||||
|
public void setPrintTimeSeconds(Integer printTimeSeconds) { this.printTimeSeconds = printTimeSeconds; }
|
||||||
|
|
||||||
|
public BigDecimal getMaterialGrams() { return materialGrams; }
|
||||||
|
public void setMaterialGrams(BigDecimal materialGrams) { this.materialGrams = materialGrams; }
|
||||||
|
|
||||||
|
public BigDecimal getUnitPriceChf() { return unitPriceChf; }
|
||||||
|
public void setUnitPriceChf(BigDecimal unitPriceChf) { this.unitPriceChf = unitPriceChf; }
|
||||||
|
|
||||||
|
public BigDecimal getLineTotalChf() { return lineTotalChf; }
|
||||||
|
public void setLineTotalChf(BigDecimal lineTotalChf) { this.lineTotalChf = lineTotalChf; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.printcalculator.exception;
|
||||||
|
|
||||||
|
public class StorageException extends RuntimeException {
|
||||||
|
|
||||||
|
public StorageException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StorageException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
|||||||
import com.printcalculator.entity.OrderItem;
|
import com.printcalculator.entity.OrderItem;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
|
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
|
||||||
|
List<OrderItem> findByOrder_Id(UUID orderId);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.UrlResource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import com.printcalculator.exception.StorageException;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FileSystemStorageService implements StorageService {
|
||||||
|
|
||||||
|
private final Path rootLocation;
|
||||||
|
private final ClamAVService clamAVService;
|
||||||
|
|
||||||
|
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
|
||||||
|
this.rootLocation = Paths.get(storageLocation);
|
||||||
|
this.clamAVService = clamAVService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(rootLocation);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new StorageException("Could not initialize storage", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
|
||||||
|
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||||
|
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||||
|
throw new StorageException("Cannot store file outside current directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi
|
||||||
|
Files.createDirectories(destinationFile.getParent());
|
||||||
|
file.transferTo(destinationFile.toFile());
|
||||||
|
|
||||||
|
// 2. Scansiona il file appena salvato aprendo un nuovo stream
|
||||||
|
try (InputStream inputStream = new FileInputStream(destinationFile.toFile())) {
|
||||||
|
if (!clamAVService.scan(inputStream)) {
|
||||||
|
// Se infetto, cancella il file e solleva eccezione
|
||||||
|
Files.deleteIfExists(destinationFile);
|
||||||
|
throw new StorageException("File rejected by antivirus scanner.");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (e instanceof StorageException) throw e;
|
||||||
|
// Se l'antivirus fallisce per motivi tecnici, lasciamo il file (fail-open come concordato)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void store(Path source, Path destinationRelativePath) throws IOException {
|
||||||
|
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||||
|
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||||
|
throw new StorageException("Cannot store file outside current directory.");
|
||||||
|
}
|
||||||
|
Files.createDirectories(destinationFile.getParent());
|
||||||
|
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(Path path) throws IOException {
|
||||||
|
Path file = rootLocation.resolve(path);
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Resource loadAsResource(Path path) throws IOException {
|
||||||
|
try {
|
||||||
|
Path file = rootLocation.resolve(path);
|
||||||
|
Resource resource = new UrlResource(file.toUri());
|
||||||
|
if (resource.exists() || resource.isReadable()) {
|
||||||
|
return resource;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("Could not read file: " + path);
|
||||||
|
}
|
||||||
|
} catch (MalformedURLException e) {
|
||||||
|
throw new RuntimeException("Could not read file: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||||
|
import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class InvoicePdfRenderingService {
|
||||||
|
|
||||||
|
private final TemplateEngine thymeleafTemplateEngine;
|
||||||
|
|
||||||
|
public InvoicePdfRenderingService(TemplateEngine thymeleafTemplateEngine) {
|
||||||
|
this.thymeleafTemplateEngine = thymeleafTemplateEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] generateInvoicePdfBytesFromTemplate(Map<String, Object> invoiceTemplateVariables, String qrBillSvg) {
|
||||||
|
try {
|
||||||
|
Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY);
|
||||||
|
thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables);
|
||||||
|
thymeleafContextWithInvoiceData.setVariable("qrBillSvg", qrBillSvg);
|
||||||
|
|
||||||
|
String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData);
|
||||||
|
|
||||||
|
String classpathBaseUrlForHtmlResources = new ClassPathResource("templates/").getURL().toExternalForm();
|
||||||
|
|
||||||
|
ByteArrayOutputStream generatedPdfByteArrayOutputStream = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
PdfRendererBuilder openHtmlToPdfRendererBuilder = new PdfRendererBuilder();
|
||||||
|
openHtmlToPdfRendererBuilder.useFastMode();
|
||||||
|
openHtmlToPdfRendererBuilder.useSVGDrawer(new BatikSVGDrawer());
|
||||||
|
openHtmlToPdfRendererBuilder.withHtmlContent(renderedInvoiceHtml, classpathBaseUrlForHtmlResources);
|
||||||
|
openHtmlToPdfRendererBuilder.toStream(generatedPdfByteArrayOutputStream);
|
||||||
|
openHtmlToPdfRendererBuilder.run();
|
||||||
|
|
||||||
|
return generatedPdfByteArrayOutputStream.toByteArray();
|
||||||
|
} catch (Exception pdfGenerationException) {
|
||||||
|
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.dto.AddressDto;
|
||||||
|
import com.printcalculator.dto.CreateOrderRequest;
|
||||||
|
import com.printcalculator.entity.*;
|
||||||
|
import com.printcalculator.repository.CustomerRepository;
|
||||||
|
import com.printcalculator.repository.OrderItemRepository;
|
||||||
|
import com.printcalculator.repository.OrderRepository;
|
||||||
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final OrderRepository orderRepo;
|
||||||
|
private final OrderItemRepository orderItemRepo;
|
||||||
|
private final QuoteSessionRepository quoteSessionRepo;
|
||||||
|
private final QuoteLineItemRepository quoteLineItemRepo;
|
||||||
|
private final CustomerRepository customerRepo;
|
||||||
|
private final StorageService storageService;
|
||||||
|
private final InvoicePdfRenderingService invoiceService;
|
||||||
|
private final QrBillService qrBillService;
|
||||||
|
|
||||||
|
public OrderService(OrderRepository orderRepo,
|
||||||
|
OrderItemRepository orderItemRepo,
|
||||||
|
QuoteSessionRepository quoteSessionRepo,
|
||||||
|
QuoteLineItemRepository quoteLineItemRepo,
|
||||||
|
CustomerRepository customerRepo,
|
||||||
|
StorageService storageService,
|
||||||
|
InvoicePdfRenderingService invoiceService,
|
||||||
|
QrBillService qrBillService) {
|
||||||
|
this.orderRepo = orderRepo;
|
||||||
|
this.orderItemRepo = orderItemRepo;
|
||||||
|
this.quoteSessionRepo = quoteSessionRepo;
|
||||||
|
this.quoteLineItemRepo = quoteLineItemRepo;
|
||||||
|
this.customerRepo = customerRepo;
|
||||||
|
this.storageService = storageService;
|
||||||
|
this.invoiceService = invoiceService;
|
||||||
|
this.qrBillService = qrBillService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
|
||||||
|
QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Quote Session not found"));
|
||||||
|
|
||||||
|
if (session.getConvertedOrderId() != null) {
|
||||||
|
throw new IllegalStateException("Quote session already converted to order");
|
||||||
|
}
|
||||||
|
|
||||||
|
Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Customer newC = new Customer();
|
||||||
|
newC.setEmail(request.getCustomer().getEmail());
|
||||||
|
newC.setCustomerType(request.getCustomer().getCustomerType());
|
||||||
|
newC.setCreatedAt(OffsetDateTime.now());
|
||||||
|
newC.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
return customerRepo.save(newC);
|
||||||
|
});
|
||||||
|
|
||||||
|
customer.setPhone(request.getCustomer().getPhone());
|
||||||
|
customer.setCustomerType(request.getCustomer().getCustomerType());
|
||||||
|
customer.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
customerRepo.save(customer);
|
||||||
|
|
||||||
|
Order order = new Order();
|
||||||
|
order.setSourceQuoteSession(session);
|
||||||
|
order.setCustomer(customer);
|
||||||
|
order.setCustomerEmail(request.getCustomer().getEmail());
|
||||||
|
order.setCustomerPhone(request.getCustomer().getPhone());
|
||||||
|
order.setStatus("PENDING_PAYMENT");
|
||||||
|
order.setCreatedAt(OffsetDateTime.now());
|
||||||
|
order.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
order.setCurrency("CHF");
|
||||||
|
|
||||||
|
order.setBillingCustomerType(request.getCustomer().getCustomerType());
|
||||||
|
if (request.getBillingAddress() != null) {
|
||||||
|
order.setBillingFirstName(request.getBillingAddress().getFirstName());
|
||||||
|
order.setBillingLastName(request.getBillingAddress().getLastName());
|
||||||
|
order.setBillingCompanyName(request.getBillingAddress().getCompanyName());
|
||||||
|
order.setBillingContactPerson(request.getBillingAddress().getContactPerson());
|
||||||
|
order.setBillingAddressLine1(request.getBillingAddress().getAddressLine1());
|
||||||
|
order.setBillingAddressLine2(request.getBillingAddress().getAddressLine2());
|
||||||
|
order.setBillingZip(request.getBillingAddress().getZip());
|
||||||
|
order.setBillingCity(request.getBillingAddress().getCity());
|
||||||
|
order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH");
|
||||||
|
}
|
||||||
|
|
||||||
|
order.setShippingSameAsBilling(request.isShippingSameAsBilling());
|
||||||
|
if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) {
|
||||||
|
order.setShippingFirstName(request.getShippingAddress().getFirstName());
|
||||||
|
order.setShippingLastName(request.getShippingAddress().getLastName());
|
||||||
|
order.setShippingCompanyName(request.getShippingAddress().getCompanyName());
|
||||||
|
order.setShippingContactPerson(request.getShippingAddress().getContactPerson());
|
||||||
|
order.setShippingAddressLine1(request.getShippingAddress().getAddressLine1());
|
||||||
|
order.setShippingAddressLine2(request.getShippingAddress().getAddressLine2());
|
||||||
|
order.setShippingZip(request.getShippingAddress().getZip());
|
||||||
|
order.setShippingCity(request.getShippingAddress().getCity());
|
||||||
|
order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH");
|
||||||
|
} else {
|
||||||
|
order.setShippingFirstName(order.getBillingFirstName());
|
||||||
|
order.setShippingLastName(order.getBillingLastName());
|
||||||
|
order.setShippingCompanyName(order.getBillingCompanyName());
|
||||||
|
order.setShippingContactPerson(order.getBillingContactPerson());
|
||||||
|
order.setShippingAddressLine1(order.getBillingAddressLine1());
|
||||||
|
order.setShippingAddressLine2(order.getBillingAddressLine2());
|
||||||
|
order.setShippingZip(order.getBillingZip());
|
||||||
|
order.setShippingCity(order.getBillingCity());
|
||||||
|
order.setShippingCountryCode(order.getBillingCountryCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
|
||||||
|
|
||||||
|
BigDecimal subtotal = BigDecimal.ZERO;
|
||||||
|
order.setSubtotalChf(BigDecimal.ZERO);
|
||||||
|
order.setTotalChf(BigDecimal.ZERO);
|
||||||
|
order.setDiscountChf(BigDecimal.ZERO);
|
||||||
|
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
|
||||||
|
order.setShippingCostChf(BigDecimal.valueOf(9.00));
|
||||||
|
|
||||||
|
order = orderRepo.save(order);
|
||||||
|
|
||||||
|
List<OrderItem> savedItems = new ArrayList<>();
|
||||||
|
|
||||||
|
for (QuoteLineItem qItem : quoteItems) {
|
||||||
|
OrderItem oItem = new OrderItem();
|
||||||
|
oItem.setOrder(order);
|
||||||
|
oItem.setOriginalFilename(qItem.getOriginalFilename());
|
||||||
|
oItem.setQuantity(qItem.getQuantity());
|
||||||
|
oItem.setColorCode(qItem.getColorCode());
|
||||||
|
oItem.setMaterialCode(session.getMaterialCode());
|
||||||
|
|
||||||
|
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
|
||||||
|
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
||||||
|
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
|
||||||
|
oItem.setMaterialGrams(qItem.getMaterialGrams());
|
||||||
|
|
||||||
|
UUID fileUuid = UUID.randomUUID();
|
||||||
|
String ext = getExtension(qItem.getOriginalFilename());
|
||||||
|
String storedFilename = fileUuid.toString() + "." + ext;
|
||||||
|
|
||||||
|
oItem.setStoredFilename(storedFilename);
|
||||||
|
oItem.setStoredRelativePath("PENDING");
|
||||||
|
oItem.setMimeType("application/octet-stream");
|
||||||
|
oItem.setCreatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
oItem = orderItemRepo.save(oItem);
|
||||||
|
|
||||||
|
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||||
|
oItem.setStoredRelativePath(relativePath);
|
||||||
|
|
||||||
|
if (qItem.getStoredPath() != null) {
|
||||||
|
try {
|
||||||
|
Path sourcePath = Paths.get(qItem.getStoredPath());
|
||||||
|
if (Files.exists(sourcePath)) {
|
||||||
|
storageService.store(sourcePath, Paths.get(relativePath));
|
||||||
|
oItem.setFileSizeBytes(Files.size(sourcePath));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oItem = orderItemRepo.save(oItem);
|
||||||
|
savedItems.add(oItem);
|
||||||
|
subtotal = subtotal.add(oItem.getLineTotalChf());
|
||||||
|
}
|
||||||
|
|
||||||
|
order.setSubtotalChf(subtotal);
|
||||||
|
if (order.getShippingCostChf() == null) {
|
||||||
|
order.setShippingCostChf(BigDecimal.valueOf(9.00));
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
||||||
|
order.setTotalChf(total);
|
||||||
|
|
||||||
|
session.setConvertedOrderId(order.getId());
|
||||||
|
session.setStatus("CONVERTED");
|
||||||
|
quoteSessionRepo.save(session);
|
||||||
|
|
||||||
|
// Generate Invoice and QR Bill
|
||||||
|
generateAndSaveDocuments(order, savedItems);
|
||||||
|
|
||||||
|
return orderRepo.save(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
|
||||||
|
try {
|
||||||
|
// 1. Generate QR Bill
|
||||||
|
byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order);
|
||||||
|
String qrBillSvg = new String(qrBillSvgBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
|
||||||
|
if (qrBillSvg.contains("<?xml")) {
|
||||||
|
int svgStartIndex = qrBillSvg.indexOf("<svg");
|
||||||
|
if (svgStartIndex != -1) {
|
||||||
|
qrBillSvg = qrBillSvg.substring(svgStartIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save QR Bill SVG
|
||||||
|
String qrRelativePath = "orders/" + order.getId() + "/documents/qr-bill.svg";
|
||||||
|
saveFileBytes(qrBillSvgBytes, qrRelativePath);
|
||||||
|
|
||||||
|
// 2. Prepare Invoice Variables
|
||||||
|
Map<String, Object> vars = new HashMap<>();
|
||||||
|
vars.put("sellerDisplayName", "3D Fab Switzerland");
|
||||||
|
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
|
||||||
|
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
||||||
|
vars.put("sellerEmail", "info@3dfab.ch");
|
||||||
|
|
||||||
|
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
|
||||||
|
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
|
||||||
|
String buyerName = "BUSINESS".equals(order.getBillingCustomerType())
|
||||||
|
? order.getBillingCompanyName()
|
||||||
|
: order.getBillingFirstName() + " " + order.getBillingLastName();
|
||||||
|
vars.put("buyerDisplayName", buyerName);
|
||||||
|
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
|
||||||
|
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
|
||||||
|
|
||||||
|
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
|
||||||
|
Map<String, Object> line = new HashMap<>();
|
||||||
|
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
|
||||||
|
line.put("quantity", i.getQuantity());
|
||||||
|
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
|
||||||
|
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
|
||||||
|
return line;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<String, Object> setupLine = new HashMap<>();
|
||||||
|
setupLine.put("description", "Costo Setup");
|
||||||
|
setupLine.put("quantity", 1);
|
||||||
|
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||||
|
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||||
|
invoiceLineItems.add(setupLine);
|
||||||
|
|
||||||
|
Map<String, Object> shippingLine = new HashMap<>();
|
||||||
|
shippingLine.put("description", "Spedizione");
|
||||||
|
shippingLine.put("quantity", 1);
|
||||||
|
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||||
|
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||||
|
invoiceLineItems.add(shippingLine);
|
||||||
|
|
||||||
|
vars.put("invoiceLineItems", invoiceLineItems);
|
||||||
|
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
|
||||||
|
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
|
||||||
|
vars.put("paymentTermsText", "Appena riceviamo il pagamento l'ordine entrerà nella coda di stampa. Grazie per la fiducia");
|
||||||
|
|
||||||
|
// 3. Generate PDF
|
||||||
|
byte[] pdfBytes = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
||||||
|
|
||||||
|
// Save PDF
|
||||||
|
String pdfRelativePath = "orders/" + order.getId() + "/documents/invoice-" + order.getId() + ".pdf";
|
||||||
|
saveFileBytes(pdfBytes, pdfRelativePath);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
// Don't fail the order if document generation fails, but log it
|
||||||
|
// TODO: Better error handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveFileBytes(byte[] content, String relativePath) {
|
||||||
|
// Since StorageService takes paths, we might need to write to temp first or check if it supports bytes/streams
|
||||||
|
// Simulating via temp file for now as StorageService.store takes a Path
|
||||||
|
try {
|
||||||
|
Path tempFile = Files.createTempFile("print-calc-upload", ".tmp");
|
||||||
|
Files.write(tempFile, content);
|
||||||
|
storageService.store(tempFile, Paths.get(relativePath));
|
||||||
|
Files.delete(tempFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to save file " + relativePath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String filename) {
|
||||||
|
if (filename == null) return "stl";
|
||||||
|
int i = filename.lastIndexOf('.');
|
||||||
|
if (i > 0) {
|
||||||
|
return filename.substring(i + 1);
|
||||||
|
}
|
||||||
|
return "stl";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import net.codecrete.qrbill.generator.Bill;
|
||||||
|
import net.codecrete.qrbill.generator.GraphicsFormat;
|
||||||
|
import net.codecrete.qrbill.generator.QRBill;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class QrBillService {
|
||||||
|
|
||||||
|
public byte[] generateQrBillSvg(Order order) {
|
||||||
|
Bill bill = createBillFromOrder(order);
|
||||||
|
return QRBill.generate(bill);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Bill createBillFromOrder(Order order) {
|
||||||
|
Bill bill = new Bill();
|
||||||
|
|
||||||
|
// Creditor (Merchant)
|
||||||
|
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
|
||||||
|
bill.setCreditor(createAddress(
|
||||||
|
"Küng, Joe",
|
||||||
|
"Via G. Pioda 29a",
|
||||||
|
"6710",
|
||||||
|
"Biasca",
|
||||||
|
"CH"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Debtor (Customer)
|
||||||
|
String debtorName;
|
||||||
|
if ("BUSINESS".equals(order.getBillingCustomerType())) {
|
||||||
|
debtorName = order.getBillingCompanyName();
|
||||||
|
} else {
|
||||||
|
debtorName = order.getBillingFirstName() + " " + order.getBillingLastName();
|
||||||
|
}
|
||||||
|
|
||||||
|
bill.setDebtor(createAddress(
|
||||||
|
debtorName,
|
||||||
|
order.getBillingAddressLine1(), // Assuming simple address for now. Splitting might be needed if street/house number are separate
|
||||||
|
order.getBillingZip(),
|
||||||
|
order.getBillingCity(),
|
||||||
|
order.getBillingCountryCode()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Amount
|
||||||
|
bill.setAmount(order.getTotalChf());
|
||||||
|
bill.setCurrency("CHF");
|
||||||
|
|
||||||
|
// Reference
|
||||||
|
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
|
||||||
|
bill.setUnstructuredMessage("Order " + order.getId());
|
||||||
|
|
||||||
|
return bill;
|
||||||
|
}
|
||||||
|
|
||||||
|
private net.codecrete.qrbill.generator.Address createAddress(String name, String street, String zip, String city, String country) {
|
||||||
|
net.codecrete.qrbill.generator.Address address = new net.codecrete.qrbill.generator.Address();
|
||||||
|
address.setName(name);
|
||||||
|
address.setStreet(street);
|
||||||
|
address.setPostalCode(zip);
|
||||||
|
address.setTown(city);
|
||||||
|
address.setCountryCode(country);
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface StorageService {
|
||||||
|
void init();
|
||||||
|
void store(MultipartFile file, Path destination) throws IOException;
|
||||||
|
void store(Path source, Path destination) throws IOException;
|
||||||
|
void delete(Path path) throws IOException;
|
||||||
|
Resource loadAsResource(Path path) throws IOException;
|
||||||
|
}
|
||||||
@@ -18,3 +18,8 @@ profiles.root=${PROFILES_DIR:profiles}
|
|||||||
# File Upload Limits
|
# File Upload Limits
|
||||||
spring.servlet.multipart.max-file-size=200MB
|
spring.servlet.multipart.max-file-size=200MB
|
||||||
spring.servlet.multipart.max-request-size=200MB
|
spring.servlet.multipart.max-request-size=200MB
|
||||||
|
|
||||||
|
# ClamAV Configuration
|
||||||
|
clamav.host=${CLAMAV_HOST:clamav}
|
||||||
|
clamav.port=${CLAMAV_PORT:3310}
|
||||||
|
clamav.enabled=${CLAMAV_ENABLED:false}
|
||||||
|
|||||||
249
backend/src/main/resources/templates/invoice.html
Normal file
249
backend/src/main/resources/templates/invoice.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<style>
|
||||||
|
@page invoice { size: 8.5in 11in; margin: 0.65in; }
|
||||||
|
@page qrpage { size: A4; margin: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
page: invoice;
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-page {
|
||||||
|
page: invoice;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
width: 58%;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
width: 42%;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-title {
|
||||||
|
font-size: 15pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 4mm 0;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 9mm 0 2mm 0;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buyer-box {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 20mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
margin-top: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th,
|
||||||
|
.line-items td {
|
||||||
|
border-bottom: 1px solid #d8d8d8;
|
||||||
|
padding: 2.8mm 2.2mm;
|
||||||
|
vertical-align: top;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(1),
|
||||||
|
.line-items td:nth-child(1) {
|
||||||
|
width: 54%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(2),
|
||||||
|
.line-items td:nth-child(2) {
|
||||||
|
width: 12%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(3),
|
||||||
|
.line-items td:nth-child(3) {
|
||||||
|
width: 17%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(4),
|
||||||
|
.line-items td:nth-child(4) {
|
||||||
|
width: 17%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals {
|
||||||
|
margin-top: 7mm;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 76mm;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals td {
|
||||||
|
border: none;
|
||||||
|
padding: 1.6mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-label {
|
||||||
|
text-align: left;
|
||||||
|
color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-value {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-strong td {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
padding-top: 2.4mm;
|
||||||
|
border-top: 1px solid #d8d8d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-terms {
|
||||||
|
margin-top: 9mm;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #2b2b2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-only-page {
|
||||||
|
page: qrpage;
|
||||||
|
position: relative;
|
||||||
|
width: 210mm;
|
||||||
|
height: 297mm;
|
||||||
|
background: #fff;
|
||||||
|
page-break-before: always;
|
||||||
|
break-before: page;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-bill-bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 210mm;
|
||||||
|
height: 105mm;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-bill-bottom svg {
|
||||||
|
width: 210mm !important;
|
||||||
|
height: 105mm !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="invoice-page">
|
||||||
|
|
||||||
|
<table class="header-table">
|
||||||
|
<tr>
|
||||||
|
<td class="header-left">
|
||||||
|
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
|
||||||
|
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
|
||||||
|
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
|
||||||
|
<div th:text="${sellerEmail}">email@example.com</div>
|
||||||
|
</td>
|
||||||
|
<td class="header-right">
|
||||||
|
<div class="invoice-title">Fattura</div>
|
||||||
|
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
|
||||||
|
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
|
||||||
|
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-title">Fatturare a</div>
|
||||||
|
<div class="buyer-box">
|
||||||
|
<div>
|
||||||
|
<div th:text="${buyerDisplayName}">Cliente SA</div>
|
||||||
|
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
|
||||||
|
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="line-items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Descrizione</th>
|
||||||
|
<th>Qtà</th>
|
||||||
|
<th>Prezzo</th>
|
||||||
|
<th>Totale</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="lineItem : ${invoiceLineItems}">
|
||||||
|
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
|
||||||
|
<td th:text="${lineItem.quantity}">1</td>
|
||||||
|
<td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
|
||||||
|
<td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="totals">
|
||||||
|
<tr>
|
||||||
|
<td class="totals-label">Subtotale</td>
|
||||||
|
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="total-strong">
|
||||||
|
<td class="totals-label">Totale</td>
|
||||||
|
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="payment-terms" th:text="${paymentTermsText}">
|
||||||
|
Pagamento entro 7 giorni. Grazie.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qr-only-page">
|
||||||
|
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
568
frontend/package-lock.json
generated
568
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,23 @@ export class QuoteEstimatorService {
|
|||||||
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
|
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrder(orderId: string): Observable<any> {
|
||||||
|
const headers: any = {};
|
||||||
|
// @ts-ignore
|
||||||
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrderInvoice(orderId: string): Observable<Blob> {
|
||||||
|
const headers: any = {};
|
||||||
|
// @ts-ignore
|
||||||
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
|
||||||
|
headers,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||||
console.log('QuoteEstimatorService: Calculating quote...', request);
|
console.log('QuoteEstimatorService: Calculating quote...', request);
|
||||||
if (request.items.length === 0) {
|
if (request.items.length === 0) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<div class="checkout-page">
|
<div class="checkout-page">
|
||||||
<h2 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h2>
|
<div class="container hero">
|
||||||
|
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
<div class="checkout-layout">
|
<div class="checkout-layout">
|
||||||
|
|
||||||
<!-- LEFT COLUMN: Form -->
|
<!-- LEFT COLUMN: Form -->
|
||||||
@@ -13,36 +16,33 @@
|
|||||||
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
||||||
|
|
||||||
<!-- Contact Info Card -->
|
<!-- Contact Info Card -->
|
||||||
<div class="form-card">
|
<app-card class="mb-6">
|
||||||
<div class="card-header">
|
<div class="card-header-simple">
|
||||||
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
|
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
|
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
|
||||||
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
|
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</app-card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Billing Address Card -->
|
<!-- Billing Address Card -->
|
||||||
<div class="form-card">
|
<app-card class="mb-6">
|
||||||
<div class="card-header">
|
<div class="card-header-simple">
|
||||||
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content" formGroupName="billingAddress">
|
<div formGroupName="billingAddress">
|
||||||
<div class="form-row">
|
|
||||||
|
<!-- Private Person Fields -->
|
||||||
|
<div *ngIf="!isCompany" class="form-row">
|
||||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
|
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
|
||||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
|
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
<!-- Company Fields -->
|
||||||
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
<div *ngIf="isCompany" class="company-fields mb-4">
|
||||||
|
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
||||||
<div class="form-row three-cols">
|
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
||||||
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
|
|
||||||
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
|
|
||||||
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Type Selector -->
|
<!-- User Type Selector -->
|
||||||
@@ -55,13 +55,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Company Fields (Indented with left border) -->
|
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
||||||
<div *ngIf="isCompany" class="company-fields">
|
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
||||||
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
|
||||||
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
<div class="form-row three-cols">
|
||||||
</div>
|
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
|
||||||
|
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
|
||||||
|
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</app-card>
|
||||||
|
|
||||||
<!-- Shipping Option -->
|
<!-- Shipping Option -->
|
||||||
<div class="shipping-option">
|
<div class="shipping-option">
|
||||||
@@ -73,11 +76,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shipping Address Card (Conditional) -->
|
<!-- Shipping Address Card (Conditional) -->
|
||||||
<div class="form-card" *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value">
|
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
|
||||||
<div class="card-header">
|
<div class="card-header-simple">
|
||||||
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
|
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content" formGroupName="shippingAddress">
|
<div formGroupName="shippingAddress">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
|
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
|
||||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
|
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
|
||||||
@@ -96,11 +99,11 @@
|
|||||||
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
|
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</app-card>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
|
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
|
||||||
{{ isSubmitting() ? ('CHECKOUT.PROCESSING' | translate) : ('CHECKOUT.PLACE_ORDER' | translate) }}
|
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,12 +112,11 @@
|
|||||||
|
|
||||||
<!-- RIGHT COLUMN: Order Summary -->
|
<!-- RIGHT COLUMN: Order Summary -->
|
||||||
<div class="checkout-summary-section">
|
<div class="checkout-summary-section">
|
||||||
<div class="form-card sticky-card">
|
<app-card class="sticky-card">
|
||||||
<div class="card-header">
|
<div class="card-header-simple">
|
||||||
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
|
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="summary-items" *ngIf="quoteSession() as session">
|
<div class="summary-items" *ngIf="quoteSession() as session">
|
||||||
<div class="summary-item" *ngFor="let item of session.items">
|
<div class="summary-item" *ngFor="let item of session.items">
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
@@ -142,15 +144,18 @@
|
|||||||
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
|
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
|
||||||
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
|
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider"></div>
|
<div class="total-row">
|
||||||
<div class="total-row grand-total">
|
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
|
||||||
|
<span>{{ 9.00 | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grand-total">
|
||||||
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
|
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
|
||||||
<span>{{ session.grandTotalChf | currency:'CHF' }}</span>
|
<span>{{ (session.grandTotalChf + 9.00) | currency:'CHF' }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,84 +1,76 @@
|
|||||||
.checkout-page {
|
.hero {
|
||||||
padding: 3rem 1rem;
|
padding: var(--space-8) 0;
|
||||||
max-width: 1200px;
|
text-align: center;
|
||||||
margin: 0 auto;
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkout-layout {
|
.checkout-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 380px;
|
grid-template-columns: 1fr 420px;
|
||||||
gap: var(--space-8);
|
gap: var(--space-8);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
margin-bottom: var(--space-12);
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1024px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-6);
|
gap: var(--space-8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.card-header-simple {
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
color: var(--color-heading);
|
padding-bottom: var(--space-4);
|
||||||
}
|
|
||||||
|
|
||||||
.form-card {
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
padding: var(--space-4) var(--space-6);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--color-bg-subtle);
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-heading);
|
color: var(--color-text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-content {
|
|
||||||
padding: var(--space-6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
flex-direction: row;
|
||||||
|
& > * { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.no-margin {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.three-cols {
|
&.three-cols {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2fr 1fr;
|
grid-template-columns: 1.5fr 2fr 1fr;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
|
||||||
|
|
||||||
app-input {
|
@media (max-width: 768px) {
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
flex-direction: column;
|
|
||||||
&.three-cols {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User Type Selector Styles - Matched with Contact Form */
|
/* User Type Selector - Matching Contact Form Style */
|
||||||
.user-type-selector {
|
.user-type-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
background-color: var(--color-neutral-100);
|
background-color: var(--color-neutral-100);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin-bottom: var(--space-4);
|
margin: var(--space-6) 0;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
@@ -112,12 +104,14 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding-left: var(--space-4);
|
padding-left: var(--space-4);
|
||||||
border-left: 2px solid var(--color-border);
|
border-left: 2px solid var(--color-border);
|
||||||
margin-top: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shipping-option {
|
.shipping-option {
|
||||||
margin: var(--space-6) 0;
|
margin: var(--space-6) 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Checkbox */
|
/* Custom Checkbox */
|
||||||
@@ -125,9 +119,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 30px;
|
padding-left: 36px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|
||||||
@@ -153,10 +148,10 @@
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
left: 0;
|
left: 0;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
height: 20px;
|
height: 24px;
|
||||||
width: 20px;
|
width: 24px;
|
||||||
background-color: var(--color-bg-surface);
|
background-color: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
@@ -164,12 +159,12 @@
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: none;
|
display: none;
|
||||||
left: 6px;
|
left: 7px;
|
||||||
top: 2px;
|
top: 3px;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border: solid white;
|
border: solid #000;
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2.5px 2.5px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,40 +174,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.checkout-summary-section {
|
.checkout-summary-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-card {
|
.sticky-card {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: var(--space-6);
|
||||||
/* Inherits styles from .form-card */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-items {
|
.summary-items {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
max-height: 400px;
|
max-height: 450px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-right: var(--space-2);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-item {
|
.summary-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: var(--space-3) 0;
|
padding: var(--space-4) 0;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
&:last-child {
|
&:first-child { padding-top: 0; }
|
||||||
border-bottom: none;
|
&:last-child { border-bottom: none; }
|
||||||
}
|
|
||||||
|
|
||||||
.item-details {
|
.item-details {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
.item-name {
|
.item-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -226,8 +229,8 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
.color-dot {
|
.color-dot {
|
||||||
width: 12px;
|
width: 14px;
|
||||||
height: 12px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -245,55 +248,52 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-left: var(--space-3);
|
margin-left: var(--space-3);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--color-heading);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-totals {
|
.summary-totals {
|
||||||
padding-top: var(--space-4);
|
background: var(--color-neutral-100);
|
||||||
border-top: 1px solid var(--color-border);
|
padding: var(--space-4);
|
||||||
margin-top: var(--space-4);
|
border-radius: var(--radius-md);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
|
||||||
.total-row {
|
.total-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
&.grand-total {
|
.grand-total {
|
||||||
color: var(--color-heading);
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--color-text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.25rem;
|
font-size: 1.5rem;
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
padding-top: var(--space-4);
|
padding-top: var(--space-4);
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 2px solid var(--color-border);
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-8);
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
app-button {
|
app-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
|
||||||
width: auto;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: var(--color-danger);
|
color: var(--color-error);
|
||||||
background: var(--color-danger-subtle);
|
background: #fef2f2;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
border: 1px solid var(--color-danger);
|
border: 1px solid #fee2e2;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-6 { margin-bottom: var(--space-6); }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||||
import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
|
import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-checkout',
|
selector: 'app-checkout',
|
||||||
@@ -15,7 +16,8 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
TranslateModule,
|
TranslateModule,
|
||||||
AppInputComponent,
|
AppInputComponent,
|
||||||
AppButtonComponent
|
AppButtonComponent,
|
||||||
|
AppCardComponent
|
||||||
],
|
],
|
||||||
templateUrl: './checkout.component.html',
|
templateUrl: './checkout.component.html',
|
||||||
styleUrls: ['./checkout.component.scss']
|
styleUrls: ['./checkout.component.scss']
|
||||||
@@ -75,20 +77,27 @@ export class CheckoutComponent implements OnInit {
|
|||||||
const type = isCompany ? 'BUSINESS' : 'PRIVATE';
|
const type = isCompany ? 'BUSINESS' : 'PRIVATE';
|
||||||
this.checkoutForm.patchValue({ customerType: type });
|
this.checkoutForm.patchValue({ customerType: type });
|
||||||
|
|
||||||
// Update validators based on type
|
|
||||||
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
|
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
|
||||||
const companyControl = billingGroup.get('companyName');
|
const companyControl = billingGroup.get('companyName');
|
||||||
const referenceControl = billingGroup.get('referencePerson');
|
const referenceControl = billingGroup.get('referencePerson');
|
||||||
|
const firstNameControl = billingGroup.get('firstName');
|
||||||
|
const lastNameControl = billingGroup.get('lastName');
|
||||||
|
|
||||||
if (isCompany) {
|
if (isCompany) {
|
||||||
companyControl?.setValidators([Validators.required]);
|
companyControl?.setValidators([Validators.required]);
|
||||||
referenceControl?.setValidators([Validators.required]);
|
referenceControl?.setValidators([Validators.required]);
|
||||||
|
firstNameControl?.clearValidators();
|
||||||
|
lastNameControl?.clearValidators();
|
||||||
} else {
|
} else {
|
||||||
companyControl?.clearValidators();
|
companyControl?.clearValidators();
|
||||||
referenceControl?.clearValidators();
|
referenceControl?.clearValidators();
|
||||||
|
firstNameControl?.setValidators([Validators.required]);
|
||||||
|
lastNameControl?.setValidators([Validators.required]);
|
||||||
}
|
}
|
||||||
companyControl?.updateValueAndValidity();
|
companyControl?.updateValueAndValidity();
|
||||||
referenceControl?.updateValueAndValidity();
|
referenceControl?.updateValueAndValidity();
|
||||||
|
firstNameControl?.updateValueAndValidity();
|
||||||
|
lastNameControl?.updateValueAndValidity();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
|||||||
@@ -1,21 +1,109 @@
|
|||||||
<div class="payment-container">
|
<div class="container hero">
|
||||||
<mat-card class="payment-card">
|
<h1>{{ 'PAYMENT.TITLE' | translate }}</h1>
|
||||||
<mat-card-header>
|
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
|
||||||
<mat-icon mat-card-avatar>payment</mat-icon>
|
</div>
|
||||||
<mat-card-title>Payment Integration</mat-card-title>
|
|
||||||
<mat-card-subtitle>Order #{{ orderId }}</mat-card-subtitle>
|
<div class="container">
|
||||||
</mat-card-header>
|
<div class="payment-layout" *ngIf="order() as o">
|
||||||
<mat-card-content>
|
<div class="payment-main">
|
||||||
<div class="coming-soon">
|
<app-card class="mb-6">
|
||||||
<h3>Coming Soon</h3>
|
<div class="card-header-simple">
|
||||||
<p>The online payment system is currently under development.</p>
|
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
|
||||||
<p>Your order has been saved. Please contact us to arrange payment.</p>
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-selection">
|
||||||
|
<div class="methods-grid">
|
||||||
|
<div
|
||||||
|
class="type-option"
|
||||||
|
[class.selected]="selectedPaymentMethod === 'twint'"
|
||||||
|
(click)="selectPayment('twint')">
|
||||||
|
<span class="method-name">TWINT</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="type-option"
|
||||||
|
[class.selected]="selectedPaymentMethod === 'bill'"
|
||||||
|
(click)="selectPayment('bill')">
|
||||||
|
<span class="method-name">QR Bill / Bank Transfer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'twint'">
|
||||||
|
<div class="details-header">
|
||||||
|
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="qr-placeholder">
|
||||||
|
<div class="qr-box">
|
||||||
|
<span>QR CODE</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
|
||||||
|
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
|
||||||
|
<div class="details-header">
|
||||||
|
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="bank-details">
|
||||||
|
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
|
||||||
|
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
|
||||||
|
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
|
||||||
|
|
||||||
|
<div class="qr-bill-actions">
|
||||||
|
<app-button variant="outline" (click)="downloadInvoice()">
|
||||||
|
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true">
|
||||||
|
{{ 'PAYMENT.CONFIRM' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-summary">
|
||||||
|
<app-card class="sticky-card">
|
||||||
|
<div class="card-header-simple">
|
||||||
|
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
|
||||||
|
<p class="order-id">#{{ o.id.substring(0, 8) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-totals">
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
|
||||||
|
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
|
||||||
|
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
|
||||||
|
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grand-total-row">
|
||||||
|
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
|
||||||
|
<span>{{ o.totalChf | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="loading()" class="loading-state">
|
||||||
|
<app-card>
|
||||||
|
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="error()" class="error-message">
|
||||||
|
<app-card>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
|
||||||
<mat-card-actions align="end">
|
|
||||||
<button mat-raised-button color="primary" (click)="completeOrder()">
|
|
||||||
Simulate Payment Completion
|
|
||||||
</button>
|
|
||||||
</mat-card-actions>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +1,202 @@
|
|||||||
.payment-container {
|
.hero {
|
||||||
display: flex;
|
padding: var(--space-12) 0 var(--space-8);
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 80vh;
|
|
||||||
padding: 2rem;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-card {
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coming-soon {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem 0;
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 400px;
|
||||||
|
gap: var(--space-8);
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: var(--space-12);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-simple {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-bottom: 1rem;
|
font-size: 1.25rem;
|
||||||
color: #555;
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-id {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-selection {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.methods-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-details {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
.details-header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.qr-box {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
background-color: white;
|
||||||
|
border: 2px solid var(--color-neutral-900);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-details {
|
||||||
p {
|
p {
|
||||||
color: #777;
|
margin-bottom: var(--space-2);
|
||||||
margin-bottom: 0.5rem;
|
font-size: 1rem;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mat-icon {
|
.qr-bill-actions {
|
||||||
font-size: 40px;
|
margin-top: var(--space-4);
|
||||||
width: 40px;
|
}
|
||||||
height: 40px;
|
|
||||||
color: #3f51b5;
|
.sticky-card {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-totals {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
padding: var(--space-6);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grand-total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-6 { margin-bottom: var(--space-6); }
|
||||||
|
|
||||||
|
.error-message,
|
||||||
|
.loading-state {
|
||||||
|
margin-top: var(--space-12);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,75 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-payment',
|
selector: 'app-payment',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule],
|
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
|
||||||
templateUrl: './payment.component.html',
|
templateUrl: './payment.component.html',
|
||||||
styleUrl: './payment.component.scss'
|
styleUrl: './payment.component.scss'
|
||||||
})
|
})
|
||||||
export class PaymentComponent implements OnInit {
|
export class PaymentComponent implements OnInit {
|
||||||
orderId: string | null = null;
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private quoteService = inject(QuoteEstimatorService);
|
||||||
|
|
||||||
constructor(
|
orderId: string | null = null;
|
||||||
private route: ActivatedRoute,
|
selectedPaymentMethod: 'twint' | 'bill' | null = null;
|
||||||
private router: Router
|
order = signal<any>(null);
|
||||||
) {}
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
||||||
|
if (this.orderId) {
|
||||||
|
this.loadOrder();
|
||||||
|
} else {
|
||||||
|
this.error.set('Order ID not found.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOrder() {
|
||||||
|
if (!this.orderId) return;
|
||||||
|
this.quoteService.getOrder(this.orderId).subscribe({
|
||||||
|
next: (order) => {
|
||||||
|
this.order.set(order);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load order', err);
|
||||||
|
this.error.set('Failed to load order details.');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPayment(method: 'twint' | 'bill'): void {
|
||||||
|
this.selectedPaymentMethod = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadInvoice() {
|
||||||
|
if (!this.orderId) return;
|
||||||
|
this.quoteService.getOrderInvoice(this.orderId).subscribe({
|
||||||
|
next: (blob) => {
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `invoice-${this.orderId}.pdf`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
error: (err) => console.error('Failed to download invoice', err)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
completeOrder(): void {
|
completeOrder(): void {
|
||||||
// Simulate payment completion
|
|
||||||
alert('Payment Simulated! Order marked as PAID.');
|
alert('Payment Simulated! Order marked as PAID.');
|
||||||
// Here you would call the backend to mark as paid if we had that endpoint ready
|
|
||||||
// For now, redirect home or show success
|
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@
|
|||||||
},
|
},
|
||||||
"CHECKOUT": {
|
"CHECKOUT": {
|
||||||
"TITLE": "Checkout",
|
"TITLE": "Checkout",
|
||||||
|
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
|
||||||
"CONTACT_INFO": "Contact Information",
|
"CONTACT_INFO": "Contact Information",
|
||||||
"BILLING_ADDR": "Billing Address",
|
"BILLING_ADDR": "Billing Address",
|
||||||
"SHIPPING_ADDR": "Shipping Address",
|
"SHIPPING_ADDR": "Shipping Address",
|
||||||
@@ -171,6 +172,25 @@
|
|||||||
"SUBTOTAL": "Subtotal",
|
"SUBTOTAL": "Subtotal",
|
||||||
"SETUP_FEE": "Setup Fee",
|
"SETUP_FEE": "Setup Fee",
|
||||||
"TOTAL": "Total",
|
"TOTAL": "Total",
|
||||||
"QTY": "Qty"
|
"QTY": "Qty",
|
||||||
|
"SHIPPING": "Shipping"
|
||||||
|
},
|
||||||
|
"PAYMENT": {
|
||||||
|
"TITLE": "Payment",
|
||||||
|
"METHOD": "Payment Method",
|
||||||
|
"TWINT_TITLE": "Pay with TWINT",
|
||||||
|
"TWINT_DESC": "Scan the code with your TWINT app",
|
||||||
|
"BANK_TITLE": "Bank Transfer",
|
||||||
|
"BANK_OWNER": "Owner",
|
||||||
|
"BANK_IBAN": "IBAN",
|
||||||
|
"BANK_REF": "Reference",
|
||||||
|
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
|
||||||
|
"CONFIRM": "Confirm Order",
|
||||||
|
"SUMMARY_TITLE": "Order Summary",
|
||||||
|
"SUBTOTAL": "Subtotal",
|
||||||
|
"SHIPPING": "Shipping",
|
||||||
|
"SETUP_FEE": "Setup Fee",
|
||||||
|
"TOTAL": "Total",
|
||||||
|
"LOADING": "Loading order details..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
},
|
},
|
||||||
"CHECKOUT": {
|
"CHECKOUT": {
|
||||||
"TITLE": "Checkout",
|
"TITLE": "Checkout",
|
||||||
|
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
|
||||||
"CONTACT_INFO": "Informazioni di Contatto",
|
"CONTACT_INFO": "Informazioni di Contatto",
|
||||||
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
||||||
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
||||||
@@ -150,6 +151,25 @@
|
|||||||
"SUBTOTAL": "Subtotale",
|
"SUBTOTAL": "Subtotale",
|
||||||
"SETUP_FEE": "Costo di Avvio",
|
"SETUP_FEE": "Costo di Avvio",
|
||||||
"TOTAL": "Totale",
|
"TOTAL": "Totale",
|
||||||
"QTY": "Qtà"
|
"QTY": "Qtà",
|
||||||
|
"SHIPPING": "Spedizione"
|
||||||
|
},
|
||||||
|
"PAYMENT": {
|
||||||
|
"TITLE": "Pagamento",
|
||||||
|
"METHOD": "Metodo di Pagamento",
|
||||||
|
"TWINT_TITLE": "Paga con TWINT",
|
||||||
|
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
|
||||||
|
"BANK_TITLE": "Bonifico Bancario",
|
||||||
|
"BANK_OWNER": "Titolare",
|
||||||
|
"BANK_IBAN": "IBAN",
|
||||||
|
"BANK_REF": "Riferimento",
|
||||||
|
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
|
||||||
|
"CONFIRM": "Conferma Ordine",
|
||||||
|
"SUMMARY_TITLE": "Riepilogo Ordine",
|
||||||
|
"SUBTOTAL": "Subtotale",
|
||||||
|
"SHIPPING": "Spedizione",
|
||||||
|
"SETUP_FEE": "Costo Setup",
|
||||||
|
"TOTAL": "Totale",
|
||||||
|
"LOADING": "Caricamento dettagli ordine..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user