Compare commits

...

2 Commits

Author SHA1 Message Date
9dad59fe16 modificando carrito 2025-10-27 20:30:38 +01:00
f6a683de81 modificando carrito 2025-10-27 20:30:11 +01:00
22 changed files with 1050 additions and 42 deletions

View File

@ -4,14 +4,10 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import com.imprimelibros.erp.users.UserDetailsImpl;
import jakarta.servlet.http.HttpServletRequest;
import com.imprimelibros.erp.users.User;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import com.imprimelibros.erp.common.Utils;
import java.security.Principal;
import java.util.Locale;
@ -27,32 +23,11 @@ public class CartController {
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");
}
/** Vista del carrito */
@GetMapping
public String viewCart(Model model, Principal principal, Locale locale) {
var items = service.listItems(currentUserId(principal), locale);
var items = service.listItems(Utils.currentUserId(principal), locale);
model.addAttribute("items", items);
return "imprimelibros/cart/cart"; // crea esta vista si quieres (tabla simple)
}
@ -60,14 +35,14 @@ public class CartController {
/** 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 +58,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 +72,14 @@ 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";
}
}

View File

@ -134,8 +134,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());

View File

@ -0,0 +1,86 @@
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.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.paises.PaisesService;
import jakarta.mail.Message;
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)
}
@GetMapping("/get-address/{id}")
public String getDireccionCard(@PathVariable Long id, 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("direccion", dir);
return "imprimelibros/direcciones/direccionCard :: direccionCard(direccion=${direccion})";
}
}

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,7 @@ import java.util.Optional;
import java.util.function.BiFunction;
import org.springframework.context.MessageSource;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
@ -22,6 +24,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 +44,24 @@ public class Utils {
this.messageSource = messageSource;
}
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

@ -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,33 @@ 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,
Authentication auth) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
Long currentUserId = null;
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 +514,5 @@ public class DireccionController {
}
return currentUserId != null && currentUserId.equals(ownerId);
}
}

View File

@ -0,0 +1,86 @@
package com.imprimelibros.erp.direcciones;
import java.text.Collator;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.imprimelibros.erp.direcciones.DireccionRepository;
import com.imprimelibros.erp.paises.Paises;
@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);
}
}

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;
@ -20,6 +21,7 @@ import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import java.util.Map;
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 +221,49 @@ 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 = (Double) responseBody.get("data");
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 +281,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

@ -11,8 +11,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
@ -24,8 +24,8 @@ spring.jpa.show-sql=false
#
# Safekat API Configuration
#
safekat.api.url=http://localhost:8000/
#safekat.api.url=https://erp-dev.safekat.es/
#safekat.api.url=http://localhost:8000/
safekat.api.url=https://erp-dev.safekat.es/
safekat.api.email=imnavajas@coit.es
safekat.api.password=Safekat2024

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

@ -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.onlyOneShipping=Todo el pedido se envía a una única dirección.
checkout.shipping.add=Añadir dirección
checkout.shipping.add.title=Seleccione una dirección
checkout.shipping.select-placeholder=Buscar en direcciones...
checkout.shipping.new-address=Nueva dirección
checkout.summary.presupuesto=#Presupuesto
checkout.summary.titulo=Título
checkout.summary.base=Base

View File

@ -0,0 +1,193 @@
$(() => {
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);
$("#onlyOneShipping").on('change', function () {
if ($(this).is(':checked')) {
$('#shippingAddressesContainer').empty().show();
$('#shippingMultipleAddressesContainer').show();
} else {
$('#shippingAddressesContainer').hide();
$('#shippingMultipleAddressesContainer').empty().hide();
}
});
$('#addOrderAddress').on('click', () => {
if ($('#onlyOneShipping').is(':checked')) {
seleccionarDireccionEnvio();
} else {
// Add address to multiple shipping addresses container
}
});
async function seleccionarDireccionEnvio() {
const { value: direccionId, isDenied } = await Swal.fire({
title: window.languageBundle['checkout.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['checkout.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['checkout.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
});
// (Opcional) Prefijar valor si ya tienes una dirección elegida:
// const preselected = { id: '123', text: 'Oficina Central — Madrid' };
// const option = new Option(preselected.text, preselected.id, true, true);
// $select.append(option).trigger('change');
},
preConfirm: () => {
const $select = $('#direccionSelect');
const val = $select.val();
if (!val) {
Swal.showValidationMessage(
lang.startsWith('es') ? 'Debes seleccionar una dirección' : 'You must select an address'
);
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.show();
});
}
if (direccionId) {
// Obtén el objeto completo seleccionado
const response = await fetch(`/checkout/get-address/${direccionId}`);
if (response.ok) {
const html = await response.text();
$('#shippingAddressesContainer').html(html);
}
}
}
$(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.hide();
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

@ -36,7 +36,28 @@
<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 class="card">
<div class="card-body">
<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="onlyOneShipping" name="onlyOneShipping" checked />
<label for="onlyOneShipping" class="form-label d-flex align-items-center mb-0">
<span th:text="#{checkout.shipping.onlyOneShipping}" 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>
</div>
<div th:each="item : ${items}" th:insert="~{imprimelibros/cart/_cartItem :: cartItem(${item})}">
</div>
</div>
@ -67,13 +88,14 @@
<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:onclick="location.href='/checkout'"
th:text="#{cart.resumen.tramitar}">Checkout</button>
</div>
<!-- end table-responsive -->
@ -83,7 +105,8 @@
<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>
<h5 class="fs-14 text-danger fw-semibold"
th:text="#{cart.resumen.fidelizacion}"></h5>
</div>
</div>
</div>

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="onlyOneShipping"
name="onlyOneShipping" checked />
<label for="onlyOneShipping" class="form-label d-flex align-items-center mb-0">
<span th:text="#{checkout.shipping.onlyOneShipping}" 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,18 @@
<div th:fragment="direccionCard(direccion)">
<div class="card mb-3 direccion-card"
th:attr="data-id=${direccion.id},
data-cp=${direccion.cp},
data-pais-code3=${direccion.pais.code3}">
<div class="card-body">
<h5 class="card-title" th:text="${direccion.alias}">Alias</h5>
<h3 class="card-text mb-0"
th:if="${direccion.att != null and !#strings.isEmpty(direccion.att)}"
th:text="${direccion.att}">Att</h3>
<p class="card-text mb-0" th:text="${direccion.direccion}">Calle</p>
<p class="card-text mb-0" th:text="${direccion.cp} + ' ' + ${direccion.ciudad}">CP Ciudad</p>
<p class="card-text mb-0" th:text="${pais}">País</p>
</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,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)");*/
}
}