Merge branch 'feat/checkout' into 'main'

Feat/checkout

See merge request jjimenez/erp-imprimelibros!19
This commit is contained in:
2025-10-31 10:36:41 +00:00
58 changed files with 3230 additions and 240 deletions

37
pom.xml
View File

@ -6,7 +6,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.6</version>
<version>3.5.7</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.imprimelibros</groupId>
@ -151,6 +151,41 @@
<version>${liquibase.version}</version>
</dependency>
<!-- Redsys -->
<dependency>
<groupId>sis.redsys</groupId>
<artifactId>apiSha256</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/apiSha256.jar</systemPath>
</dependency>
<!-- Dependencias locales incluidas en el ZIP -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.47</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/bcprov-jdk15on-1.4.7.jar</systemPath>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.3</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/commons-codec-1.3.jar</systemPath>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/org.json.jar</systemPath>
</dependency>
</dependencies>
<build>

View File

@ -1,18 +1,23 @@
package com.imprimelibros.erp.cart;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "carts",
uniqueConstraints = @UniqueConstraint(name="uq_carts_user_active", columnNames={"user_id","status"}))
@Table(name = "carts", uniqueConstraints = @UniqueConstraint(name = "uq_carts_user_active", columnNames = { "user_id",
"status" }))
public class Cart {
public enum Status { ACTIVE, LOCKED, ABANDONED }
public enum Status {
ACTIVE, LOCKED, ABANDONED
}
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
@ -25,6 +30,9 @@ public class Cart {
@Column(nullable = false, length = 3)
private String currency = "EUR";
@Column(name = "only_one_shipment", nullable = false)
private Boolean onlyOneShipment = true;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@ -34,23 +42,93 @@ public class Cart {
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CartItem> items = new ArrayList<>();
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CartDireccion> direcciones = new ArrayList<>();
@Column(name = "total", nullable = false)
private BigDecimal total = BigDecimal.ZERO;
@PreUpdate
public void preUpdate() { this.updatedAt = LocalDateTime.now(); }
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters & Setters
public Long getId() { return id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getId() {
return id;
}
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public Long getUserId() {
return userId;
}
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public void setUserId(Long userId) {
this.userId = userId;
}
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public Status getStatus() {
return status;
}
public List<CartItem> getItems() { return items; }
public void setItems(List<CartItem> items) { this.items = items; }
public void setStatus(Status status) {
this.status = status;
}
public String getCurrency() {
return currency;
}
public void setCurrency(String currency) {
this.currency = currency;
}
public Boolean getOnlyOneShipment() {
return onlyOneShipment;
}
public void setOnlyOneShipment(Boolean onlyOneShipment) {
this.onlyOneShipment = onlyOneShipment;
}
public BigDecimal getTotal() {
return total;
}
public void setTotal(BigDecimal total) {
this.total = total;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public List<CartItem> getItems() {
return items;
}
public void setItems(List<CartItem> items) {
this.items = items;
}
public List<CartDireccion> getDirecciones() {
return direcciones;
}
public void setDirecciones(List<CartDireccion> direcciones) {
this.direcciones = direcciones;
}
public void addDireccion(CartDireccion d) {
direcciones.add(d);
d.setCart(this);
}
public void removeDireccion(CartDireccion d) {
direcciones.remove(d);
d.setCart(null);
}
}

View File

@ -3,71 +3,105 @@ package com.imprimelibros.erp.cart;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import com.imprimelibros.erp.users.UserDetailsImpl;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import com.imprimelibros.erp.users.User;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.i18n.TranslationService;
import java.security.Principal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
@Controller
@RequestMapping("/cart")
public class CartController {
private final CartService service;
protected final CartService service;
protected DireccionService direccionService;
protected MessageSource messageSource;
protected TranslationService translationService;
public CartController(CartService service) {
public CartController(CartService service, DireccionService direccionService, MessageSource messageSource,
TranslationService translationService) {
this.service = service;
}
/**
* Obtiene el ID de usuario desde tu seguridad.
* Adáptalo a tu UserDetails (e.g., SecurityContext con getId())
*/
private Long currentUserId(Principal principal) {
if (principal == null) {
throw new IllegalStateException("Usuario no autenticado");
}
if (principal instanceof Authentication auth) {
Object principalObj = auth.getPrincipal();
if (principalObj instanceof UserDetailsImpl udi) {
return udi.getId();
} else if (principalObj instanceof User u && u.getId() != null) {
return u.getId();
}
}
throw new IllegalStateException("No se pudo obtener el ID del usuario actual");
this.direccionService = direccionService;
this.messageSource = messageSource;
this.translationService = translationService;
}
/** Vista del carrito */
@GetMapping
public String viewCart(Model model, Principal principal, Locale locale) {
var items = service.listItems(currentUserId(principal), locale);
List<String> keys = List.of(
"app.cancelar",
"app.seleccionar",
"cart.shipping.add.title",
"cart.shipping.select-placeholder",
"cart.shipping.new-address",
"cart.shipping.errors.noAddressSelected",
"cart.shipping.enter-units",
"cart.shipping.units-label",
"cart.shipping.errors.units-error",
"cart.shipping.ud",
"cart.shipping.uds",
"cart.shipping.send-in-palets",
"cart.shipping.send-in-palets.info",
"cart.shipping.tipo-envio",
"cart.pass-to.customer.error",
"cart.pass-to.customer.error-move",
"app.yes",
"app.aceptar",
"app.cancelar");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
Long userId = Utils.currentUserId(principal);
Cart cart = service.getOrCreateActiveCart(userId);
var items = service.listItems(userId, locale);
model.addAttribute("items", items);
Map<String, Object> direcciones = service.getCartDirecciones(cart.getId(), locale);
if (direcciones != null && direcciones.containsKey("mainDir"))
model.addAttribute("mainDir", direcciones.get("mainDir"));
else if (direcciones != null && direcciones.containsKey("direcciones"))
model.addAttribute("direcciones", direcciones.get("direcciones"));
var summary = service.getCartSummary(cart, locale);
model.addAttribute("cartSummary", summary);
if (summary.get("errorShipmentCost") != null && (Boolean) summary.get("errorShipmentCost"))
model.addAttribute("errorEnvio", true);
else
model.addAttribute("errorEnvio", false);
model.addAttribute("cart", cart);
return "imprimelibros/cart/cart"; // crea esta vista si quieres (tabla simple)
}
/** Añadir presupuesto via POST form */
@PostMapping("/add")
public String add(@PathVariable(name = "presupuestoId", required = true) Long presupuestoId, Principal principal) {
service.addPresupuesto(currentUserId(principal), presupuestoId);
service.addPresupuesto(Utils.currentUserId(principal), presupuestoId);
return "redirect:/cart";
}
/** Añadir presupuesto con ruta REST (opcional) */
@PostMapping("/add/{presupuestoId}")
public Object addPath(@PathVariable Long presupuestoId, Principal principal, HttpServletRequest request) {
service.addPresupuesto(currentUserId(principal), presupuestoId);
service.addPresupuesto(Utils.currentUserId(principal), presupuestoId);
boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
if (isAjax) {
// Responder 200 con la URL a la que quieres ir
@ -83,13 +117,13 @@ public class CartController {
public long getCount(Principal principal) {
if (principal == null)
return 0;
return service.countItems(currentUserId(principal));
return service.countItems(Utils.currentUserId(principal));
}
/** Eliminar línea por ID de item */
@DeleteMapping("/{itemId}/remove")
public String remove(@PathVariable Long itemId, Principal principal) {
service.removeItem(currentUserId(principal), itemId);
service.removeItem(Utils.currentUserId(principal), itemId);
return "redirect:/cart";
}
@ -97,14 +131,72 @@ public class CartController {
@DeleteMapping("/delete/item/{presupuestoId}")
@ResponseBody
public String removeByPresupuesto(@PathVariable Long presupuestoId, Principal principal) {
service.removeByPresupuesto(currentUserId(principal), presupuestoId);
service.removeByPresupuesto(Utils.currentUserId(principal), presupuestoId);
return "redirect:/cart";
}
/** Vaciar carrito completo */
@DeleteMapping("/clear")
public String clear(Principal principal) {
service.clear(currentUserId(principal));
service.clear(Utils.currentUserId(principal));
return "redirect:/cart";
}
@GetMapping("/get-address/{id}")
public String getDireccionCard(@PathVariable Long id, @RequestParam(required = false) Long presupuestoId,
@RequestParam(required = false) Integer unidades,
@RequestParam(required = false) Integer isPalets,
Model model, Locale locale) {
Direccion dir = direccionService.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("pais", messageSource.getMessage("paises." + dir.getPais().getKeyword(), null,
dir.getPais().getKeyword(), locale));
model.addAttribute("presupuestoId", presupuestoId);
model.addAttribute("unidades", unidades);
model.addAttribute("isPalets", isPalets);
model.addAttribute("direccion", dir);
return "imprimelibros/direcciones/direccionCard :: direccionCard(direccion=${direccion})";
}
@PostMapping(value = "/update/{id}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public String updateCart(@PathVariable Long id, UpdateCartRequest updateRequest, Model model, Locale locale,
Principal principal) {
try {
service.updateCart(id, updateRequest);
var cartSummary = service.getCartSummary(service.getCartById(id), locale);
model.addAttribute("cartSummary", cartSummary);
return "imprimelibros/cart/_cartSummary :: cartSummary(summary=${cartSummary})";
} catch (Exception e) {
// redirect to cart with error message
String errorMessage = messageSource.getMessage("cart.update.error", null, "Error updating cart", locale);
model.addAttribute("errorMessage", errorMessage);
return "redirect:/cart";
}
}
@PostMapping(value = "/pass-to-customer/{customerId}")
public ResponseEntity<?> moveToCustomer(
@PathVariable Long customerId,
Principal principal) {
if(!Utils.isCurrentUserAdmin()) {
return ResponseEntity.status(403).body(Map.of("error", "Forbidden"));
}
Long userId = Utils.currentUserId(principal);
Cart cart = service.getOrCreateActiveCart(userId);
boolean ok = service.moveCartToCustomer(cart.getId(), customerId);
if (ok)
return ResponseEntity.ok().build();
return ResponseEntity.status(400).body(Map.of("error", "cart.errors.move-cart"));
}
}

View File

@ -0,0 +1,101 @@
package com.imprimelibros.erp.cart;
import java.util.Locale;
import org.springframework.context.MessageSource;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import jakarta.persistence.*;
@Entity
@Table(name = "cart_direcciones")
public class CartDireccion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id", nullable = false)
private Cart cart;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "direccion_id", nullable = false)
private Direccion direccion;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "presupuesto_id")
private Presupuesto presupuesto;
@Column(name = "unidades")
private Integer unidades;
@Column(name = "isPalets", nullable = false)
private Boolean isPalets;
// --- Getters & Setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Cart getCart() {
return cart;
}
public void setCart(Cart cart) {
this.cart = cart;
}
public Direccion getDireccion() {
return direccion;
}
public void setDireccion(Direccion direccion) {
this.direccion = direccion;
}
public Presupuesto getPresupuesto() {
return presupuesto;
}
public void setPresupuesto(Presupuesto presupuesto) {
this.presupuesto = presupuesto;
}
public Integer getUnidades() {
return unidades;
}
public void setUnidades(Integer unidades) {
this.unidades = unidades;
}
public Boolean getIsPalets() {
return isPalets;
}
public void setIsPalets(Boolean isPalets) {
this.isPalets = isPalets;
}
public DireccionCardDTO toDireccionCard(MessageSource messageSource, Locale locale) {
String pais = messageSource.getMessage("paises." + this.direccion.getPais().getKeyword(), null,
this.direccion.getPais().getKeyword(), locale);
return new DireccionCardDTO(
this.direccion,
this.presupuesto != null ? this.presupuesto.getId() : null,
this.unidades,
this.isPalets,
pais
);
}
}

View File

@ -3,6 +3,8 @@ package com.imprimelibros.erp.cart;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
@Entity
@Table(
name = "cart_items",
@ -17,8 +19,9 @@ public class CartItem {
@JoinColumn(name = "cart_id", nullable = false)
private Cart cart;
@Column(name = "presupuesto_id", nullable = false)
private Long presupuestoId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "presupuesto_id", nullable = false)
private Presupuesto presupuesto;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@ -29,8 +32,8 @@ public class CartItem {
public Cart getCart() { return cart; }
public void setCart(Cart cart) { this.cart = cart; }
public Long getPresupuestoId() { return presupuestoId; }
public void setPresupuestoId(Long presupuestoId) { this.presupuestoId = presupuestoId; }
public Presupuesto getPresupuesto() { return presupuesto; }
public void setPresupuesto(Presupuesto presupuesto) { this.presupuesto = presupuesto; }
public LocalDateTime getCreatedAt() { return createdAt; }
}

View File

@ -1,9 +1,22 @@
package com.imprimelibros.erp.cart;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface CartRepository extends JpaRepository<Cart, Long> {
Optional<Cart> findByUserIdAndStatus(Long userId, Cart.Status status);
@Query("""
select distinct c from Cart c
left join fetch c.direcciones cd
left join fetch cd.direccion d
left join fetch d.pais p
left join fetch cd.presupuesto pr
where c.id = :id
""")
Optional<Cart> findByIdFetchAll(@Param("id") Long id);
}

View File

@ -1,6 +1,6 @@
package com.imprimelibros.erp.cart;
import jakarta.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
@ -10,32 +10,43 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
import com.imprimelibros.erp.cart.dto.DireccionShipment;
import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.pedido.PedidoService;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
@Service
public class CartService {
private final CartRepository cartRepo;
private final CartItemRepository itemRepo;
private final MessageSource messageSource;
private final PresupuestoFormatter presupuestoFormatter;
private final PresupuestoRepository presupuestoRepo;
private final Utils utils;
private final DireccionService direccionService;
private final skApiClient skApiClient;
private final PedidoService pedidoService;
public CartService(CartRepository cartRepo, CartItemRepository itemRepo,
MessageSource messageSource, PresupuestoFormatter presupuestoFormatter,
PresupuestoRepository presupuestoRepo, Utils utils) {
PresupuestoRepository presupuestoRepo, Utils utils, DireccionService direccionService,
skApiClient skApiClient, PedidoService pedidoService) {
this.cartRepo = cartRepo;
this.itemRepo = itemRepo;
this.messageSource = messageSource;
this.presupuestoFormatter = presupuestoFormatter;
this.presupuestoRepo = presupuestoRepo;
this.utils = utils;
this.direccionService = direccionService;
this.skApiClient = skApiClient;
this.pedidoService = pedidoService;
}
/** Devuelve el carrito activo o lo crea si no existe. */
@ -50,6 +61,11 @@ public class CartService {
});
}
public Cart getCartById(Long cartId) {
return cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
}
/** Lista items (presupuestos) del carrito activo del usuario. */
@Transactional
public List<Map<String, Object>> listItems(Long userId, Locale locale) {
@ -58,14 +74,13 @@ public class CartService {
List<CartItem> items = itemRepo.findByCartId(cart.getId());
for (CartItem item : items) {
Presupuesto p = presupuestoRepo.findById(item.getPresupuestoId())
.orElseThrow(() -> new IllegalStateException("Presupuesto no encontrado: " + item.getPresupuestoId()));
Presupuesto p = item.getPresupuesto();
Map<String, Object> elemento = getElementoCart(p, locale);
elemento.put("cartItemId", item.getId());
resultados.add(elemento);
}
//System.out.println("Cart items: " + resultados);
// System.out.println("Cart items: " + resultados);
return resultados;
}
@ -77,7 +92,8 @@ public class CartService {
if (!exists) {
CartItem ci = new CartItem();
ci.setCart(cart);
ci.setPresupuestoId(presupuestoId);
ci.setPresupuesto(presupuestoRepo.findById(presupuestoId)
.orElseThrow(() -> new IllegalArgumentException("Presupuesto no encontrado")));
itemRepo.save(ci);
}
}
@ -96,8 +112,9 @@ public class CartService {
@Transactional
public void removeByPresupuesto(Long userId, Long presupuestoId) {
Cart cart = getOrCreateActiveCart(userId);
itemRepo.findByCartIdAndPresupuestoId(cart.getId(), presupuestoId)
.ifPresent(itemRepo::delete);
CartItem item = itemRepo.findByCartIdAndPresupuestoId(cart.getId(), presupuestoId)
.orElseThrow(() -> new IllegalArgumentException("Item no encontrado"));
itemRepo.deleteById(item.getId());
}
/** Vacía todo el carrito activo. */
@ -122,9 +139,9 @@ public class CartService {
}
private Map<String, Object> getElementoCart(Presupuesto presupuesto, Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
resumen.put("imagen",
@ -134,8 +151,15 @@ public class CartService {
resumen.put("presupuestoId", presupuesto.getId());
if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().contains("ejemplar-prueba")) {
resumen.put("hasSample", true);
} else {
resumen.put("hasSample", false);
}
Map<String, Object> detalles = utils.getTextoPresupuesto(presupuesto, locale);
resumen.put("tirada", presupuesto.getSelectedTirada());
resumen.put("baseTotal", Utils.formatCurrency(presupuesto.getBaseImponible(), locale));
resumen.put("base", presupuesto.getBaseImponible());
resumen.put("iva4", presupuesto.getIvaImporte4());
@ -145,4 +169,257 @@ public class CartService {
return resumen;
}
public Map<String, Object> getCartSummary(Cart cart, Locale locale) {
double base = 0.0;
double iva4 = 0.0;
double iva21 = 0.0;
double shipment = 0.0;
Boolean errorShipementCost = false;
List<CartItem> items = cart.getItems();
List<CartDireccion> direcciones = cart.getDirecciones();
for (CartItem item : items) {
Presupuesto p = item.getPresupuesto();
Double peso = p.getPeso() != null ? p.getPeso().doubleValue() : 0.0;
base += p.getBaseImponible().doubleValue();
iva4 += p.getIvaImporte4().doubleValue();
iva21 += p.getIvaImporte21().doubleValue();
if (cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) {
// Si es envío único, que es a españa y no ha canarias
if (direcciones != null && direcciones.size() > 0) {
CartDireccion cd = direcciones.get(0);
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment) {
Integer unidades = p.getSelectedTirada();
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) {
errorShipementCost = true;
}
else{
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
// si tiene prueba de envio, hay que añadir el coste
if (p.getServiciosJson() != null && p.getServiciosJson().contains("ejemplar-prueba")) {
Map<String, Object> res = getShippingCost(cd, peso, 1, locale);
if (res.get("success").equals(Boolean.FALSE)) {
errorShipementCost = true;
}
else{
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
}
} else {
// envio por cada presupuesto
// buscar la direccion asignada a este presupuesto
if (direcciones == null)
continue;
List<CartDireccion> cd_presupuesto = direcciones.stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() != null && d.getUnidades() != null && d.getUnidades() > 0)
.toList();
Boolean firstDirection = true;
for (CartDireccion cd : cd_presupuesto) {
Integer unidades = cd.getUnidades();
if (firstDirection) {
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment && unidades != null && unidades > 0) {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) {
errorShipementCost = true;
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
firstDirection = false;
} else {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) {
errorShipementCost = true;
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
}
// ejemplar de prueba
CartDireccion cd_prueba = direcciones.stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() == null)
.findFirst().orElse(null);
if (cd_prueba != null) {
Map<String, Object> res = getShippingCost(cd_prueba, peso, 1, locale);
if (res.get("success").equals(Boolean.FALSE)) {
errorShipementCost = true;
}
else{
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
}
}
double total = base + iva4 + iva21 + shipment;
int fidelizacion = pedidoService.getDescuentoFidelizacion();
double descuento = (total) * fidelizacion / 100.0;
total -= descuento;
Map<String, Object> summary = new HashMap<>();
summary.put("base", Utils.formatCurrency(base, locale));
summary.put("iva4", Utils.formatCurrency(iva4, locale));
summary.put("iva21", Utils.formatCurrency(iva21, locale));
summary.put("shipment", Utils.formatCurrency(shipment, locale));
summary.put("fidelizacion", fidelizacion + "%");
summary.put("descuento", Utils.formatCurrency(-descuento, locale));
summary.put("total", Utils.formatCurrency(total, locale));
summary.put("errorShipmentCost", errorShipementCost);
return summary;
}
@Transactional(readOnly = true)
public Map<String, Object> getCartDirecciones(Long cartId, Locale locale) {
Cart cart = cartRepo.findByIdFetchAll(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
Map<String, Object> result = new HashMap<>();
List<CartDireccion> direcciones = cart.getDirecciones();
if (cart.getOnlyOneShipment() && !direcciones.isEmpty()) {
result.put("mainDir", direcciones.get(0).toDireccionCard(messageSource, locale));
} else {
List<DireccionCardDTO> dirCards = cart.getDirecciones().stream()
.filter(Objects::nonNull)
.map(cd -> cd.toDireccionCard(messageSource, locale))
.filter(Objects::nonNull)
.toList();
result.put("direcciones", dirCards);
}
return result;
}
@Transactional
public Boolean updateCart(Long cartId, UpdateCartRequest request) {
try {
Cart cart = cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
cart.setOnlyOneShipment(request.isOnlyOneShipment());
// Borramos todas las direcciones actuales de la bbdd
// Opcional (limpieza): romper backref antes de clear
for (CartDireccion d : cart.getDirecciones()) {
d.setCart(null);
}
cart.getDirecciones().clear();
// Guardamos las direcciones
List<DireccionShipment> direcciones = request.getDirecciones();
if (direcciones != null && direcciones.size() > 0) {
for (DireccionShipment dir : direcciones) {
// Crear una nueva CartDireccion por cada item
CartDireccion cd = new CartDireccion();
cd.setCart(cart);
cd.setDireccion(dir.getId() != null ? direccionService.findById(dir.getId())
.orElseThrow(() -> new IllegalArgumentException("Dirección no encontrada")) : null);
cd.setIsPalets(dir.getIsPalets() != null ? dir.getIsPalets() : false);
cd.setUnidades(dir.getUnidades() != null ? dir.getUnidades() : null);
if (dir.getPresupuestoId() != null) {
Presupuesto p = presupuestoRepo.findById(dir.getPresupuestoId())
.orElse(null);
cd.setPresupuesto(p);
}
cart.addDireccion(cd);
}
} else {
}
cartRepo.save(cart);
return true;
} catch (Exception e) {
// Manejo de excepciones
return false;
}
}
public Boolean moveCartToCustomer(Long cartId, Long customerId) {
try {
// Remove the cart from the customer if they have one
Cart existingCart = cartRepo.findByUserIdAndStatus(customerId, Cart.Status.ACTIVE)
.orElse(null);
if (existingCart != null) {
cartRepo.delete(existingCart);
}
Cart cart = cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
cart.setUserId(customerId);
cartRepo.save(cart);
return true;
} catch (Exception e) {
// Manejo de excepciones
return false;
}
}
/***************************************
* MÉTODOS PRIVADOS
***************************************/
private Map<String, Object> getShippingCost(
CartDireccion cd,
Double peso,
Integer unidades,
Locale locale) {
Map<String, Object> result = new HashMap<>();
try {
Map<String, Object> data = Map.of(
"cp", cd.getDireccion().getCp(),
"pais_code3", cd.getDireccion().getPaisCode3(),
"peso", peso != null ? peso : 0.0,
"unidades", unidades,
"palets", Boolean.TRUE.equals(cd.getIsPalets()) ? 1 : 0);
var shipmentCost = skApiClient.getCosteEnvio(data, locale);
if (shipmentCost != null && shipmentCost.get("data") != null) {
Number n = (Number) shipmentCost.get("data");
double cost = n.doubleValue();
result.put("success", true);
result.put("shipment", cost);
result.put("iva21", cost * 0.21);
} else {
result.put("success", false);
result.put("shipment", 0.0);
result.put("iva21", 0.0);
}
} catch (Exception e) {
result.put("success", false);
result.put("shipment", 0.0);
result.put("iva21", 0.0);
}
return result;
}
}

View File

@ -0,0 +1,15 @@
package com.imprimelibros.erp.cart.dto;
import com.imprimelibros.erp.cart.CartDireccion;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CartDireccionRepository extends JpaRepository<CartDireccion, Long> {
// Borrado masivo por cart_id
void deleteByCartId(Long cartId);
// Lectura por cart_id (útil para componer respuestas)
List<CartDireccion> findByCartId(Long cartId);
}

View File

@ -0,0 +1,39 @@
package com.imprimelibros.erp.cart.dto;
import com.imprimelibros.erp.direcciones.Direccion;
public class DireccionCardDTO {
private final Direccion direccion;
private final Long presupuestoId;
private final Integer unidades;
private final Boolean isPalets;
private final String pais;
public DireccionCardDTO(Direccion direccion, Long presupuestoId, Integer unidades, Boolean isPalets, String pais) {
this.direccion = direccion;
this.presupuestoId = presupuestoId;
this.unidades = unidades;
this.isPalets = isPalets;
this.pais = pais;
}
public Direccion getDireccion() {
return direccion;
}
public Long getPresupuestoId() {
return presupuestoId;
}
public Integer getUnidades() {
return unidades;
}
public Boolean getIsPalets() {
return isPalets;
}
public String getPais() {
return pais;
}
}

View File

@ -0,0 +1,60 @@
package com.imprimelibros.erp.cart.dto;
public class DireccionShipment {
private Long id; // puede no venir → null
private String cp; // puede no venir → null
private String paisCode3; // puede no venir → null
private Long presupuestoId; // puede no venir → null
private Integer unidades; // puede no venir → null
private Boolean isPalets; // puede no venir → null
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCp() {
return cp;
}
public void setCp(String cp) {
this.cp = cp;
}
public String getPaisCode3() {
return paisCode3;
}
public void setPaisCode3(String paisCode3) {
this.paisCode3 = paisCode3;
}
public Long getPresupuestoId() {
return presupuestoId;
}
public void setPresupuestoId(Long presupuestoId) {
this.presupuestoId = presupuestoId;
}
public Integer getUnidades() {
return unidades;
}
public void setUnidades(Integer unidades) {
this.unidades = unidades;
}
public Boolean getIsPalets() {
return isPalets;
}
public void setIsPalets(Boolean isPalets) {
this.isPalets = isPalets;
}
}

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.cart.dto;
import java.util.ArrayList;
import java.util.List;
public class UpdateCartRequest {
private Boolean onlyOneShipment = Boolean.FALSE; // default: false
private List<DireccionShipment> direcciones = new ArrayList<>();
public boolean isOnlyOneShipment() { // boolean-style getter
return Boolean.TRUE.equals(onlyOneShipment);
}
public void setOnlyOneShipment(Boolean onlyOneShipment) {
this.onlyOneShipment = onlyOneShipment;
}
public List<DireccionShipment> getDirecciones() {
return direcciones;
}
public void setDirecciones(List<DireccionShipment> direcciones) {
this.direcciones = (direcciones != null) ? direcciones : new ArrayList<>();
}
}

View File

@ -0,0 +1,66 @@
package com.imprimelibros.erp.checkout;
import java.security.Principal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.paises.PaisesService;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.cart.CartService;
@Controller
@RequestMapping("/checkout")
public class CheckoutController {
protected CartService cartService;
protected TranslationService translationService;
protected PaisesService paisesService;
protected DireccionService direccionService;
protected MessageSource messageSource;
public CheckoutController(CartService cartService, TranslationService translationService,
PaisesService paisesService, DireccionService direccionService, MessageSource messageSource) {
this.cartService = cartService;
this.translationService = translationService;
this.paisesService = paisesService;
this.direccionService = direccionService;
this.messageSource = messageSource;
}
@GetMapping
public String view(Model model, Principal principal, Locale locale) {
List<String> keys = List.of(
"app.cancelar",
"app.seleccionar",
"checkout.shipping.add.title",
"checkout.shipping.select-placeholder",
"checkout.shipping.new-address",
"app.yes",
"app.cancelar");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
var items = this.cartService.listItems(Utils.currentUserId(principal), locale);
for (var item : items) {
if (item.get("hasSample") != null && (Boolean) item.get("hasSample")) {
model.addAttribute("hasSample", true);
break;
}
}
model.addAttribute("items", items);
return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple)
}
}

View File

@ -2,6 +2,7 @@ package com.imprimelibros.erp.common;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.security.Principal;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.HashMap;
@ -12,6 +13,8 @@ import java.util.Optional;
import java.util.function.BiFunction;
import org.springframework.context.MessageSource;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
@ -22,6 +25,8 @@ import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatrices;
import com.imprimelibros.erp.presupuesto.marcapaginas.Marcapaginas;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDetailsImpl;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Path;
@ -40,6 +45,30 @@ public class Utils {
this.messageSource = messageSource;
}
public static boolean isCurrentUserAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
}
public static Long currentUserId(Principal principal) {
if (principal == null) {
throw new IllegalStateException("Usuario no autenticado");
}
if (principal instanceof Authentication auth) {
Object principalObj = auth.getPrincipal();
if (principalObj instanceof UserDetailsImpl udi) {
return udi.getId();
} else if (principalObj instanceof User u && u.getId() != null) {
return u.getId();
}
}
throw new IllegalStateException("No se pudo obtener el ID del usuario actual");
}
public static String formatCurrency(BigDecimal amount, Locale locale) {
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
return currencyFormatter.format(amount);

View File

@ -91,7 +91,6 @@ public class SecurityConfig {
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
.csrf(csrf -> csrf
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/")))
// ====== RequestCache: sólo navegaciones HTML reales ======
.requestCache(rc -> {
HttpSessionRequestCache cache = new HttpSessionRequestCache();

View File

@ -1,5 +1,6 @@
package com.imprimelibros.erp.direcciones;
import java.security.Principal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@ -43,20 +44,23 @@ import jakarta.validation.Valid;
@RequestMapping("/direcciones")
public class DireccionController {
private final DireccionService direccionService;
protected final DireccionRepository repo;
protected final PaisesService paisesService;
protected final MessageSource messageSource;
protected final UserDao userRepo;
protected final TranslationService translationService;
public DireccionController(DireccionRepository repo, PaisesService paisesService,
MessageSource messageSource, UserDao userRepo, TranslationService translationService) {
MessageSource messageSource, UserDao userRepo, TranslationService translationService,
DireccionService direccionService) {
this.repo = repo;
this.paisesService = paisesService;
this.messageSource = messageSource;
this.userRepo = userRepo;
this.translationService = translationService;
this.direccionService = direccionService;
}
@GetMapping()
@ -295,6 +299,33 @@ public class DireccionController {
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
@GetMapping("direction-form")
public String getForm(@RequestParam(required = false) Long id,
Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Principal principal,
Locale locale) {
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
Direccion newDireccion = new Direccion();
User user = null;
if (principal instanceof UserDetailsImpl udi) {
user = new User();
user.setId(udi.getId());
} else if (principal instanceof User u && u.getId() != null) {
user = u;
}
newDireccion.setUser(user);
model.addAttribute("dirForm", newDireccion);
model.addAttribute("action", "/direcciones/add");
return "imprimelibros/direcciones/direccion-form-fixed-user :: direccionForm";
}
@PostMapping
public String create(
@Valid @ModelAttribute("dirForm") Direccion direccion,
@ -327,6 +358,34 @@ public class DireccionController {
return null;
}
// para el formulario modal en checkout
@PostMapping("/add")
public String create2(
@Valid @ModelAttribute("dirForm") Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Authentication auth,
Locale locale) {
User current = userRepo.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(auth.getName()).orElse(null);
direccion.setUser(current);
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
model.addAttribute("action", "/direcciones/add");
model.addAttribute("dirForm", direccion);
return "imprimelibros/direcciones/direccion-form-fixed-user :: direccionForm";
}
var data = direccion;
repo.save(data);
response.setStatus(201);
return null;
}
@PostMapping("/{id}")
public String update(
@PathVariable Long id,
@ -416,12 +475,36 @@ public class DireccionController {
}
}
@GetMapping(value = "/select2", produces = "application/json")
@ResponseBody
public Map<String, Object> getSelect2(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
@RequestParam(value = "presupuestoId", required = false) Long presupuestoId,
Authentication auth) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
Long currentUserId = null;
if (!isAdmin) {
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else if (auth != null) {
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null);
}
}
return direccionService.getForSelect(q1, q2, isAdmin ? null : currentUserId);
}
private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) {
if (auth == null) {
return false;
}
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
if (isAdmin) {
return true;
}
@ -434,4 +517,5 @@ public class DireccionController {
}
return currentUserId != null && currentUserId.equals(ownerId);
}
}

View File

@ -0,0 +1,97 @@
package com.imprimelibros.erp.direcciones;
import java.text.Collator;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@Service
public class DireccionService {
protected DireccionRepository repo;
public DireccionService(DireccionRepository repo) {
this.repo = repo;
}
public Map<String, Object> getForSelect(String q1, String q2, Long userId) {
try {
// Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config)
String search = Optional.ofNullable(q1).orElse(q2);
if (search != null) {
search = search.trim();
}
final String q = (search == null || search.isEmpty())
? null
: search.toLowerCase();
List<Direccion> all = userId != null ? repo.findByUserId(userId) : repo.findAll();
// Mapear a opciones id/text con i18n y filtrar por búsqueda si llega
List<Map<String, String>> options = all.stream()
.map(cc -> {
String id = cc.getId().toString();
String alias = cc.getAlias();
String direccion = cc.getDireccion();
String cp = String.valueOf(cc.getCp());
String ciudad = cc.getCiudad();
String att = cc.getAtt();
Map<String, String> m = new HashMap<>();
m.put("id", id); // lo normal en Select2: id = valor que guardarás (code3)
m.put("text", alias); // texto mostrado, i18n con fallback a keyword
m.put("cp", cp);
m.put("ciudad", ciudad);
m.put("att", att);
m.put("alias", alias);
m.put("direccion", direccion);
return m;
})
.filter(opt -> {
if (q == null || q.isEmpty())
return true;
String cp = opt.get("cp");
String ciudad = opt.get("ciudad").toLowerCase();
String att = opt.get("att").toLowerCase();
String alias = opt.get("alias").toLowerCase();
String text = opt.get("text").toLowerCase();
String direccion = opt.get("direccion").toLowerCase();
return text.contains(q) || cp.contains(q) || ciudad.contains(q) || att.contains(q)
|| alias.contains(q) || direccion.contains(q);
})
.sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance()))
.collect(Collectors.toList());
// Estructura Select2
Map<String, Object> resp = new HashMap<>();
resp.put("results", options);
return resp;
} catch (Exception e) {
e.printStackTrace();
return Map.of("results", List.of());
}
}
public Optional<Direccion> findById(Long id) {
return repo.findById(id);
}
public Boolean checkFreeShipment(Integer cp, String paisCode3) {
if (paisCode3 != null && paisCode3.toLowerCase().equals("esp") && cp != null) {
// Excluir Canarias (35xxx y 38xxx), Baleares (07xxx), Ceuta (51xxx), Melilla
// (52xxx)
int provincia = cp / 1000;
if (provincia != 7 && provincia != 35 && provincia != 38 && provincia != 51 && provincia != 52) {
return true; // España peninsular
}
}
return false;
}
}

View File

@ -6,6 +6,7 @@ import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -18,8 +19,10 @@ import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import java.util.Map;
import java.util.Optional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.function.Supplier;
@ -219,6 +222,54 @@ public class skApiClient {
}
}
public Map<String, Object> getCosteEnvio(Map<String, Object> data, Locale locale) {
return performWithRetryMap(() -> {
String url = this.skApiUrl + "api/calcular-envio";
URI uri = UriComponentsBuilder.fromUriString(url)
.queryParam("pais_code3", data.get("pais_code3"))
.queryParam("cp", data.get("cp"))
.queryParam("peso", data.get("peso"))
.queryParam("unidades", data.get("unidades"))
.queryParam("palets", data.get("palets"))
.build(true) // no re-encode []
.toUri();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authService.getToken());
ResponseEntity<String> response = restTemplate.exchange(
uri,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
try {
Map<String, Object> responseBody = new ObjectMapper().readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
Boolean error = (Boolean) responseBody.get("error");
if (error != null && error) {
return Map.of("error", messageSource.getMessage("direcciones.error.noShippingCost", null, locale));
} else {
Double total = Optional.ofNullable(responseBody.get("data"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.map(Number::doubleValue)
.orElse(0.0);
return Map.of("data", total);
}
} catch (JsonProcessingException e) {
e.printStackTrace();
return Map.of("error", "Internal Server Error: 1"); // Fallback en caso de error
}
});
}
/******************
* PRIVATE METHODS
******************/
@ -236,6 +287,20 @@ public class skApiClient {
}
}
private Map<String, Object> performWithRetryMap(Supplier<Map<String, Object>> request) {
try {
return request.get();
} catch (HttpClientErrorException.Unauthorized e) {
// Token expirado, renovar y reintentar
authService.invalidateToken();
try {
return request.get(); // segundo intento
} catch (HttpClientErrorException ex) {
throw new RuntimeException("La autenticación ha fallado tras renovar el token.", ex);
}
}
}
private static BigDecimal calcularMargen(
BigDecimal importe, BigDecimal importeMin, BigDecimal importeMax,
BigDecimal margenMax, BigDecimal margenMin) {

View File

@ -1,8 +1,6 @@
package com.imprimelibros.erp.pdf;
import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.springframework.core.io.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.pedido;
import org.springframework.stereotype.Service;
@Service
public class PedidoService {
public int getDescuentoFidelizacion() {
// descuento entre el 1% y el 6% para clientes fidelidad (mas de 1500€ en el ultimo año)
double totalGastado = 1600.0; // Ejemplo, deberías obtenerlo del historial del cliente
if(totalGastado < 1200) {
return 0;
} else if(totalGastado >= 1200 && totalGastado < 1999) {
return 1;
} else if(totalGastado >= 2000 && totalGastado < 2999) {
return 2;
} else if(totalGastado >= 3000 && totalGastado < 3999) {
return 3;
} else if(totalGastado >= 4000 && totalGastado < 4999) {
return 4;
} else if(totalGastado >= 5000) {
return 5;
}
return 0;
}
}

View File

@ -4,6 +4,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import java.security.Principal;
import java.time.Instant;
import java.util.HashMap;
import java.util.Locale;
@ -621,14 +622,14 @@ public class PresupuestoController {
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatable(
HttpServletRequest request, Authentication auth, Locale locale,
@PathVariable("tipo") String tipo) {
@PathVariable("tipo") String tipo, Principal principal) {
DataTablesRequest dt = DataTablesParser.from(request);
if ("anonimos".equals(tipo)) {
return dtService.datatablePublicos(dt, locale);
return dtService.datatablePublicos(dt, locale, principal);
} else if ("clientes".equals(tipo)) {
return dtService.datatablePrivados(dt, locale);
return dtService.datatablePrivados(dt, locale, principal);
} else {
throw new IllegalArgumentException("Tipo de datatable no válido");
}

View File

@ -1,15 +1,18 @@
package com.imprimelibros.erp.presupuesto;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto;
import com.imprimelibros.erp.datatables.*;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import jakarta.persistence.criteria.Expression;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.Principal;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
@ -26,18 +29,29 @@ public class PresupuestoDatatableService {
}
@Transactional(readOnly = true)
public DataTablesResponse<Map<String, Object>> datatablePublicos(DataTablesRequest dt, Locale locale) {
return commonDataTable(dt, locale, "publico", true);
public DataTablesResponse<Map<String, Object>> datatablePublicos(DataTablesRequest dt, Locale locale,
Principal principal) {
return commonDataTable(dt, locale, "publico", true, principal);
}
@Transactional(readOnly = true)
public DataTablesResponse<Map<String, Object>> datatablePrivados(DataTablesRequest dt, Locale locale) {
return commonDataTable(dt, locale, "privado", false);
public DataTablesResponse<Map<String, Object>> datatablePrivados(DataTablesRequest dt, Locale locale,
Principal principal) {
return commonDataTable(dt, locale, "privado", false, principal);
}
private DataTablesResponse<Map<String, Object>> commonDataTable(DataTablesRequest dt, Locale locale, String origen,
boolean publico) {
Long count = repo.findAllByOrigen(Presupuesto.Origen.valueOf(origen)).stream().count();
boolean publico, Principal principal) {
Specification<Presupuesto> base = Specification.allOf(
(root, query, cb) -> cb.equal(root.get("origen"), Presupuesto.Origen.valueOf(origen)));
Boolean isAdmin = Utils.isCurrentUserAdmin();
if (!isAdmin) {
base = base.and((root, query, cb) -> cb.equal(root.get("user").get("id"), Utils.currentUserId(principal)));
}
Long count = repo.count(base);
List<String> orderable = List.of(
"id", "titulo", "user.fullName", "tipoEncuadernacion", "tipoCubierta", "tipoImpresion",
@ -74,6 +88,7 @@ public class PresupuestoDatatableService {
.add("updatedAt", p -> formatDate(p.getUpdatedAt(), locale))
.addIf(!publico, "user", p -> p.getUser() != null ? p.getUser().getFullName() : "")
.add("actions", this::generarBotones)
.where(base)
.toJson(count);
}

View File

@ -920,4 +920,28 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
public void setId(Long id){
this.id = id;
}
public Double getPeso(){
// get peso from first element of pricingSnapshotJson (need to parse JSON)
// pricingSnapshotJson = {"xxx":{"peso":0.5,...}} is a String
if (this.pricingSnapshotJson != null && !this.pricingSnapshotJson.isEmpty()) {
try {
String json = this.pricingSnapshotJson.trim();
int pesoIndex = json.indexOf("\"peso\":");
if (pesoIndex != -1) {
int startIndex = pesoIndex + 7;
int endIndex = json.indexOf(",", startIndex);
if (endIndex == -1) {
endIndex = json.indexOf("}", startIndex);
}
String pesoStr = json.substring(startIndex, endIndex).trim();
return Double.parseDouble(pesoStr);
}
} catch (Exception e) {
// log error
e.printStackTrace();
}
}
return null;
}
}

View File

@ -0,0 +1,83 @@
package com.imprimelibros.erp.redsys;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/pagos/redsys")
public class RedsysController {
private final RedsysService service;
public RedsysController(RedsysService service) {
this.service = service;
}
@PostMapping("/crear")
public String crearPago(@RequestParam String order,
@RequestParam long amountCents,
Model model) throws Exception {
var req = new RedsysService.PaymentRequest(order, amountCents, "Compra en ImprimeLibros");
var form = service.buildRedirectForm(req);
model.addAttribute("action", form.action());
model.addAttribute("signatureVersion", form.signatureVersion());
model.addAttribute("merchantParameters", form.merchantParameters());
model.addAttribute("signature", form.signature());
return "imprimelibros/payments/redsys-redirect";
}
@PostMapping("/notify")
@ResponseBody
public ResponseEntity<String> notifyRedsys(
@RequestParam("Ds_Signature") String dsSignature,
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters) {
try {
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature,
dsMerchantParameters);
// 1) Idempotencia: comprueba si el pedido ya fue procesado
// 2) Valida que importe/moneda/pedido coincidan con lo que esperabas
// 3) Marca como pagado si notif.authorized() == true
return ResponseEntity.ok("OK"); // Redsys espera "OK"
} catch (SecurityException se) {
// Firma incorrecta: NO procesar
return ResponseEntity.status(400).body("BAD SIGNATURE");
} catch (Exception e) {
return ResponseEntity.status(500).body("ERROR");
}
}
@PostMapping("/ok")
public String okReturn(@RequestParam("Ds_Signature") String dsSignature,
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters,
Model model) {
try {
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, dsMerchantParameters);
// Aquí puedes validar importe/pedido/moneda con tu base de datos y marcar como
// pagado
model.addAttribute("authorized", notif.authorized());
//model.addAttribute("order", notif.order());
//model.addAttribute("amountCents", notif.amountCents());
return "imprimelibros/payments/redsys-ok";
} catch (Exception e) {
model.addAttribute("error", "No se pudo validar la respuesta de Redsys.");
return "imprimelibros/payments/redsys-ko";
}
}
@PostMapping("/ko")
public String koReturn(@RequestParam(value = "Ds_Signature", required = false) String dsSignature,
@RequestParam(value = "Ds_MerchantParameters", required = false) String dsMerchantParameters,
Model model) {
// Suele venir cuando el usuario cancela o hay error
model.addAttribute("error", "Operación cancelada o rechazada.");
return "imprimelibros/payments/redsys-ko";
}
}

View File

@ -0,0 +1,174 @@
package com.imprimelibros.erp.redsys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import sis.redsys.api.ApiMacSha256;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
@Service
public class RedsysService {
// ---------- CONFIG ----------
@Value("${redsys.merchant-code}")
private String merchantCode;
@Value("${redsys.terminal}")
private String terminal;
@Value("${redsys.currency}")
private String currency;
@Value("${redsys.transaction-type}")
private String txType;
@Value("${redsys.secret-key}")
private String secretKeyBase64;
@Value("${redsys.urls.ok}")
private String urlOk;
@Value("${redsys.urls.ko}")
private String urlKo;
@Value("${redsys.urls.notify}")
private String urlNotify;
@Value("${redsys.environment}")
private String env;
// ---------- RECORDS ----------
public record PaymentRequest(String order, long amountCents, String description) {
}
public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) {
}
// ---------- MÉTODO PRINCIPAL ----------
public FormPayload buildRedirectForm(PaymentRequest req) throws Exception {
ApiMacSha256 api = new ApiMacSha256();
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents()));
api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros si puedes
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
api.setParameter("DS_MERCHANT_CURRENCY", currency);
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
api.setParameter("DS_MERCHANT_TERMINAL", terminal);
api.setParameter("DS_MERCHANT_MERCHANTURL", urlNotify);
api.setParameter("DS_MERCHANT_URLOK", urlOk);
api.setParameter("DS_MERCHANT_URLKO", urlKo);
String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64);
String action = "test".equalsIgnoreCase(env)
? "https://sis-t.redsys.es:25443/sis/realizarPago"
: "https://sis.redsys.es/sis/realizarPago";
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
}
// ---------- STEP 3: Decodificar Ds_MerchantParameters ----------
private static final ObjectMapper MAPPER = new ObjectMapper();
public Map<String, Object> decodeMerchantParametersToMap(String dsMerchantParametersB64) throws Exception {
try {
byte[] decoded = Base64.getDecoder().decode(dsMerchantParametersB64);
String json = new String(decoded, StandardCharsets.UTF_8);
return MAPPER.readValue(json, new TypeReference<>() {
});
} catch (Exception e) {
throw new IllegalArgumentException("No se pudo decodificar Ds_MerchantParameters", e);
}
}
// ---------- STEP 4: Validar notificación ----------
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
throws Exception {
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
RedsysNotification notif = new RedsysNotification(mp);
if (notif.order == null || notif.order.isBlank()) {
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
}
ApiMacSha256 api = new ApiMacSha256();
api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64);
String expected = api.createMerchantSignatureNotif(secretKeyBase64, api.decodeMerchantParameters(dsMerchantParametersB64)); // ✅ SOLO UN PARÁMETRO
if (!safeEqualsB64(dsSignature, expected)) {
throw new SecurityException("Firma Redsys no válida");
}
return notif;
}
// ---------- HELPERS ----------
private static boolean safeEqualsB64(String a, String b) {
if (Objects.equals(a, b))
return true;
try {
String na = normalizeB64(a);
String nb = normalizeB64(b);
byte[] da = Base64.getDecoder().decode(na);
byte[] db = Base64.getDecoder().decode(nb);
return MessageDigest.isEqual(da, db);
} catch (Exception e) {
return false;
}
}
private static String normalizeB64(String s) {
if (s == null)
return "";
String n = s.replace('-', '+').replace('_', '/');
int mod = n.length() % 4;
if (mod == 2)
n += "==";
else if (mod == 3)
n += "=";
else if (mod == 1)
n += "===";
return n;
}
// ---------- MODELO DE NOTIFICACIÓN ----------
public static final class RedsysNotification {
public final Map<String, Object> raw;
public final String order;
public final String response;
public final long amountCents;
public final String currency;
public RedsysNotification(Map<String, Object> raw) {
this.raw = raw;
this.order = str(raw.get("Ds_Order"));
this.response = str(raw.get("Ds_Response"));
this.currency = str(raw.get("Ds_Currency"));
this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
}
public boolean authorized() {
try {
int r = Integer.parseInt(response);
return r >= 0 && r <= 99;
} catch (Exception e) {
return false;
}
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static long parseLongSafe(Object o) {
try {
return Long.parseLong(String.valueOf(o));
} catch (Exception e) {
return 0L;
}
}
}
}

View File

@ -1,6 +1,9 @@
package com.imprimelibros.erp.users;
import org.springframework.security.core.userdetails.UserDetailsService;
import java.util.Map;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

View File

@ -2,10 +2,21 @@ package com.imprimelibros.erp.users;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import java.text.Collator;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import com.imprimelibros.erp.direcciones.Direccion;
@Service
public class UserServiceImpl implements UserService {
@ -29,5 +40,4 @@ public class UserServiceImpl implements UserService {
if (query == null || query.isBlank()) query = null;
return userDao.searchUsers(role, query, pageable);
}
}

View File

@ -1,5 +1,9 @@
spring.application.name=erp
server.forward-headers-strategy=framework
server.servlet.session.cookie.secure=true
#
# Logging
#
@ -11,8 +15,8 @@ logging.level.org.springframework=ERROR
#
# Database Configuration
#
spring.datasource.url=jdbc:mysql://localhost:3309/imprimelibros
#spring.datasource.url=jdbc:mysql://127.0.0.1:3309/imprimelibros?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Europe/Madrid&characterEncoding=utf8
#spring.datasource.url=jdbc:mysql://localhost:3309/imprimelibros
spring.datasource.url=jdbc:mysql://127.0.0.1:3309/imprimelibros?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Europe/Madrid&characterEncoding=utf8
spring.datasource.username=imprimelibros_user
spring.datasource.password=om91irrDctd
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
@ -101,3 +105,22 @@ spring.liquibase.change-log=classpath:db/changelog/master.yml
# spring.liquibase.url=jdbc:mysql://localhost:3306/imprimelibros
# spring.liquibase.user=tu_user
# spring.liquibase.password=tu_pass
# Redsys
redsys.environment=test
redsys.merchant-code=124760810
redsys.terminal=1
redsys.currency=978
redsys.transaction-type=0
redsys.secret-key=sq7HjrUOBfKmC576ILgskD5srU870gJ7
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
redsys.urls.notify=http://localhost:8080/pagos/redsys/notify
# Mensajes de error mas cortos
# Oculta el stack trace en los errores del servidor
server.error.include-stacktrace=never
# No mostrar el mensaje completo de excepción en la respuesta
server.error.include-message=always

View File

@ -0,0 +1,37 @@
databaseChangeLog:
- changeSet:
id: 0005-add-carts-onlyoneshipment
author: jjo
preConditions:
onFail: MARK_RAN
not:
columnExists:
tableName: carts
columnName: only_one_shipment
changes:
- addColumn:
tableName: carts
columns:
- column:
name: only_one_shipment
type: TINYINT(1)
defaultValueNumeric: 1
remarks: "Si 1, el carrito tiene la misma direccion para todos los items"
afterColumn: currency
constraints:
nullable: false
- column:
name: total
type: DECIMAL(19,6)
defaultValueNumeric: 0
remarks: "Total del carrito"
afterColumn: only_one_shipment
constraints:
nullable: false
rollback:
- dropColumn:
tableName: carts
columnName: only_one_shipment

View File

@ -0,0 +1,94 @@
databaseChangeLog:
- changeSet:
id: 0006-add-cart-direcciones
author: jjo
preConditions:
onFail: MARK_RAN
not:
tableExists:
tableName: cart_direcciones
changes:
- createTable:
tableName: cart_direcciones
remarks: "Relación de direcciones y unidades por carrito/direcciones_envio"
columns:
- column:
name: id
type: BIGINT UNSIGNED AUTO_INCREMENT
constraints:
primaryKey: true
primaryKeyName: pk_cart_direcciones
- column:
name: cart_id
type: BIGINT
constraints:
nullable: false
- column:
name: direccion_id
type: BIGINT
constraints:
nullable: false
- column:
name: presupuesto_id
type: BIGINT
constraints:
nullable: true
- column:
name: unidades
type: INT
constraints:
nullable: true
- column:
name: is_palets
type: TINYINT(1)
constraints:
nullable: false
defaultValue: false
- createIndex:
indexName: idx_cart_dir_direccion_id
tableName: cart_direcciones
columns:
- column:
name: direccion_id
- createIndex:
indexName: idx_cart_dir_presupuesto_id
tableName: cart_direcciones
columns:
- column:
name: presupuesto_id
- addForeignKeyConstraint:
baseTableName: cart_direcciones
baseColumnNames: direccion_id
constraintName: fk_cart_dir_direccion
referencedTableName: direcciones
referencedColumnNames: id
onDelete: CASCADE
onUpdate: CASCADE
- addForeignKeyConstraint:
baseTableName: cart_direcciones
baseColumnNames: presupuesto_id
constraintName: fk_cart_dir_presupuesto
referencedTableName: presupuesto
referencedColumnNames: id
onDelete: SET NULL
onUpdate: CASCADE
rollback:
- dropForeignKeyConstraint:
baseTableName: cart_direcciones
constraintName: fk_cart_dir_direccion
- dropForeignKeyConstraint:
baseTableName: cart_direcciones
constraintName: fk_cart_dir_presupuesto
- dropTable:
tableName: cart_direcciones

View File

@ -6,4 +6,8 @@ databaseChangeLog:
- include:
file: db/changelog/changesets/0003-create-paises.yml
- include:
file: db/changelog/changesets/0004-create-direcciones.yml
file: db/changelog/changesets/0004-create-direcciones.yml
- include:
file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml
- include:
file: db/changelog/changesets/0006-add-cart-direcciones.yml

View File

@ -3,6 +3,7 @@ app.yes=Sí
app.no=No
app.aceptar=Aceptar
app.cancelar=Cancelar
app.seleccionar=Seleccionar
app.guardar=Guardar
app.editar=Editar
app.add=Añadir

View File

@ -4,11 +4,47 @@ cart.empty=Tu cesta de la compra está vacía.
cart.item.presupuesto-numero=Presupuesto #
cart.precio=Precio
cart.tabs.details=Detalles
cart.tabs.envio=Envío
cart.shipping.add=Añadir dirección
cart.shipping.add.title=Seleccione una dirección
cart.shipping.select-placeholder=Buscar en direcciones...
cart.shipping.new-address=Nueva dirección
cart.shipping.info=Todos los pedidos incluyen un envío gratuito a la Península y Baleares por línea de pedido.
cart.shipping.order=Envío del pedido
cart.shipping.samples=Envío de prueba
cart.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección.
cart.shipping.tirada=Tirada:
cart.shipping.unidades=unidades
cart.shipping.ud=ud.
cart.shipping.uds=uds.
cart.shipping.enter-units=Introduzca el número de unidades para esta dirección:
cart.shipping.units-label=Número de unidades (máximo {max})
cart.shipping.send-in-palets=Enviar en palets
cart.shipping.send-in-palets.info=Marque esta opción si desea que el envío se realice en palets (sólo para tiradas grandes). La entrega se realizará a pie de calle.
cart.shipping.tipo-envio=Tipo de envío:
cart.shipping.errors.units-error=Por favor, introduzca un número válido entre 1 y {max}.
cart.shipping.errors.noAddressSelected=Debe seleccionar una dirección de envío para el pedido.
cart.shipping.errors.fillAddressesItems=Debe seleccionar una dirección de envío para cada artículo de la cesta.
cart.resumen.title=Resumen de la cesta
cart.resumen.base=Base imponible:
cart.resumen.iva-4=IVA 4%:
cart.resumen.iva-21=IVA 21%:
cart.resumen.total=Total cesta:
cart.resumen.base=Base imponible
cart.resumen.envio=Coste de envío
cart.resumen.iva-4=IVA 4%
cart.resumen.iva-21=IVA 21%
cart.resumen.descuento=Descuento fidelización
cart.resumen.total=Total cesta
cart.resumen.tramitar=Tramitar pedido
cart.resumen.fidelizacion=Si tiene descuento por fidelización, se aplicará al tramitar el pedido.
cart.pass-to.customer=Mover cesta a cliente
cart.pass-to.customer.info=Puede mover la cesta actual al cliente seleccionado. Esto eliminará la cesta del usuario actual y la asociará al cliente seleccionado.
cart.pass-to.customer.warning=Advertencia: Esta acción no se puede deshacer y sobrescribirá la cesta del cliente seleccionado. Asegúrese de que el cliente seleccionado es correcto.
cart.pass-to.select-customer=Seleccione un cliente
cart.pass-to.button=Mover cesta
cart.pass-to.success=Cesta movida correctamente al cliente {0}.
cart.pass-to.customer.error=Debe seleccionar un cliente para mover la cesta.
cart.pass-to.customer.error-move=Error al mover la cesta de la compra
cart.errors.update-cart=Error al actualizar la cesta de la compra: {0}
cart.errors.shipping=No se puede calcular el coste del envío para alguna de las direcciones seleccionadas. Por favor, póngase en contacto con el servicio de atención al cliente.

View File

@ -53,5 +53,7 @@ direcciones.error.delete-internal-error=Error interno al eliminar la dirección.
direcciones.error.noEncontrado=Dirección no encontrada.
direcciones.error.sinPermiso=No tiene permiso para realizar esta acción.
direcciones.error.noShippingCost=No se pudo calcular el coste de envío para la dirección proporcionada.
direcciones.form.error.required=Campo obligatorio.

View File

@ -0,0 +1,17 @@
checkout.title=Finalizar compra
checkout.summay=Resumen de la compra
checkout.shipping=Envío
checkout.payment=Método de pago
checkout.shipping.info=Todos los pedidos incluyen un envío gratuito a la Península y Baleares por línea de pedido.
checkout.shipping.order=Envío del pedido
checkout.shipping.samples=Envío de pruebas
checkout.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección.
checkout.summary.presupuesto=#Presupuesto
checkout.summary.titulo=Título
checkout.summary.base=Base
checkout.summary.iva-4=IVA 4%
checkout.summary.iva-21=IVA 21%
checkout.summary.envio=Envío

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,38 @@
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
background-color: #4c5c6366 !important;
}
.step-arrow-nav .nav {
background-color: #4c5c6333 !important;
}
.nav-link.active .bg-soft-primary {
background-color: #ffffff33 !important;
/* #4c5c63 al 20% */
}
.nav-link.active .text-primary {
color: #ffffff !important;
/* #4c5c63 al 20% */
}
.nav-link:not(.active) .bg-soft-primary {
background-color: #4c5c6366 !important;
/* #4c5c63 al 20% */
}
.nav-link:not(.active) .text-primary {
color: #000000 !important;
/* #4c5c63 al 20% */
}
.direccion-card {
flex: 1 1 350px; /* ancho mínimo 350px, crece si hay espacio */
max-width: 350px; /* opcional, para que no se estiren demasiado */
min-width: 340px; /* protege el ancho mínimo */
}
.shipping-addresses-item { align-items: stretch; justify-content: center;}
.shipping-addresses-sample { align-items: stretch; justify-content: center;}
.direccion-card { display: flex; flex-direction: column; }
.direccion-card .card-body { display: flex; flex-direction: column; }

View File

@ -29,3 +29,13 @@ body {
color: #fff;
border-color: #92b2a7;
}
/* Solo dentro del modal */
.swal2-popup .form-switch-custom {
font-size: 1rem; /* clave: fija el tamaño base del switch */
line-height: 1.5;
}
.swal2-popup .form-switch-custom .form-check-input {
float: none; /* por si acaso */
margin: 0;
}

View File

@ -1,44 +1,111 @@
import { formateaMoneda } from '../../imprimelibros/utils.js';
import { showLoader, hideLoader } from '../loader.js';
$(() => {
updateTotal();
$(document).ajaxStart(showLoader).ajaxStop(hideLoader);
function updateTotal() {
const items = $(".product");
let iva4 = 0;
let iva21 = 0;
let base = 0;
for (let i = 0; i < items.length; i++) {
const item = $(items[i]);
const b = item.data("base");
const i4 = item.data("iva-4");
const i21 = item.data("iva-21");
base += parseFloat(b) || 0;
iva4 += parseFloat(i4) || 0;
iva21 += parseFloat(i21) || 0;
$(document).on('updateCart', () => {
// get form and submit
const form = $('#cartForm');
const container = $("#onlyOneShipment").is(':checked') ? $('#shippingAddressesContainer') : $('.product');
// remove name from container . direccion-card
container.find('.direccion-card input[type="hidden"]').removeAttr('name');
container.find('.direccion-card').each(function (i) {
$(this).find('.direccion-id').attr('name', 'direcciones[' + i + '].id');
$(this).find('.direccion-cp').attr('name', 'direcciones[' + i + '].cp');
$(this).find('.direccion-pais-code3').attr('name', 'direcciones[' + i + '].paisCode3');
if ($(this).find('.presupuesto-id').length > 0 && $(this).find('.presupuesto-id').val() !== null
&& $(this).find('.presupuesto-id').val() !== "")
$(this).find('.presupuesto-id').attr('name', 'direcciones[' + i + '].presupuestoId');
if ($(this).find('.item-tirada').length > 0 && $(this).find('.item-tirada').val() !== null
&& $(this).find('.item-tirada').val() !== "")
$(this).find('.item-tirada').attr('name', 'direcciones[' + i + '].unidades');
});
$.post(form.attr('action'), form.serialize(), (response) => {
// if success and received html, replace container summary
if (response) {
$('.cart-summary-container').replaceWith(response);
}
}).always(() => {
hideLoader();
});
checkAddressesForItems();
});
checkAddressesForItems();
function checkAddressesForItems() {
if ($('.product').length === 0) {
$("#alert-empty").removeClass("d-none");
$('.cart-content').addClass('d-none');
return;
}
$("#base-cesta").text(formateaMoneda(base));
if (iva4 > 0) {
$("#iva-4-cesta").text(formateaMoneda(iva4));
$("#tr-iva-4").show();
} else {
$("#tr-iva-4").hide();
else {
$('.cart-content').removeClass('d-none');
$("#alert-empty").addClass("d-none");
// check if select2 is initialized
if ($('#select-customer').length && !$('#select-customer').hasClass('select2-hidden-accessible')) {
initMoveCartToCustomer();
}
}
if (iva21 > 0) {
$("#iva-21-cesta").text(formateaMoneda(iva21));
$("#tr-iva-21").show();
} else {
$("#tr-iva-21").hide();
if ($('#onlyOneShipment').is(':checked')) {
if ($("#shippingAddressesContainer .direccion-card").length === 0) {
$(".alert-shipment").removeClass("d-none");
$('#btn-checkout').prop('disabled', true);
return;
}
$(".alert-shipment").addClass("d-none");
$('#btn-checkout').prop('disabled', false);
}
else {
const items = $(".product");
let errorFound = false;
for (let i = 0; i < items.length; i++) {
let errorFoundItem = false;
const item = $(items[i]);
const tirada = parseInt(item.find(".item-tirada").val()) || 0;
const direcciones = item.find(".direccion-card");
let totalUnidades = 0;
direcciones.each(function () {
const unidades = parseInt($(this).find(".item-tirada").val()) || 0;
totalUnidades += unidades;
});
if (totalUnidades < tirada) {
errorFoundItem = true;
}
if (item.find(".shipping-addresses-sample")) {
const container = item.find(".shipping-addresses-sample");
if (container.find('.direccion-card').toArray().length === 0) {
errorFoundItem = true;
}
}
if (errorFoundItem) {
errorFound = true;
item.find(".alert-icon-shipment").removeClass("d-none");
}
else {
item.find(".alert-icon-shipment").addClass("d-none");
}
}
if (errorFound) {
$(".alert-shipment").removeClass("d-none");
$('#btn-checkout').prop('disabled', true);
}
else {
$(".alert-shipment").addClass("d-none");
$('#btn-checkout').prop('disabled', false);
}
}
const total = base + iva4 + iva21;
$("#total-cesta").text(formateaMoneda(total));
}
$(document).on("click", ".delete-item", async function (event) {
event.preventDefault();
const cartItemId = $(this).data("cart-item-id");
const presupuestoId = $(this).data("cart-item-id");
const card = $(this).closest('.card.product');
// CSRF (Spring Security)
@ -46,7 +113,7 @@ $(() => {
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.content || 'X-CSRF-TOKEN';
try {
const res = await fetch(`/cart/delete/item/${cartItemId}`, {
const res = await fetch(`/cart/delete/item/${presupuestoId}`, {
method: 'DELETE',
headers: { [csrfHeader]: csrfToken }
});
@ -55,13 +122,81 @@ $(() => {
console.error('Error al eliminar. Status:', res.status);
return;
}
else{
else {
card?.remove();
updateTotal();
$(document).trigger('updateCart');
}
} catch (err) {
console.error('Error en la solicitud:', err);
}
});
function initMoveCartToCustomer() {
if ($('#select-customer').length) {
$('#moveCart').on('click', async function (e) {
e.preventDefault();
const customerId = $('#select-customer').val();
if (!customerId) {
// set text and show alert
$('#alert-select-customer').text(window.languageBundle['cart.pass-to.customer.error'] || 'Debe seleccionar un cliente para mover la cesta.');
$('#alert-select-customer').removeClass('d-none').hide().fadeIn();
setTimeout(() => {
$('#alert-select-customer').fadeOut(function () {
$(this).addClass('d-none');
});
}, 5000);
return;
}
// CSRF (Spring Security)
const csrfToken = document.querySelector('meta[name="_csrf"]')?.content || '';
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.content || 'X-CSRF-TOKEN';
try {
const res = await fetch(`/cart/pass-to-customer/${customerId}`, {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
});
if (!res.ok) {
$('#alert-select-customer').text(window.languageBundle['cart.pass-to.customer.move.error'] || 'Error al mover la cesta de la compra');
$('#alert-select-customer').removeClass('d-none').hide().fadeIn();
setTimeout(() => {
$('#alert-select-customer').fadeOut(function () {
$(this).addClass('d-none');
});
}, 5000);
return;
}
else {
window.location.href = '/cart';
}
} catch (err) {
console.error('Error en la solicitud:', err);
$('#alert-select-customer').text(window.languageBundle['cart.errors.move-cart'] || 'Error al mover la cesta de la compra');
$('#alert-select-customer').removeClass('d-none').hide().fadeIn();
setTimeout(() => {
$('#alert-select-customer').fadeOut(function () {
$(this).addClass('d-none');
});
}, 5000);
}
});
$('#select-customer').select2({
width: '100%',
ajax: {
url: 'users/api/get-users',
dataType: 'json',
delay: 250,
},
allowClear: true
});
}
}
initMoveCartToCustomer();
});

View File

@ -0,0 +1,443 @@
import { showLoader, hideLoader } from '../loader.js';
$(() => {
// Si usas jQuery AJAX:
$(document).ajaxStart(showLoader).ajaxStop(hideLoader);
$("#onlyOneShipment").on('change', function () {
if ($(this).is(':checked')) {
$('.nav-product').addClass('d-none');
document.querySelectorAll('.card.product').forEach(card => {
const detailsBtn = card.querySelector('.nav-link[id^="pills-details-"][id$="-tab"]');
if (detailsBtn) new bootstrap.Tab(detailsBtn).show();
});
$('#shippingAddressesContainer').empty().removeClass('d-none');
$('.shipping-addresses-item').toArray().forEach(element => {
$(element).empty().addClass('d-none');
});
$('#addOrderAddress').removeClass('d-none');
} else {
$('.nav-product').removeClass('d-none');
$('#shippingAddressesContainer').empty().addClass('d-none');
$('.shipping-addresses-item').toArray().forEach(element => {
$(element).empty().removeClass('d-none');
});
$('#addOrderAddress').addClass('d-none');
}
$(document).trigger('updateCart');
});
$(document).on('click', '.btn-delete-direccion', function (e) {
e.preventDefault();
const $card = $(this).closest('.direccion-card');
const $div = $card.parent();
$card.remove();
if ($div.hasClass('shipping-order-address')) {
$('#addOrderAddress').removeClass('d-none');
}
else {
$div.trigger('direcciones:actualizadas');
}
$(document).trigger('updateCart');
});
//btn-edit-direccion
$(document).on('click', '.btn-edit-direccion', function (e) {
e.preventDefault();
const $card = $(this).closest('.direccion-card');
const container = $(this).closest('.product').find('.shipping-addresses-item');
const tirada = $(this).closest('.product').find('.item-tirada').val();
const totalTirada = container.find('.item-tirada').toArray().reduce((acc, el) => acc + parseInt($(el).val() || 0), 0);
const remainingTirada = parseInt(tirada) - parseInt(totalTirada) + parseInt($card.find('.item-tirada').val() || 0);
const data = getUnitsFromUser(remainingTirada);
data.then(data => {
if (data.unidades) {
$card.find('.item-tirada').val(data.unidades);
$card.find('#units-text').each(function () {
if (data.unidades == 1) {
$(this).text(`${data.unidades} ${window.languageBundle['cart.shipping.ud'] || 'unidad'}`);
} else {
$(this).text(`${data.unidades} ${window.languageBundle['cart.shipping.uds'] || 'unidades'}`);
}
});
container.trigger('direcciones:actualizadas');
$(document).trigger('updateCart');
$card.find('.is-palets').val(data.isPalets ? 'true' : 'false');
$card.find('.icon-shipment').each(function () {
if (data.isPalets) {
$(this).removeClass('la-box').addClass('la-pallet');
} else {
$(this).removeClass('la-pallet').addClass('la-box');
}
});
}
});
});
const language = document.documentElement.lang || 'es-ES';
const modalEl = document.getElementById('direccionFormModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
$(document).on("change", ".direccionFacturacion", function () {
const isChecked = $(this).is(':checked');
if (isChecked) {
$('.direccionFacturacionItems').removeClass('d-none');
} else {
$('.direccionFacturacionItems').addClass('d-none');
$('#razonSocial').val('');
$('#tipoIdentificacionFiscal').val('DNI');
$('#identificacionFiscal').val('');
}
});
$('#addOrderAddress').on('click', async () => {
if ($('#onlyOneShipment').is(':checked')) {
if (await seleccionarDireccionEnvio()) {
$('#addOrderAddress').addClass('d-none');
}
}
});
$(document).on('click', '.btn-add-shipping', function () {
const presupuestoId = $(this).closest('.product').find('.item-presupuesto-id').val();
const container = $(this).closest('.product').find('.shipping-addresses-item');
const tirada = $(this).closest('.product').find('.item-tirada').val();
const totalTirada = container.find('.item-tirada').toArray().reduce((acc, el) => acc + parseInt($(el).val() || 0), 0);
const remainingTirada = parseInt(tirada) - parseInt(totalTirada);
seleccionarDireccionEnvio(presupuestoId, remainingTirada, container);
});
$(document).on('click', '.btn-add-shipping-sample', function () {
const presupuestoId = $(this).closest('.product').find('.item-presupuesto-id').val();
const container = $(this).closest('.product').find('.shipping-addresses-sample');
seleccionarDireccionEnvio(presupuestoId, null, container);
});
async function seleccionarDireccionEnvio(presupuestoId = null, tirada = null, container = null) {
const { value: direccionId, isDenied } = await Swal.fire({
title: window.languageBundle['cart.shipping.add.title'] || 'Seleccione una dirección',
html: `
<select id="direccionSelect" class="form-select" style="width: 100%"></select>
`,
showCancelButton: true,
showDenyButton: true,
buttonsStyling: false,
confirmButtonText: window.languageBundle['app.seleccionar'] || 'Seleccionar',
cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar',
denyButtonText: window.languageBundle['cart.shipping.new-address'] || 'Nueva dirección',
customClass: {
confirmButton: 'btn btn-secondary me-2',
cancelButton: 'btn btn-light',
denyButton: 'btn btn-secondary me-2'
},
focusConfirm: false,
// Inicializa cuando el DOM del modal ya existe
didOpen: () => {
const $select = $('#direccionSelect');
$select.empty(); // limpia placeholder estático
$select.select2({
width: '100%',
dropdownParent: $('.swal2-container'),
ajax: {
url: '/direcciones/select2',
dataType: 'json',
delay: 250,
data: params => ({ q: params.term || '' }),
processResults: (data) => {
const items = Array.isArray(data) ? data : (data.results || []);
return {
results: items.map(item => ({
id: item.id,
text: item.text, // ← Select2 necesita 'id' y 'text'
alias: item.alias || 'Sin alias',
att: item.att || '',
direccion: item.direccion || '',
cp: item.cp || '',
ciudad: item.ciudad || '',
html: `
<div>
<strong>${item.alias || 'Sin alias'}</strong><br>
${item.att ? `<small>${item.att}</small><br>` : ''}
<small>${item.direccion || ''}${item.cp ? ', ' + item.cp : ''}${item.ciudad ? ', ' + item.ciudad : ''}</small>
</div>
`
})),
pagination: { more: false } // opcional, evita que espere más páginas
};
}
},
placeholder: window.languageBundle['cart.shipping.select-placeholder'] || 'Buscar en direcciones...',
language: language,
templateResult: data => {
if (data.loading) return data.text;
return $(data.html || data.text);
},
// Selección más compacta (solo alias + ciudad)
templateSelection: data => {
if (!data.id) return data.text;
const alias = data.alias || data.text;
const ciudad = data.ciudad ? `${data.ciudad}` : '';
return $(`<span>${alias}${ciudad}</span>`);
},
escapeMarkup: m => m
});
},
preConfirm: () => {
const $select = $('#direccionSelect');
const val = $select.val();
if (!val) {
Swal.showValidationMessage(
window.languageBundle['cart.shipping.errors.noAddressSelected'] || 'Por favor, seleccione una dirección.'
);
return false;
}
return val;
},
didClose: () => {
// Limpieza: destruir select2 para evitar fugas
const $select = $('#direccionSelect');
if ($select.data('select2')) {
$select.select2('destroy');
}
}
});
if (isDenied) {
$.get('/direcciones/direction-form', function (html) {
$('#direccionFormModalBody').html(html);
const title = $('#direccionFormModalBody #direccionForm').data('add');
$('#direccionFormModal .modal-title').text(title);
modal.removeClass('d-none');
});
}
let unidades = null;
let isPalets = 0;
if (tirada !== null && tirada >= 1 && direccionId) {
const data = await getUnitsFromUser(tirada);
if (data && data.unidades) {
unidades = parseInt(data.unidades);
isPalets = data.isPalets ? 1 : 0;
} else {
// Si el usuario cancela, salir de la función
return false;
}
}
else if (presupuestoId == null && direccionId) { // caso para todas los envios a la misma direccion
const isPaletsValue = await getTipoEnvio();
if (isPaletsValue !== null) {
isPalets = isPaletsValue ? 1 : 0;
} else {
// Si el usuario cancela, salir de la función
return false;
}
}
if (direccionId) {
// Obtén el objeto completo seleccionado
showLoader();
let uri = `/cart/get-address/${direccionId}?isPalets=${isPalets}`;
if (presupuestoId !== null) {
uri += `&presupuestoId=${presupuestoId}`;
if (tirada !== null) {
uri += `&unidades=${unidades}`;
}
}
const response = await fetch(uri);
if (response.ok) {
const html = await response.text();
if (presupuestoId !== null) {
container.append(html).trigger('direcciones:actualizadas');
}
else {
$('#shippingAddressesContainer').append(html);
}
$(document).trigger('updateCart');
return true;
}
hideLoader();
return false;
}
hideLoader();
return false;
}
async function getUnitsFromUser(tirada) {
const { value: formValues } = await Swal.fire({
title: window.languageBundle['cart.shipping.enter-units'] || 'Introduzca el número de unidades para esta dirección',
html: `
<div class="mb-3">
<label class="form-label fw-semibold">
${window.languageBundle['cart.shipping.units-label']?.replace('{max}', tirada) || `Número de unidades (máximo ${tirada})`}
</label>
<input id="swal-input-unidades" type="number" min="1" max="${tirada}" step="1"
value="${tirada}" class="form-control text-center">
</div>
<div class="form-check form-switch form-switch-custom mb-3 d-flex align-items-center justify-content-center ps-0">
<input type="checkbox"
id="swal-input-palets"
class="form-check-input ms-0 me-2 float-none">
<label for="swal-input-palets" class="form-check-label mb-0">
${window.languageBundle['cart.shipping.send-in-palets'] || 'Enviar en palets'}
</label>
</div>
<span class="form-text text-muted">
${window.languageBundle['cart.shipping.send-in-palets.info'] || 'En palets la entrega se realizará a pie de calle.'}
</span>
`,
focusConfirm: false,
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2',
cancelButton: 'btn btn-light',
},
confirmButtonText: window.languageBundle['app.aceptar'] || 'Aceptar',
cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar',
preConfirm: () => {
const unidades = parseInt(document.getElementById('swal-input-unidades').value, 10);
const isPalets = document.getElementById('swal-input-palets').checked;
if (!unidades || isNaN(unidades) || unidades < 1 || unidades > tirada) {
Swal.showValidationMessage(
window.languageBundle['cart.shipping.errors.units-error']?.replace('{max}', tirada)
|| `Por favor, introduzca un número válido entre 1 y ${tirada}.`
);
return false;
}
return { unidades, isPalets };
}
});
if (formValues) {
return formValues; // { unidades: number, isPalets: boolean }
}
return null; // Si se cancela el Swal
}
async function getTipoEnvio() {
const { isConfirmed, value } = await Swal.fire({
title: window.languageBundle['cart.shipping.tipo-envio'] || 'Tipo de envío',
html: `
<div class="form-check form-switch form-switch-custom my-3 d-flex align-items-center justify-content-center gap-2">
<input type="checkbox" class="form-check-input" id="swal-input-palets">
<label for="swal-input-palets" class="form-label mb-0">
${window.languageBundle['cart.shipping.send-in-palets'] || 'Enviar en palets'}
</label>
</div>
<span class="form-text text-muted">
${window.languageBundle['cart.shipping.send-in-palets.info'] || 'En palets la entrega se realizará a pie de calle.'}
</span>
`,
focusConfirm: false,
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2',
cancelButton: 'btn btn-light',
},
confirmButtonText: window.languageBundle['app.aceptar'] || 'Aceptar',
cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar',
preConfirm: () => {
const popup = Swal.getPopup();
const chk = popup.querySelector('#swal-input-palets');
// Devuelve un OBJETO (siempre truthy) con el booleano dentro
return { isPalets: !!chk?.checked };
}
});
if (!isConfirmed) return null; // cancelado
return value.isPalets; // true / false
}
function checkTotalUnits(container, tirada) {
const totalUnits = container.find('.direccion-card').toArray().reduce((acc, el) => {
const unidades = parseInt($(el).find('.item-tirada').val()) || 0;
return acc + unidades;
}, 0);
if (totalUnits < tirada) {
return false;
}
if (container.find('.product').closest('.shipping-addresses-sample')) {
if (container.find('.direccion-card').toArray().length === 0) {
return false;
}
}
return true;
}
$(document).on('direcciones:actualizadas', '.shipping-addresses-item', function (e) {
const tirada = $(this).closest('.product').find('.item-tirada').val();
const container = $(this);
if (!checkTotalUnits(container, tirada)) {
container.closest('.px-2').find('.btn-add-shipping').removeClass('d-none');
} else {
container.closest('.px-2').find('.btn-add-shipping').addClass('d-none');
}
});
$(document).on('direcciones:actualizadas', '.shipping-addresses-sample', function (e) {
const container = $(this);
if (container.find('.direccion-card').toArray().length === 0) {
container.closest('.px-2').find('.btn-add-shipping-sample').removeClass('d-none');
}
else {
container.closest('.px-2').find('.btn-add-shipping-sample').addClass('d-none');
}
});
$(document).on('submit', '#direccionForm', function (e) {
e.preventDefault();
const $form = $(this);
$.ajax({
url: $form.attr('action'),
type: 'POST', // PUT simulado via _method
data: $form.serialize(),
dataType: 'html',
success: function (html) {
// Si por cualquier motivo llega 200 con fragmento, lo insertamos igual
if (typeof html === 'string' && html.indexOf('id="direccionForm"') !== -1 && html.indexOf('<html') === -1) {
$('#direccionFormModalBody').html(html);
const isEdit = $('#direccionFormModalBody #direccionForm input[name="_method"][value="PUT"]').length > 0;
const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add');
$('#direccionModal .modal-title').text(title);
return;
}
// Éxito real: cerrar y recargar tabla
modal.addClass('d-none');
seleccionarDireccionEnvio();
},
error: function (xhr) {
// Con 422 devolvemos el fragmento con errores aquí
if (xhr.status === 422 && xhr.responseText) {
$('#direccionFormModalBody').html(xhr.responseText);
const isEdit = $('#direccionFormModalBody #direccionForm input[name="_method"][value="PUT"]').length > 0;
const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add');
$('#direccionModal .modal-title').text(title);
initSelect2Cliente(true);
return;
}
// Fallback
$('#direccionFormModalBody').html('<div class="p-3 text-danger">Error inesperado.</div>');
}
});
});
});

View File

@ -0,0 +1,21 @@
$(() => {
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
if (window.$ && csrfToken && csrfHeader) {
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
}
const language = document.documentElement.lang || 'es-ES';
const modalEl = document.getElementById('direccionFormModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
});

View File

@ -0,0 +1,11 @@
export function showLoader() {
const el = document.getElementById('sectionLoader');
el.classList.remove('d-none');
el.classList.add('d-flex');
}
export function hideLoader() {
const el = document.getElementById('sectionLoader');
el.classList.remove('d-flex');
el.classList.add('d-none');
}

View File

@ -0,0 +1,61 @@
<div th:fragment="cartContent(items, cartId)" th:class="${'cart-content container-fluid row gy-4' + (items.isEmpty() ? ' d-none' : '')}">
<div id="sectionLoader" class="position-absolute top-0 start-0 w-100 h-100 d-none justify-content-center align-items-center
bg-body bg-opacity-75" style="z-index:10;">
<div class="spinner-border" role="status" style="width:2.5rem;height:2.5rem;">
<span class="visually-hidden">Cargando…</span>
</div>
</div>
<div id="errorEnvio" th:class="${'alert alert-danger' + (errorEnvio ? '' : ' d-none')}" role="alert"
th:text="#{cart.errors.shipping}"></div>
<div th:if="${!#strings.isEmpty(errorMessage) and items != null and !items.isEmpty()}" class="alert alert-danger "
role="alert" th:text="${errorMessage}"></div>
<div class="alert alert-danger alert-shipment d-none" role="alert"
th:text="#{cart.shipping.errors.fillAddressesItems}"></div>
<form id="cartForm" th:action="${'/cart/update/' + cart.id}" method="POST" class="col-xl-8 col-12">
<input type="hidden" name="id" th:value="${cart.id}" />
<div class="card">
<div class="card-body">
<p th:text="#{cart.shipping.info}"></p>
<div
class="form-check form-switch form-switch-custom form-switch-presupuesto mb-3 d-flex align-items-center">
<input type="checkbox" class="form-check-input datos-generales-data me-2" id="onlyOneShipment"
th:field="${cart.onlyOneShipment}" />
<label for="onlyOneShipment" class="form-label d-flex align-items-center mb-0">
<span th:text="#{cart.shipping.onlyOneShipment}" class="me-2"></span>
</label>
</div>
<button type="button"
th:class="${'btn btn-secondary' + (!cart.onlyOneShipment or #lists.size(mainDir ?: {}) > 0 ? ' d-none' : '')}"
id="addOrderAddress" th:text="#{cart.shipping.add}">Añadir dirección</button>
<div id="shippingAddressesContainer" class="shipping-order-address d-flex flex-wrap gap-3 mt-4">
<div th:replace="${cart.onlyOneShipment and mainDir != null}
? ~{imprimelibros/direcciones/direccionCard :: direccionCard(
${mainDir.direccion},
${mainDir.pais},
${mainDir.presupuestoId},
${mainDir.unidades},
${mainDir.isPalets}
)}
: ~{}">
</div>
</div>
</div>
</div>
<div th:each="item : ${items}" th:insert="~{imprimelibros/cart/_cartItem :: cartItem(${item})}">
</div>
</form>
<div th:replace="~{imprimelibros/cart/_cartSummary :: cartSummary(${cartSummary})}"></div>
</div>

View File

@ -1,71 +1,192 @@
<!-- _cartItem.html -->
<div th:fragment="cartItem(item)" class="card product mb-3 shadow-sm" th:attr="data-iva-4=${item.iva4},
<div th:fragment="cartItem(item)" class="card product mb-3 shadow-sm gy-3" th:attr="data-iva-4=${item.iva4},
data-iva-21=${item.iva21},
data-base=${item.base}">
<div class="card-body">
<div class="row gy-3">
<div class="col-sm-auto">
<div class="avatar-lg bg-light rounded p-1">
<img th:src="${item.imagen != null ? item.imagen : '/assets/images/products/placeholder.png'}"
alt="portada" class="img-fluid d-block rounded">
<input type="hidden" class="item-presupuesto-id" th:value="${item.presupuestoId}" />
<input type="hidden" class="item-tirada" th:value="${item.tirada}" />
<div class="step-arrow-nav mt-n3 mx-n3 mb-3">
<ul th:class="${'nav nav-pills nav-justified custom-nav nav-product' + (cart.onlyOneShipment ? ' d-none' : '')}"
role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link fs-15 active" th:id="${'pills-details-' + item.cartItemId + '-tab'}"
th:data-bs-target="${'#pills-details-' + item.cartItemId}" type="button" role="tab"
th:aria-controls="${'#pills-details-' + item.cartItemId}" aria-selected="true"
data-bs-toggle="tab">
<i
class="ri-truck-line fs-5 p-1 bg-soft-primary text-primary rounded-circle align-middle me-2"></i>
<label class="fs-13 my-2" th:text="#{cart.tabs.details}">Detalles</label>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fs-15" th:id="${'pills-shipping-' + item.cartItemId + '-tab'}"
th:data-bs-target="${'#pills-shipping-' + item.cartItemId}" type="button" role="tab"
th:aria-controls="${'#pills-shipping-' + item.cartItemId}" aria-selected="false"
data-bs-toggle="tab">
<i
class="ri-truck-line fs-5 p-1 bg-soft-primary text-primary rounded-circle align-middle me-2"></i>
<label class="fs-13 my-2" th:text="#{cart.tabs.envio}">Envío</label>
<i
class="ri-error-warning-line fs-5 p-1 bg-soft-danger rounded-circle text-danger align-middle me-2 d-none alert-icon-shipment"></i>
</button>
</li>
</ul>
</div>
<div class="tab-content">
<div class="tab-pane fade show active tab-pane-details" th:id="${'pills-details-' + item.cartItemId}"
role="tabpanel" th:aria-labelledby="${'pills-details-' + item.cartItemId + '-tab'}">
<div class="row g-3 align-items-start">
<!-- Col 1: imagen -->
<div class="col-auto">
<div class="avatar-lg bg-light rounded p-1">
<img th:src="${item.imagen != null ? item.imagen : '/assets/images/products/placeholder.png'}"
alt="portada" class="img-fluid d-block rounded">
</div>
</div>
<!-- Col 2: detalles -->
<div class="col">
<h5 class="fs-18 text-truncate mb-1">
<a th:href="@{|presupuesto/edit/${item.presupuestoId}|}" class="text-dark"
th:text="${item.titulo != null ? item.titulo : 'Presupuesto #'}">Presupuesto</a>
</h5>
<h5 class="fs-14 text-truncate mb-1">
<span th:text="#{cart.item.presupuesto-numero}">Presupuesto #</span>
<span th:text="${item.presupuestoId != null ? item.presupuestoId : ''}">#</span>
</h5>
<ul class="list-unstyled text-muted mb-1 ps-0">
<li th:each="linea : ${item.resumen.lineas}" class="mb-1">
<span th:utext="${linea['descripcion']}"></span>
</li>
</ul>
<ul class="list-unstyled text-muted mb-1" th:if="${item.resumen.servicios != null}">
<li>
<span th:utext="#{pdf.servicios-adicionales}">Servicios adicionales:</span>
<span class="spec-label" th:text="${item.resumen.servicios}"></span>
</li>
</ul>
<ul class="list-unstyled text-muted mb-1"
th:if="${item.resumen != null and #maps.containsKey(item.resumen,'datosMaquetacion') and item.resumen['datosMaquetacion'] != null}">
<li class="spec-row mb-1">
<span th:text="#{pdf.datos-maquetacion}">Datos de maquetación:</span>
<span th:utext="${item.resumen.datosMaquetacion}"></span>
</li>
</ul>
<ul class="list-unstyled text-muted mb-1"
th:if="${item.resumen != null and #maps.containsKey(item.resumen,'datosMarcapaginas') and item.resumen['datosMarcapaginas'] != null}">
<li class="spec-row mb-1">
<span th:text="#{pdf.datos-marcapaginas}">Datos de marcapáginas:</span>
<span th:utext="${item.resumen.datosMarcapaginas}"></span>
</li>
</ul>
</div>
<!-- Col 3: precio -->
<div class="col-auto ms-auto text-end">
<p class="text-muted mb-1" th:text="#{cart.precio}">Precio</p>
<h5 class="fs-14 mb-0">
<span th:text="${item.baseTotal != null ? item.baseTotal : '-'}">0,00</span>
</h5>
</div>
</div>
</div>
</div>
<!-- Detalles -->
<div class="col-sm">
<!-- Título / enlace -->
<h5 class="fs-18 text-truncate mb-1">
<a th:href="@{|presupuesto/edit/${item.presupuestoId}|}" class="text-dark"
th:text="${item.titulo != null ? item.titulo : 'Presupuesto #'}">
Presupuesto
</a>
</h5>
<h5 class="fs-14 text-truncate mb-1">
<span th:text="#{cart.item.presupuesto-numero}">Presupuesto #</span>
<span th:text="${item.presupuestoId != null ? item.presupuestoId : ''}">#</span>
</h5>
<div class="tab-content">
<div class="tab-pane fade show tab-pane-shipping" th:id="${'pills-shipping-' + item.cartItemId}"
role="tabpanel" th:aria-labelledby="${'pills-shipping-' + item.cartItemId + '-tab'}">
<!-- Detalles opcionales (ej: cliente, fecha, etc.) -->
<ul class="list-inline text-muted mb-1">
<div th:each="linea : ${item.resumen.lineas}">
<li class="list-inline-item me-3">
<div th:utext="${linea['descripcion']}"></div>
</li>
<div class="col-sm">
<!-- Título / enlace -->
<h5 class="fs-18 text-truncate my-1 p-1">
<a th:href="@{|presupuesto/edit/${item.presupuestoId}|}" class="text-dark"
th:text="${item.titulo != null ? item.titulo : 'Presupuesto #'}">
Presupuesto
</a>
</h5>
<h5 class="fs-14 text-truncate mb-1">
<span th:text="#{cart.item.presupuesto-numero}">Presupuesto #</span>
<span th:text="${item.presupuestoId != null ? item.presupuestoId : ''}">#</span>
</h5>
<h5 class="fs-14 text-truncate mb-1">
<span
th:text="#{cart.shipping.tirada} + ' ' + (${item.tirada} != null ? ${item.tirada} : '') + ' ' + #{cart.shipping.unidades} ">
</span>
</h5>
<div class="card ribbon-box border shadow-none mb-lg-0 material-shadow">
<div class="card-body">
<div class="ribbon ribbon-primary ribbon-shape" th:text="#{checkout.shipping.order}">Envio
del pedido
</div>
</div>
<div class="ribbon-content mt-4">
<div class="px-2 mb-2">
<button type="button" class="btn btn-secondary btn-add-shipping"
th:text="#{cart.shipping.add}">Añadir dirección</button>
<div class="shipping-addresses-item d-flex flex-wrap gap-3 mt-4">
<th:block th:each="dir : ${direcciones}">
<th:block
th:if="${dir != null and dir.unidades != null and dir.unidades > 0 and dir.presupuestoId == item.presupuestoId}">
<div th:replace="~{imprimelibros/direcciones/direccionCard ::
direccionCard(
direccion=${dir.direccion},
pais=${dir.pais},
presupuestoId=${dir.presupuestoId},
unidades=${dir.unidades},
isPalets=${dir.isPalets} )}">
</div>
</th:block>
</th:block>
</div>
</div>
</div>
</div>
</ul>
<ul class="list-inline text-muted mb-1" th:if="${item.resumen.servicios != null}">
<span th:utext="#{pdf.servicios-adicionales}">Servicios adicionales</span>
<span class="spec-label" th:text="${item.resumen.servicios}"></span>
</ul>
<div class="card ribbon-box border shadow-none mb-lg-0 material-shadow mt-4"
th:if="${item.hasSample}">
<div class="card-body">
<div class="ribbon ribbon-primary ribbon-shape" th:text="#{cart.shipping.samples}">Envio
de pruebas
</div>
</div>
<ul class="list-inline text-muted mb-1" th:if="${item.resumen != null
and #maps.containsKey(item.resumen, 'datosMaquetacion')
and item.resumen['datosMaquetacion'] != null}">
<li class="list-inline-item spec-row mb-1">
<span th:text="#{pdf.datos-maquetacion}">Datos de maquetación:</span>
<span th:utext="${item.resumen.datosMaquetacion}"></span>
</li>
</ul>
<div class="ribbon-content mt-4">
<div class="px-2 mb-2">
<button type="button" class="btn btn-secondary btn-add-shipping-sample"
th:text="#{cart.shipping.add}">Añadir dirección</button>
<ul class="list-inline text-muted mb-1" th:if="${item.resumen != null
and #maps.containsKey(item.resumen, 'datosMarcapaginas')
and item.resumen['datosMarcapaginas'] != null}">
<li class="list-inline-item spec-row mb-1">
<span th:text="#{pdf.datos-marcapaginas}">Datos de marcapáginas:</span>
<span th:utext="${item.resumen.datosMarcapaginas}"></span>
</li>
</ul>
<div class="shipping-addresses-sample d-flex flex-wrap gap-3 mt-4">
<th:block th:each="dir : ${direcciones}">
<th:block
th:if="${dir != null and dir.unidades == null and dir.presupuestoId == item.presupuestoId}">
<div th:replace="~{imprimelibros/direcciones/direccionCard ::
direccionCard(
direccion=${dir.direccion},
pais=${dir.pais},
presupuestoId=${dir.presupuestoId},
unidades=${dir.unidades},
isPalets=${dir.isPalets} )}">
</div>
<!-- Precio o totales (si los tienes) -->
<div class="col-sm-auto text-end">
<p class="text-muted mb-1" th:text="#{cart.precio}">Precio</p>
<h5 class="fs-14 mb-0">
<span th:text="${item.baseTotal != null ? item.baseTotal : '-'}">0,00</span>
</h5>
</div>
</th:block>
</th:block>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-auto div-shipping-product">
</div>
</div>
</div>
</div>
@ -77,7 +198,7 @@
<!-- Botón eliminar -->
<div>
<a href="javascript:void(0);" class="d-block text-body p-1 px-2 delete-item"
th:attr="data-cart-item-id=${item.cartItemId}">
th:attr="data-cart-item-id=${item.presupuestoId}">
<i class="ri-delete-bin-fill text-muted align-bottom me-1"></i> Eliminar
</a>
</div>

View File

@ -0,0 +1,76 @@
<div th:fragment="cartSummary(summary)" class="col-xl-4 cart-summary-container">
<div class="sticky-side-div">
<div class="card">
<div class="card-header border-bottom-dashed">
<h5 th:text="#{cart.resumen.title}" class="card-title mb-0"></h5>
</div>
<div class="card-body pt-2">
<div class="table-responsive">
<table class="table table-borderless mb-0">
<tbody>
<tr>
<td><span th:text="#{cart.resumen.base}"></span></td>
<td class="text-end" id="base-cesta" th:text="${summary.base}"></td>
</tr>
<tr>
<td><span th:text="#{cart.resumen.envio}"></span></td>
<td class="text-end" id="envio-cesta" th:text="${summary.shipment}"></td>
</tr>
<tr id="tr-iva-4">
<td><span th:text="#{cart.resumen.iva-4}"></span> : </td>
<td class="text-end" id="iva-4-cesta" th:text="${summary.iva4}"></td>
</tr>
<tr id="tr-iva-21">
<td><span th:text="#{cart.resumen.iva-21}"></span> : </td>
<td class="text-end" id="iva-21-cesta" th:text="${summary.iva21}"></td>
</tr>
<tr id="tr-iva-21">
<td><span
th:text="#{cart.resumen.descuento} + ' (' + ${summary.fidelizacion} + ')'"></span>
: </td>
<td class="text-end" id="descuento-cesta" th:text="${summary.descuento}"></td>
</tr>
<tr class="table-active">
<th><span th:text="#{cart.resumen.total}"></span>:</th>
<td class="text-end">
<span id="total-cesta" class="fw-semibold" th:text="${summary.total}"></span>
</td>
</tr>
</tbody>
</table>
<form th:action="@{/pagos/redsys/crear}" method="post">
<input type="hidden" name="order" value="123456789012" />
<input type="hidden" name="amountCents" value="12525" />
<button id="btn-checkout" type="submit" class="btn btn-secondary w-100 mt-2"
th:text="#{cart.resumen.tramitar}">Checkout</button>
</form>
</div>
<!-- end table-responsive -->
</div>
</div>
<div sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')" class="card">
<div class="card-header border-bottom-dashed">
<h5 th:text="#{cart.pass-to.customer}" class="card-title mb-0"></h5>
</div>
<div class="card-body pt-2">
<div class="alert alert-info" role="alert" th:text="#{cart.pass-to.customer.info}"></div>
<div class="alert alert-warning" role="alert" th:text="#{cart.pass-to.customer.warning}"></div>
<div id="alert-select-customer" class="alert alert-danger d-none" role="alert"
th:text="#{cart.pass-to.customer.error}"></div>
<div class="mb-3">
<label for="select-customer" class="form-label" th:text="#{cart.pass-to.select-customer}"></label>
<select id="select-customer" name="customerId" class="form-select" required>
</select>
</div>
<div class="mb-3">
<button id="moveCart" class="btn btn-secondary w-100" th:text="#{cart.pass-to.button}">Mover
cesta</button>
</div>
</div>
</div>
</div>
<!-- end stickey -->
</div>

View File

@ -9,7 +9,7 @@
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
</th:block>
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet" />
<link th:href="@{/assets/css/cart.css}" rel="stylesheet" />
</th:block>
</head>
@ -22,6 +22,10 @@
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<div
th:replace="imprimelibros/partials/modal-form :: modal('direccionFormModal', 'direcciones.add', 'modal-md', 'direccionFormModalBody')">
</div>
<div class="container-fluid">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
@ -32,67 +36,12 @@
</nav>
</div>
<div class="container-fluid row gy-4">
<div th:if="${items.isEmpty()}">
<div class="alert alert-info" role="alert" th:text="#{cart.empty}"></div>
</div>
<div class="col-xl-8 col-12">
<div th:each="item : ${items}" th:insert="~{imprimelibros/cart/_cartItem :: cartItem(${item})}">
</div>
</div>
<div class="col-xl-4">
<div class="sticky-side-div">
<div class="card">
<div class="card-header border-bottom-dashed">
<h5 th:text="#{cart.resumen.title}" class="card-title mb-0"></h5>
</div>
<div class="card-body pt-2">
<div class="table-responsive">
<table class="table table-borderless mb-0">
<tbody>
<tr>
<td><span th:text="#{cart.resumen.base}"></span></td>
<td class="text-end" id="base-cesta"></td>
</tr>
<tr id="tr-iva-4">
<td><span th:text="#{cart.resumen.iva-4}"></span> : </td>
<td class="text-end" id="iva-4-cesta"></td>
</tr>
<tr id="tr-iva-21">
<td><span th:text="#{cart.resumen.iva-21}"></span> : </td>
<td class="text-end" id="iva-21-cesta"></td>
</tr>
<tr class="table-active">
<th><span th:text="#{cart.resumen.total}"></span>:</th>
<td class="text-end">
<span id="total-cesta" class="fw-semibold">
</span>
</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-secondary w-100 mt-2"
th:text="#{cart.resumen.tramitar}">Checkout</button>
</div>
<!-- end table-responsive -->
</div>
</div>
<div class="alert border-dashed alert-danger" role="alert">
<div class="d-flex align-items-center">
<div class="ms-2">
<h5 class="fs-14 text-danger fw-semibold" th:text="#{cart.resumen.fidelizacion}"></h5>
</div>
</div>
</div>
</div>
<!-- end stickey -->
</div>
<div th:if="${items.isEmpty()}">
<div id="alert-empty"class="alert alert-info" role="alert" th:text="#{cart.empty}"></div>
</div>
<div th:insert="~{imprimelibros/cart/_cartContent :: cartContent(${items}, ${cartId})}"></div>
</th:block>
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
@ -102,6 +51,7 @@
</script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/cart/cart.js}"></script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/cart/shipping-cart.js}"></script>
</th:block>
</body>

View File

@ -0,0 +1,43 @@
<div>
<div
th:replace="imprimelibros/partials/modal-form :: modal('direccionFormModal', 'direcciones.add', 'modal-md', 'direccionFormModalBody')">
</div>
<div class="card ribbon-box border shadow-none mb-lg-0 material-shadow">
<div class="card-body">
<div class="ribbon ribbon-primary ribbon-shape" th:text="#{checkout.shipping.order}">Envio del pedido
</div>
</div>
<div class="ribbon-content mt-4">
<div class="px-2 mb-2">
<p th:text="#{checkout.shipping.info}"></p>
<div
class="form-check form-switch form-switch-custom form-switch-presupuesto mb-3 d-flex align-items-center">
<input type="checkbox" class="form-check-input datos-generales-data me-2" id="onlyOneShipment"
name="onlyOneShipment" checked />
<label for="onlyOneShipment" class="form-label d-flex align-items-center mb-0">
<span th:text="#{checkout.shipping.onlyOneShipment}" class="me-2"></span>
</label>
</div>
<button type="button" class="btn btn-secondary" id="addOrderAddress"
th:text="#{checkout.shipping.add}">Añadir dirección</button>
<div id="orderShippingAddressesContainer" class="mt-4"></div>
<div id="orderShippingMultipleAddressesContainer d-none" class="mt-4"></div>
</div>
</div>
</div>
<div class="card ribbon-box border shadow-none mb-lg-0 material-shadow mt-4" th:if="${hasSample}">
<div class="card-body">
<div class="ribbon ribbon-primary ribbon-shape" th:text="#{checkout.shipping.samples}">Envio de pruebas
</div>
</div>
<div class="ribbon-content mt-4">
</div>
</div>
<!-- End Ribbon Shape -->
</div>

View File

@ -0,0 +1,3 @@
<div>
</div>

View File

@ -0,0 +1,175 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
</th:block>
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<div class="container-fluid">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{checkout.title}">Finalizar
compra</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-xl-8 col-12">
<div class="card">
<div class="card-body">
<div class="step-arrow-nav mt-n3 mx-n3 mb-3">
<ul class="nav nav-pills nav-justified custom-nav" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link fs-15 p-3 active" id="pills-shipping-tab"
data-bs-target="#pills-shipping" type="button" role="tab"
aria-controls="pills-shipping" aria-selected="true">
<i
class="ri-truck-line fs-5 p-1 bg-soft-primary text-primary rounded-circle align-middle me-2"></i>
<label class="fs-13 my-2" th:text="#{checkout.shipping}">Envío</label>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fs-15 p-3" id="pills-payment-tab"
data-bs-target="#pills-payment" type="button" role="tab"
aria-controls="pills-payment" aria-selected="false">
<i
class="ri-money-euro-box-line fs-5 p-1 bg-soft-primary text-primary rounded-circle align-middle me-2"></i>
<label class="fs-13 my-2" th:text="#{checkout.payment}">Método de
pago</label>
</button>
</li>
</ul>
</div>
<div class="tab-content">
<div class="tab-pane fade show active" id="pills-shipping" role="tabpanel"
aria-labelledby="pills-shipping-tab">
<div th:include="~{imprimelibros/checkout/_envio.html}">
</div>
</div>
<!-- end tab pane -->
<div class="tab-pane fade" id="pills-payment" role="tabpanel"
aria-labelledby="pills-payment-tab">
<div th:include="~{imprimelibros/checkout/_pago.html}">
</div>
</div>
<!-- end tab pane -->
</div>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="sticky-side-div">
<div class="card">
<div class="card-header border-bottom-dashed">
<h5 th:text="#{checkout.summay}" class="card-title mb-1"></h5>
</div>
<div class="card-body pt-2">
<div class="table-responsive table-card">
<table class="table table-borderless align-middle mb-0">
<thead class="table-light text-muted">
<tr>
<th style="width: 90px;" scope="col"
th:text="#{checkout.summary.presupuesto}">Presupuesto</th>
<th scope="col" th:text="#{checkout.summary.titulo}">Título</th>
<th scope="col" class="text-end" th:text="#{checkout.summary.base}">
Base</th>
<th class="d-none"></th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td>
<span th:text="${item.presupuestoId}">PRESUPUESTO-001</span>
</td>
<td>
<span th:text="${item.titulo}">Título del presupuesto</span>
</td>
<td class="text-end">
<span th:text="${item.baseTotal}">
0,00</span>
</td>
<td class="d-none">
<span th:text="${item.tirada}"></span>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2"><span th:text="#{cart.resumen.base}"></span></td>
<td class="text-end" id="base-cesta"></td>
</tr>
<tr id="tr-iva-4">
<td colspan="2"><span th:text="#{cart.resumen.iva-4}"></span> : </td>
<td class="text-end" id="iva-4-cesta"></td>
</tr>
<tr id="tr-iva-21">
<td colspan="2"><span th:text="#{cart.resumen.iva-21}"></span> : </td>
<td class="text-end" id="iva-21-cesta"></td>
</tr>
<tr class="table-active">
<td colspan="2"><span th:text="#{cart.resumen.total}"></span>:</td>
<td class="text-end">
<span id="total-cesta" class="fw-semibold">
</span>
</td>
</tr>
</tfoot>
</table>
<button type="button" class="btn btn-secondary w-100 mt-2"
th:text="#{cart.resumen.tramitar}">Checkout</button>
</div>
<!-- end table-responsive -->
</div>
</div>
<div class="alert border-dashed alert-danger" role="alert">
<div class="d-flex align-items-center">
<div class="ms-2">
<h5 class="fs-14 text-danger fw-semibold"
th:text="#{cart.resumen.fidelizacion}"></h5>
</div>
</div>
</div>
</div>
<!-- end stickey -->
</div>
</div>
</th:block>
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/checkout/checkout.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,169 @@
<div th:fragment="direccionForm">
<form id="direccionForm" novalidate th:action="${action}" th:object="${dirForm}" method="post"
th:data-add="#{direcciones.add}" th:data-edit="#{direcciones.editar}">
<div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}" th:each="err : ${#fields.globalErrors()}">
<span th:text="${err}">Error</span>
</div>
<input type="hidden" th:field="*{user.id}" />
<div class="form-group mt-2">
<label for="alias">
<span th:text="#{direcciones.alias}">Alias</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="alias" th:field="*{alias}" maxlength="100" required
th:classappend="${#fields.hasErrors('alias')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('alias')}" th:errors="*{alias}"></div>
<label th:text="#{direcciones.alias-descripcion}" class="form-text text-muted"></label>
</div>
<div class="form-group mt-2">
<label for="att">
<span th:text="#{direcciones.nombre}">Nombre y Apellidos</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="att" th:field="*{att}" maxlength="150" required
th:classappend="${#fields.hasErrors('att')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('att')}" th:errors="*{att}"></div>
</div>
<div class="form-group mt-2">
<label for="direccion">
<span th:text="#{direcciones.direccion}">Dirección</span>
<span class="text-danger">*</span>
</label>
<textarea class="form-control direccion-item" id="direccion" th:field="*{direccion}" maxlength="255"
required style="max-height: 125px;"
th:classappend="${#fields.hasErrors('direccion')} ? ' is-invalid'"></textarea>
<div class="invalid-feedback" th:if="${#fields.hasErrors('direccion')}" th:errors="*{direccion}"></div>
</div>
<div class="row mt-2">
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="cp">
<span th:text="#{direcciones.cp}">Código Postal</span>
<span class="text-danger">*</span>
</label>
<input type="number" class="form-control direccion-item" id="cp" th:field="*{cp}" min="1" max="99999"
required th:classappend="${#fields.hasErrors('cp')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('cp')}" th:errors="*{cp}"></div>
</div>
<div class="form-group col-lg-6 col-md-6 col-sm-12 mr-0">
<label for="ciudad">
<span th:text="#{direcciones.ciudad}">Ciudad</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="ciudad" th:field="*{ciudad}" maxlength="100" required
th:classappend="${#fields.hasErrors('ciudad')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('ciudad')}" th:errors="*{ciudad}"></div>
</div>
</div>
<div class="row mt-2">
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="provincia">
<span th:text="#{direcciones.provincia}">Provincia</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="provincia" th:field="*{provincia}" maxlength="100"
required th:classappend="${#fields.hasErrors('provincia')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('provincia')}" th:errors="*{provincia}"></div>
</div>
<div class="form-group col-lg-6 col-md-6 col-sm-12 mr-0">
<label for="pais">
<span th:text="#{direcciones.pais}">País</span>
<span class="text-danger">*</span>
</label>
<select class="form-control select2 direccion-item" id="paisCode3" th:field="*{paisCode3}"
th:classappend="${#fields.hasErrors('paisCode3')} ? ' is-invalid'">
<option th:each="pais : ${paises}" th:value="${pais.id}" th:text="${pais.text}"
th:selected="${pais.id} == ${dirForm.paisCode3}">
</option>
</select>
<div class="invalid-feedback" th:if="${#fields.hasErrors('paisCode3')}" th:errors="*{paisCode3}"></div>
</div>
</div>
<div class="form-group mt-2">
<label for="telefono">
<span th:text="#{direcciones.telefono}">Teléfono</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="telefono" th:field="*{telefono}" maxlength="50"
th:classappend="${#fields.hasErrors('telefono')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('telefono')}" th:errors="*{telefono}"></div>
</div>
<div class="form-group mt-2">
<label for="instrucciones">
<span th:text="#{direcciones.instrucciones}">Instrucciones</span>
</label>
<textarea class="form-control direccion-item" id="instrucciones" th:field="*{instrucciones}" maxlength="255"
style="max-height: 125px;"
th:classappend="${#fields.hasErrors('instrucciones')} ? ' is-invalid'"></textarea>
<div class="invalid-feedback" th:if="${#fields.hasErrors('instrucciones')}" th:errors="*{instrucciones}">
</div>
</div>
<div class="form-check form-switch form-switch-custom my-2">
<input type="checkbox"
class="form-check-input form-switch-custom-primary direccion-item direccionFacturacion"
id="direccionFacturacion" th:field="*{direccionFacturacion}">
<label for="direccionFacturacion" class="form-check-label" th:text="#{direcciones.isFacturacion}">
Usar también como dirección de facturación
</label>
</div>
<div
th:class="'form-group direccionFacturacionItems' + (${direccion != null and direccion.direccionFacturacion} ? '' : ' d-none')">
<label for="razonSocial">
<span th:text="#{direcciones.razon_social}">Razón Social</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="razonSocial" th:field="*{razonSocial}" maxlength="150"
th:classappend="${#fields.hasErrors('razonSocial')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('razonSocial')}" th:errors="*{razonSocial}"></div>
</div>
<div
th:class="'row mt-2 direccionFacturacionItems' + (${direccion != null and direccion.direccionFacturacion} ? '' : ' d-none')">
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="tipoIdentificacionFiscal">
<span th:text="#{direcciones.tipo_identificacion_fiscal}">Tipo de identificación fiscal</span>
<span class="text-danger">*</span>
</label>
<select class="form-control select2 direccion-item" id="tipoIdentificacionFiscal"
th:field="*{tipoIdentificacionFiscal}"
th:classappend="${#fields.hasErrors('tipoIdentificacionFiscal')} ? ' is-invalid'">
<option th:value="DNI" th:text="#{direcciones.dni}">DNI</option>
<option th:value="NIE" th:text="#{direcciones.nie}">NIE</option>
<option th:value="Pasaporte" th:text="#{direcciones.pasaporte}">Pasaporte</option>
<option th:value="CIF" th:text="#{direcciones.cif}">CIF</option>
<option th:value="VAT_ID" th:text="#{direcciones.vat_id}">VAT ID</option>
</select>
<div class="invalid-feedback" th:if="${#fields.hasErrors('tipoIdentificacionFiscal')}"
th:errors="*{tipoIdentificacionFiscal}"></div>
</div>
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="identificacionFiscal">
<span th:text="#{direcciones.identificacion_fiscal}">Número de identificación fiscal</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="identificacionFiscal" th:field="*{identificacionFiscal}"
maxlength="50" th:classappend="${#fields.hasErrors('identificacionFiscal')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('identificacionFiscal')}"
th:errors="*{identificacionFiscal}"></div>
</div>
</div>
<div class="d-flex align-items-center justify-content-center">
<button type="submit" class="btn btn-secondary mt-3" th:text="#{direcciones.save}"></button>
</div>
</form>
</div>

View File

@ -103,6 +103,7 @@
<div class="form-group mt-2">
<label for="telefono">
<span th:text="#{direcciones.telefono}">Teléfono</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="telefono" th:field="*{telefono}" maxlength="50"
th:classappend="${#fields.hasErrors('telefono')} ? ' is-invalid'">

View File

@ -0,0 +1,52 @@
<div th:fragment="direccionCard(direccion, pais, presupuestoId, unidades, isPalets)" name="direccion"
class="card card border mb-3 direccion-card bg-light w-100 mx-2">
<div class="card-body">
<input type="hidden" class="presupuesto-id" th:value="${presupuestoId}" />
<input type="hidden" class="direccion-id" th:value="${direccion.id}" />
<input type="hidden" class="direccion-cp" th:value="${direccion.cp}" />
<input type="hidden" class="direccion-pais-code3" th:value="${direccion.pais.code3}" />
<input type="hidden" class="item-tirada" th:value="${unidades != null ? unidades : ''}" />
<input type="hidden" class="is-palets" th:value="${isPalets != null ? isPalets : ''}" />
<div class="row g-3 align-items-start flex-nowrap">
<div class="col">
<span class="mb-2 fw-semibold d-block text-muted text-uppercase text-break" th:text="${direccion.alias}"></span>
<span class="fs-14 mb-1 d-block text-break" th:text="${direccion.att}"></span>
<span class="text-muted fw-normal text-wrap mb-1 d-block text-break" th:text="${direccion.direccion}"></span>
<span class="text-muted fw-normal d-block text-break" th:text="${pais}"></span>
<span class="text-muted fw-normal d-block text-break"
th:text="#{'direcciones.telefono'} + ': ' + ${direccion.telefono}"></span>
</div>
<div class="col-auto ms-auto text-end">
<div th:if="${unidades != null}">
<span id="units-text" class="mb-2 fw-semibold d-block text-muted text-uppercase" th:if="${unidades == 1}"
th:text="|${unidades} #{cart.shipping.ud}|"></span>
<!-- plural -->
<span id="units-text" class="mb-2 fw-semibold d-block text-muted text-uppercase" th:unless="${unidades == 1}"
th:text="|${unidades} #{cart.shipping.uds}|"></span>
</div>
<div th:if="${isPalets != null and isPalets==1}">
<i class="icon-shipment las la-pallet la-3x text-muted"></i>
</div>
<div th:if="${isPalets == null or isPalets == 0}">
<i class="icon-shipment las la-box la-3x text-muted"></i>
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 py-1 bg-light rounded-bottom border-top mt-auto actions-row">
<a href="javascript:void(0)" class="d-block text-body p-1 px-2 btn-delete-direccion"
data-id="${this._esc(d.id ?? '')}">
<i class="ri-delete-bin-fill text-muted align-bottom me-1"></i>
<span th:text="#{'direcciones.btn.delete'}">Eliminar</span>
</a>
<a th:if="${unidades != null}" href="javascript:void(0)" class="d-block text-body p-1 px-2 btn-edit-direccion"
data-id="${this._esc(d.id ?? '')}">
<i class="ri-pencil-fill text-muted align-bottom me-1"></i>
<span th:text="#{'direcciones.btn.edit'}">Editar</span>
</a>
</div>
</div>
</div>

View File

@ -6,6 +6,9 @@
<head>
<!--page title-->
<th:block layout:fragment="pagetitle" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<!-- Page CSS -->
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />

View File

@ -0,0 +1,7 @@
<form id="tpv" th:action="${action}" method="POST">
<input type="hidden" name="Ds_SignatureVersion" th:value="${signatureVersion}" />
<input type="hidden" name="Ds_MerchantParameters" th:value="${merchantParameters}" />
<input type="hidden" name="Ds_Signature" th:value="${signature}" />
<noscript><button type="submit">Pagar</button></noscript>
</form>
<script>document.getElementById('tpv').submit();</script>

View File

@ -0,0 +1,44 @@
package com.imprimelibros.erp;
import static org.junit.jupiter.api.Assertions.*;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Locale;
import com.imprimelibros.erp.externalApi.skApiClient;
@SpringBootTest
public class calcularEnvios {
@Autowired
private skApiClient apiClient;
@Test
void testPrecioCalculadoDevuelveJson() {
Map<String, Object> data = new HashMap<>();
data.put("pais_code3", "esp");
data.put("cp", 18200);
data.put("peso", 7.82);
data.put("unidades", 10);
data.put("palets", 0);
// get locale
Locale locale = Locale.forLanguageTag("es-ES");
Map<String, Object> resultado = apiClient.getCosteEnvio(data, locale);
System.out.println("📦 Resultado de la API:");
System.out.println(resultado);
assertNotNull(resultado, "El resultado no debe ser null");
/*assertTrue(resultado.trim().startsWith("{"), "El resultado debe comenzar con { (JSON)");
assertTrue(resultado.trim().endsWith("}"), "El resultado debe terminar con } (JSON)");*/
}
}