Compare commits

...

33 Commits

Author SHA1 Message Date
1888850a64 Merge branch 'feat/crud_presupuestos' into 'main'
Feat/crud presupuestos

See merge request jjimenez/erp-imprimelibros!10
2025-10-17 11:32:17 +00:00
06e03afa04 terminado. trabajando en el carrito. falta mensaje de ya añadido 2025-10-17 13:31:09 +02:00
46715d1017 primera version final del presupuesto 2025-10-17 09:21:31 +02:00
ea8a005cde acabando presupuesto 2025-10-16 21:46:02 +02:00
ff9c04afb6 trabajando en el resumen 2025-10-16 15:22:50 +02:00
060b435388 arreglados problemas presupuesto, botones css etc 2025-10-16 14:24:24 +02:00
f26f96a490 falta precio completo del item del carrito y devolver que ya existe el presupuesto en el carro 2025-10-15 20:05:56 +02:00
f20dd9068a corregidos varios fallos de presupuesto 2025-10-15 19:43:00 +02:00
70856edc12 añadido grapado al validados para tipos de paginas distintas 2025-10-15 11:13:45 +02:00
9f33db4055 trabajando en la vista del carro de la compra 2025-10-14 21:52:25 +02:00
a33ba3256b estructura inicial de carrito hecha 2025-10-14 15:16:02 +02:00
90376e61c8 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 14:04:21 +02:00
37ae61d6f7 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 13:21:35 +02:00
9b0a79e2cd modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 12:31:33 +02:00
47344c94a9 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 12:30:39 +02:00
543ff9a079 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 12:30:23 +02:00
d99ef65268 draft de impresion presupuesto, añadiendo posibilidades de iva 2025-10-13 14:38:30 +02:00
9d88392a2b trabajando en el pdf 2025-10-12 23:28:55 +02:00
9ebe2a3419 trabajando en el pdf 2025-10-12 23:28:43 +02:00
c15fff73ee trabajando en el pdf 2025-10-12 23:28:33 +02:00
99d27cd3ed mejorados botones form presupuesto 2025-10-12 21:43:45 +02:00
26c2ca543a preparando el imprimir 2025-10-12 21:42:04 +02:00
6641c1f077 falta presupuesto marcapaginas y maquetacion y revision general en admin. revisar user 2025-10-12 14:48:20 +02:00
62dcff8869 falta ordenar por paginas y revisar la busqueda de los selects 2025-10-11 18:16:55 +02:00
a1359f37b0 guardando presupuestos anonimos 2025-10-11 14:14:47 +02:00
d4d83fe118 trabajando en el delete 2025-10-10 13:28:03 +02:00
6c4b63daa6 trabajando en leer datos de tipo cubierta 2025-10-09 21:59:59 +02:00
328ff509e3 trabajando en el wizard del presupuesto 2025-10-09 15:40:42 +02:00
2b53579a48 tabla anonimos terminado 2025-10-08 21:35:20 +02:00
389ac22b68 ya obtengo los datos de los anonimos, falta formatear esos datos y buscar 2025-10-07 10:44:32 +02:00
1e8f9cafb3 trabajando en el datatables de los presupuestos 2025-10-06 15:32:50 +02:00
b2f3ef042e se guardan los presupuestos publicos 2025-10-05 17:47:42 +02:00
14ca264ae2 trabajando en guardar presupuestos publicos 2025-10-05 16:30:28 +02:00
134 changed files with 7167 additions and 712 deletions

33
pom.xml
View File

@ -6,7 +6,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<version>3.5.6</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.imprimelibros</groupId>
@ -120,6 +120,37 @@
<version>8.10.1</version>
</dependency>
<!-- GeoIP2 (MaxMind) -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.2.0</version>
</dependency>
<!-- HTTP client (Spring Web) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- PDF generation -->
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-slf4j</artifactId>
<version>1.0.10</version>
</dependency>
</dependencies>
<build>

View File

@ -2,8 +2,10 @@ package com.imprimelibros.erp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
public class ErpApplication {
public static void main(String[] args) {

View File

@ -6,15 +6,10 @@ import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import com.imprimelibros.erp.common.email.EmailService;
import com.imprimelibros.erp.users.User;
@ -26,26 +21,17 @@ public class PasswordResetService {
private final PasswordResetTokenRepository tokenRepo;
private final UserDao userRepo;
private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
private final MessageSource messages;
private final EmailService emailService;
public PasswordResetService(
PasswordResetTokenRepository tokenRepo,
UserDao userRepo,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender,
SpringTemplateEngine templateEngine,
MessageSource messages,
EmailService emailService
) {
this.tokenRepo = tokenRepo;
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender;
this.templateEngine = templateEngine;
this.messages = messages;
this.emailService = emailService;
}

View File

@ -0,0 +1,56 @@
package com.imprimelibros.erp.cart;
import jakarta.persistence.*;
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"}))
public class Cart {
public enum Status { ACTIVE, LOCKED, ABANDONED }
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Status status = Status.ACTIVE;
@Column(nullable = false, length = 3)
private String currency = "EUR";
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CartItem> items = new ArrayList<>();
@PreUpdate
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 Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
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; }
}

View File

@ -0,0 +1,110 @@
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 jakarta.servlet.http.HttpServletRequest;
import com.imprimelibros.erp.users.User;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import java.security.Principal;
import java.util.Locale;
import java.util.Map;
@Controller
@RequestMapping("/cart")
public class CartController {
private final CartService service;
public CartController(CartService service) {
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);
model.addAttribute("items", items);
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);
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);
boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
if (isAjax) {
// Responder 200 con la URL a la que quieres ir
return ResponseEntity.ok(
Map.of("redirect", "/cart"));
}
// Navegación normal: redirección server-side
return "redirect:/cart";
}
@GetMapping("/count")
@ResponseBody
public long getCount(Principal principal) {
if (principal == null)
return 0;
return service.countItems(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);
return "redirect:/cart";
}
/** Eliminar línea por presupuesto_id (opcional) */
@DeleteMapping("/delete/item/{presupuestoId}")
@ResponseBody
public String removeByPresupuesto(@PathVariable Long presupuestoId, Principal principal) {
service.removeByPresupuesto(currentUserId(principal), presupuestoId);
return "redirect:/cart";
}
/** Vaciar carrito completo */
@DeleteMapping("/clear")
public String clear(Principal principal) {
service.clear(currentUserId(principal));
return "redirect:/cart";
}
}

View File

@ -0,0 +1,37 @@
package com.imprimelibros.erp.cart;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(
name = "cart_items",
uniqueConstraints = @UniqueConstraint(name="uq_cartitem_unique", columnNames={"cart_id","presupuesto_id"})
)
public class CartItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id", nullable = false)
private Cart cart;
@Column(name = "presupuesto_id", nullable = false)
private Long presupuestoId;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
// Getters & Setters
public Long getId() { return id; }
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 LocalDateTime getCreatedAt() { return createdAt; }
}

View File

@ -0,0 +1,17 @@
package com.imprimelibros.erp.cart;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
List<CartItem> findByCartId(Long cartId);
Optional<CartItem> findByCartIdAndPresupuestoId(Long cartId, Long presupuestoId);
boolean existsByCartIdAndPresupuestoId(Long cartId, Long presupuestoId);
long deleteByCartId(Long cartId);
}

View File

@ -0,0 +1,9 @@
package com.imprimelibros.erp.cart;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface CartRepository extends JpaRepository<Cart, Long> {
Optional<Cart> findByUserIdAndStatus(Long userId, Cart.Status status);
}

View File

@ -0,0 +1,154 @@
package com.imprimelibros.erp.cart;
import jakarta.transaction.Transactional;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.common.Utils;
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;
public CartService(CartRepository cartRepo, CartItemRepository itemRepo,
MessageSource messageSource, PresupuestoFormatter presupuestoFormatter,
PresupuestoRepository presupuestoRepo, Utils utils) {
this.cartRepo = cartRepo;
this.itemRepo = itemRepo;
this.messageSource = messageSource;
this.presupuestoFormatter = presupuestoFormatter;
this.presupuestoRepo = presupuestoRepo;
this.utils = utils;
}
/** Devuelve el carrito activo o lo crea si no existe. */
@Transactional
public Cart getOrCreateActiveCart(Long userId) {
return cartRepo.findByUserIdAndStatus(userId, Cart.Status.ACTIVE)
.orElseGet(() -> {
Cart c = new Cart();
c.setUserId(userId);
c.setStatus(Cart.Status.ACTIVE);
return cartRepo.save(c);
});
}
/** Lista items (presupuestos) del carrito activo del usuario. */
@Transactional
public List<Map<String, Object>> listItems(Long userId, Locale locale) {
Cart cart = getOrCreateActiveCart(userId);
List<Map<String, Object>> resultados = new ArrayList<>();
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()));
Map<String, Object> elemento = getElementoCart(p, locale);
elemento.put("cartItemId", item.getId());
resultados.add(elemento);
}
//System.out.println("Cart items: " + resultados);
return resultados;
}
/** Añade un presupuesto al carrito. Si ya está, no hace nada (idempotente). */
@Transactional
public void addPresupuesto(Long userId, Long presupuestoId) {
Cart cart = getOrCreateActiveCart(userId);
boolean exists = itemRepo.existsByCartIdAndPresupuestoId(cart.getId(), presupuestoId);
if (!exists) {
CartItem ci = new CartItem();
ci.setCart(cart);
ci.setPresupuestoId(presupuestoId);
itemRepo.save(ci);
}
}
/** Elimina una línea del carrito por ID de item (validando pertenencia). */
@Transactional
public void removeItem(Long userId, Long itemId) {
Cart cart = getOrCreateActiveCart(userId);
CartItem item = itemRepo.findById(itemId).orElseThrow(() -> new IllegalArgumentException("Item no existe"));
if (!item.getCart().getId().equals(cart.getId()))
throw new IllegalStateException("El item no pertenece a tu carrito");
itemRepo.delete(item);
}
/** Elimina una línea del carrito buscando por presupuesto_id. */
@Transactional
public void removeByPresupuesto(Long userId, Long presupuestoId) {
Cart cart = getOrCreateActiveCart(userId);
itemRepo.findByCartIdAndPresupuestoId(cart.getId(), presupuestoId)
.ifPresent(itemRepo::delete);
}
/** Vacía todo el carrito activo. */
@Transactional
public void clear(Long userId) {
Cart cart = getOrCreateActiveCart(userId);
itemRepo.deleteByCartId(cart.getId());
}
/** Marca el carrito como bloqueado (por ejemplo, antes de crear un pedido). */
@Transactional
public void lockCart(Long userId) {
Cart cart = getOrCreateActiveCart(userId);
cart.setStatus(Cart.Status.LOCKED);
cartRepo.save(cart);
}
@Transactional
public long countItems(Long userId) {
Cart cart = getOrCreateActiveCart(userId);
return itemRepo.findByCartId(cart.getId()).size();
}
private Map<String, Object> getElementoCart(Presupuesto presupuesto, Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
resumen.put("imagen",
"/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion() + ".png");
resumen.put("imagen_alt",
messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null, locale));
resumen.put("presupuestoId", presupuesto.getId());
Map<String, Object> detalles = utils.getTextoPresupuesto(presupuesto, locale);
resumen.put("baseTotal", Utils.formatCurrency(presupuesto.getBaseImponible(), locale));
resumen.put("base", presupuesto.getBaseImponible());
resumen.put("iva4", presupuesto.getIvaImporte4());
resumen.put("iva21", presupuesto.getIvaImporte21());
resumen.put("resumen", detalles);
return resumen;
}
}

View File

@ -0,0 +1,237 @@
package com.imprimelibros.erp.common;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
@Component
public class Utils {
private final PresupuestoFormatter presupuestoFormatter;
private final MessageSource messageSource;
public Utils(PresupuestoFormatter presupuestoFormatter,
MessageSource messageSource) {
this.presupuestoFormatter = presupuestoFormatter;
this.messageSource = messageSource;
}
public static String formatCurrency(BigDecimal amount, Locale locale) {
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
return currencyFormatter.format(amount);
}
public static String formatCurrency(Double amount, Locale locale) {
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
return currencyFormatter.format(amount);
}
public Map<String, Object> getTextoPresupuesto(Presupuesto presupuesto, Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
resumen.put("imagen",
"/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion()
+ ".png");
resumen.put("imagen_alt",
messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null,
locale));
resumen.put("presupuestoId", presupuesto.getId());
ObjectMapper mapper = new ObjectMapper();
List<Map<String, Object>> servicios = new ArrayList<>();
if (presupuesto.getServiciosJson() != null && !presupuesto.getServiciosJson().isBlank())
try {
servicios = mapper.readValue(presupuesto.getServiciosJson(), new TypeReference<>() {
});
} catch (JsonProcessingException e) {
// Manejar la excepción
}
boolean hayDepositoLegal = servicios != null && servicios.stream()
.map(m -> java.util.Objects.toString(m.get("id"), ""))
.map(String::trim)
.anyMatch("deposito-legal"::equals);
List<HashMap<String, Object>> lineas = new ArrayList<>();
HashMap<String, Object> linea = new HashMap<>();
Double precio_unitario = 0.0;
Double precio_total = 0.0;
BigDecimal total = BigDecimal.ZERO;
linea.put("descripcion", presupuestoFormatter.resumen(presupuesto, servicios, locale));
linea.put("cantidad", presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0);
precio_unitario = (presupuesto.getPrecioUnitario() != null
? presupuesto.getPrecioUnitario().doubleValue()
: 0.0);
precio_total = (presupuesto.getPrecioTotalTirada() != null
? presupuesto.getPrecioTotalTirada().doubleValue()
: 0.0);
linea.put("precio_unitario", precio_unitario);
linea.put("precio_total", BigDecimal.valueOf(precio_total).setScale(2, RoundingMode.HALF_UP));
total = total.add(BigDecimal.valueOf(precio_total));
lineas.add(linea);
if (hayDepositoLegal) {
linea = new HashMap<>();
linea.put("descripcion",
messageSource.getMessage("pdf.ejemplares-deposito-legal", new Object[] { 4 },
locale));
lineas.add(linea);
}
String serviciosExtras = "";
if (servicios != null) {
for (Map<String, Object> servicio : servicios) {
if ("deposito-legal".equals(servicio.get("id")) ||
"service-isbn".equals(servicio.get("id"))) {
serviciosExtras += messageSource.getMessage(
"presupuesto.extras-" + servicio.get("id"), null, locale)
+ ", ";
} else {
serviciosExtras += messageSource.getMessage(
"presupuesto.extras-" + servicio.get("id"), null, locale)
.toLowerCase() + ", ";
}
}
if (!serviciosExtras.isEmpty()) {
serviciosExtras = serviciosExtras.substring(0, serviciosExtras.length() - 2);
;
}
if (servicios.stream().anyMatch(service -> "marcapaginas".equals(service.get("id")))) {
ObjectMapper mapperServicio = new ObjectMapper();
Object raw = presupuesto.getDatosMarcapaginasJson();
Map<String, Object> datosMarcapaginas;
String descripcion = "";
try {
if (raw instanceof String s) {
datosMarcapaginas = mapperServicio.readValue(s,
new TypeReference<Map<String, Object>>() {
});
} else if (raw instanceof Map<?, ?> m) {
datosMarcapaginas = mapperServicio.convertValue(m,
new TypeReference<Map<String, Object>>() {
});
} else {
throw new IllegalArgumentException(
"Tipo no soportado para datosMarcapaginas: "
+ raw);
}
} catch (JsonProcessingException e) {
throw new RuntimeException("Error parsing datosMarcapaginasJson", e);
}
descripcion += "<br/><ul><li>";
descripcion += Marcapaginas.Tamanios
.valueOf(datosMarcapaginas.get("tamanio").toString()).getLabel()
+ ", ";
descripcion += Marcapaginas.Caras_Impresion
.valueOf(datosMarcapaginas.get("carasImpresion").toString())
.getMessageKey() + ", ";
descripcion += messageSource
.getMessage(Marcapaginas.Papeles
.valueOf(datosMarcapaginas.get("papel")
.toString())
.getMessageKey(), null, locale)
+ " - " +
datosMarcapaginas.get("gramaje").toString() + " gr, ";
descripcion += messageSource.getMessage(
Marcapaginas.Acabado.valueOf(
datosMarcapaginas.get("acabado").toString())
.getMessageKey(),
null, locale);
descripcion += "</li></ul>";
resumen.put("datosMarcapaginas", descripcion);
}
if (servicios.stream().anyMatch(service -> "maquetacion".equals(service.get("id")))) {
ObjectMapper mapperServicio = new ObjectMapper();
Object raw = presupuesto.getDatosMaquetacionJson();
Map<String, Object> datosMaquetacion;
String descripcion = "";
try {
if (raw instanceof String s) {
datosMaquetacion = mapperServicio.readValue(s,
new TypeReference<Map<String, Object>>() {
});
} else if (raw instanceof Map<?, ?> m) {
datosMaquetacion = mapperServicio.convertValue(m,
new TypeReference<Map<String, Object>>() {
});
} else {
throw new IllegalArgumentException(
"Tipo no soportado para datosMaquetacion: "
+ raw);
}
} catch (JsonProcessingException e) {
throw new RuntimeException("Error parsing datosMaquetacionJson", e);
}
descripcion += "<br/><ul><li>";
descripcion += (datosMaquetacion.get("num_caracteres") + " "
+ messageSource.getMessage("presupuesto.maquetacion.caracteres",
null, locale))
+ ", ";
descripcion += MaquetacionMatrices.Formato
.valueOf(datosMaquetacion.get("formato_maquetacion").toString())
.getLabel() + ", ";
descripcion += messageSource.getMessage(MaquetacionMatrices.FontSize
.valueOf(datosMaquetacion.get("cuerpo_texto").toString())
.getMessageKey(), null, locale)
+ ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-columnas",
null, locale) + ": "
+ datosMaquetacion.get("num_columnas").toString() + ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-tablas",
null, locale) + ": "
+ datosMaquetacion.get("num_tablas").toString() + ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-fotos",
null, locale) + ": "
+ datosMaquetacion.get("num_fotos").toString();
if ((boolean) datosMaquetacion.get("correccion_ortotipografica")) {
descripcion += ", " + messageSource
.getMessage("presupuesto.maquetacion.correccion-ortotipografica",
null, locale);
}
if ((boolean) datosMaquetacion.get("texto_mecanografiado")) {
descripcion += ", " + messageSource.getMessage(
"presupuesto.maquetacion.texto-mecanografiado",
null, locale);
}
if ((boolean) datosMaquetacion.get("disenio_portada")) {
descripcion += ", "
+ messageSource.getMessage(
"presupuesto.maquetacion.diseno-portada",
null, locale);
}
if ((boolean) datosMaquetacion.get("epub")) {
descripcion += ", " + messageSource.getMessage(
"presupuesto.maquetacion.epub", null, locale);
}
descripcion += "</li></ul>";
resumen.put("datosMaquetacion", descripcion);
}
}
NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(locale);
String formattedString = currencyFormat.format(total.setScale(2, RoundingMode.HALF_UP).doubleValue());
resumen.put("total", formattedString);
resumen.put("lineas", lineas);
resumen.put("servicios", serviciosExtras);
return resumen;
}
}

View File

@ -1,6 +1,5 @@
package com.imprimelibros.erp.common.email;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.springframework.context.MessageSource;

View File

@ -0,0 +1,77 @@
package com.imprimelibros.erp.common.jpa;
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
import com.imprimelibros.erp.users.User;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AbstractAuditedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Auditoría temporal
@CreatedDate
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private Instant updatedAt;
// Auditoría por usuario (nullable si público anónimo)
@CreatedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@LastModifiedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "updated_by")
private User updatedBy;
// Soft delete
@Column(name = "deleted", nullable = false)
private boolean deleted = false;
@Column(name = "deleted_at")
private Instant deletedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "deleted_by")
private User deletedBy;
// Getters/Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public User getCreatedBy() { return createdBy; }
public void setCreatedBy(User createdBy) { this.createdBy = createdBy; }
public User getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(User updatedBy) { this.updatedBy = updatedBy; }
public boolean isDeleted() { return deleted; }
public void setDeleted(boolean deleted) { this.deleted = deleted; }
public Instant getDeletedAt() { return deletedAt; }
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
public User getDeletedBy() { return deletedBy; }
public void setDeletedBy(User deletedBy) { this.deletedBy = deletedBy; }
}

View File

@ -0,0 +1,37 @@
package com.imprimelibros.erp.common.web;
import jakarta.servlet.http.HttpServletRequest;
public class IpUtils {
public static String getClientIp(HttpServletRequest request) {
String[] headers = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
// Si hay varios (X-Forwarded-For), toma el primero
return ip.split(",")[0];
}
}
// Fallback
String ip = request.getRemoteAddr();
if ("0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip)) {
return "127.0.0.1";
}
return ip;
}
}

View File

@ -0,0 +1,47 @@
// JpaAuditConfig.java
package com.imprimelibros.erp.config;
import java.util.Optional;
import jakarta.persistence.EntityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.context.SecurityContextHolder;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDetailsImpl; // tu implementación
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class JpaAuditConfig {
@Bean
public AuditorAware<User> auditorAware(EntityManager em) {
return () -> {
var ctx = SecurityContextHolder.getContext();
if (ctx == null) return Optional.empty();
var auth = ctx.getAuthentication();
if (auth == null || !auth.isAuthenticated()) return Optional.empty();
Object principal = auth.getPrincipal();
Long userId = null;
// Tu UserDetailsImpl ya tiene el id
if (principal instanceof UserDetailsImpl udi) {
userId = udi.getId();
}
// Si a veces pones el propio User como principal:
else if (principal instanceof User u && u.getId() != null) {
userId = u.getId();
}
// ⚠️ NO hagas consultas aquí (nada de userDao.findBy...).
if (userId == null) return Optional.empty();
// Devuelve una referencia gestionada (NO hace SELECT ni fuerza flush)
return Optional.of(em.getReference(User.class, userId));
};
}
}

View File

@ -85,7 +85,7 @@ public class SecurityConfig {
.authenticationProvider(provider)
.sessionManagement(session -> session
.invalidSessionUrl("/login?expired")
//.invalidSessionUrl("/login?expired")
.maximumSessions(1))
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
@ -113,8 +113,12 @@ public class SecurityConfig {
RequestMatcher notStatic = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/assets/")));
RequestMatcher cartCount = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown));
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown, cartCount));
rc.requestCache(cache);
})
// ========================================================
@ -135,7 +139,8 @@ public class SecurityConfig {
"/presupuesto/public/**",
"/error",
"/favicon.ico",
"/.well-known/**" // opcional
"/.well-known/**", // opcional
"/api/pdf/presupuesto/**"
).permitAll()
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
.anyRequest().authenticated())

View File

@ -9,8 +9,9 @@ import java.time.LocalDateTime;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.shared.validation.NoRangeOverlap;

View File

@ -16,7 +16,6 @@ import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@ -29,8 +28,8 @@ import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@ -321,8 +320,6 @@ public class MargenPresupuestoController {
return repo.findById(id).map(u -> {
try {
u.setDeleted(true);
u.setDeletedAt(LocalDateTime.now());

View File

@ -5,8 +5,8 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
public interface MargenPresupuestoDao
extends JpaRepository<MargenPresupuesto, Long>, JpaSpecificationExecutor<MargenPresupuesto> {

View File

@ -6,8 +6,8 @@ import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
@Service
@Transactional

View File

@ -7,12 +7,14 @@ import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import jakarta.persistence.criteria.*;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Function;
public class DataTable<T> {
/* ===== Tipos funcionales ===== */
public interface FilterHook<T> extends BiConsumer<SpecBuilder<T>, DataTablesRequest> {
}
@ -20,25 +22,55 @@ public class DataTable<T> {
void add(Specification<T> extra);
}
/**
* Filtro custom por campo virtual: te doy (root, query, cb, value) y me
* devuelves un Predicate
*/
@FunctionalInterface
public interface FieldFilter<T> {
Predicate apply(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb, String value);
}
/**
* Orden custom por campo virtual: te doy (root, query, cb) y me devuelves la
* Expression<?> para orderBy
*/
@FunctionalInterface
public interface FieldOrder<T> {
Expression<?> apply(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
/* ===== Estado ===== */
private final JpaSpecificationExecutor<T> repo;
private final Class<T> entityClass;
private final DataTablesRequest dt;
private final List<String> searchable;
private final List<Function<T, Map<String, Object>>> adders = new ArrayList<>();
private final List<Function<Map<String, Object>, Map<String, Object>>> editors = new ArrayList<>();
private final List<FilterHook<T>> filters = new ArrayList<>();
private Specification<T> baseSpec = (root, q, cb) -> cb.conjunction();
private final ObjectMapper om = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
/** whitelist de campos ordenables “simples” (por nombre) */
private List<String> orderable = null;
/** mapas de comportamiento custom por campo */
private final Map<String, FieldOrder<T>> orderCustom = new HashMap<>();
private final Map<String, FieldFilter<T>> filterCustom = new HashMap<>();
private boolean onlyAdded = false;
/* ===== Ctor / factory ===== */
private DataTable(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
List<String> searchable) {
this.repo = repo;
this.entityClass = entityClass;
this.dt = dt;
this.searchable = searchable;
this.searchable = searchable != null ? searchable : List.of();
}
public static <T> DataTable<T> of(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
@ -46,13 +78,19 @@ public class DataTable<T> {
return new DataTable<>(repo, entityClass, dt, searchable);
}
/** Equivalente a tu $q->where(...): establece condición base */
/* ===== Fluent API ===== */
public DataTable<T> onlyAddedColumns() {
this.onlyAdded = true;
return this;
}
/** WHERE base reusable */
public DataTable<T> where(Specification<T> spec) {
this.baseSpec = this.baseSpec.and(spec);
return this;
}
/** add("campo", fn(entity)->valor|Map) */
/** Campos renderizados */
public DataTable<T> add(String field, Function<T, Object> fn) {
adders.add(entity -> {
Map<String, Object> m = new HashMap<>();
@ -62,19 +100,19 @@ public class DataTable<T> {
return this;
}
/**
* add(fn(entity)->Map<String,Object>) para devolver objetos anidados como tu
* "logo"
*/
public DataTable<T> addIf(boolean condition, String field, Function<T, Object> fn) {
if (condition)
return add(field, fn);
return this;
}
public DataTable<T> add(Function<T, Map<String, Object>> fn) {
adders.add(fn);
return this;
}
/**
* edit("campo", fn(entity)->valor) sobreescribe un campo existente o lo crea si
* no existe
*/
/** Edita/inyecta valor usando la entidad original (guardada como __entity) */
@SuppressWarnings("unchecked")
public DataTable<T> edit(String field, Function<T, Object> fn) {
editors.add(row -> {
row.put(field, fn.apply((T) row.get("__entity")));
@ -83,73 +121,132 @@ public class DataTable<T> {
return this;
}
/** Whitelist de campos simples ordenables (por nombre) */
public DataTable<T> orderable(List<String> fields) {
this.orderable = fields;
return this;
}
private List<String> getOrderable() {
return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable;
/** Orden custom por campo virtual (expresiones) */
public DataTable<T> orderable(String field, FieldOrder<T> orderFn) {
this.orderCustom.put(field, orderFn);
return this;
}
/** filter((builder, req) -> builder.add(miExtraSpec(req))) */
/** Filtro custom por campo virtual (LIKE, rangos, etc.) */
public DataTable<T> filter(String field, FieldFilter<T> filterFn) {
this.filterCustom.put(field, filterFn);
return this;
}
/** Hook para añadir Specifications extra programáticamente */
public DataTable<T> filter(FilterHook<T> hook) {
filters.add(hook);
return this;
}
/* ===== Helpers ===== */
private List<String> getOrderable() {
return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable;
}
/* ===== Core ===== */
public DataTablesResponse<Map<String, Object>> toJson(long totalCount) {
// Construye spec con búsqueda global + base + filtros custom
// 1) Spec base + búsqueda (global/columnas) + hooks programáticos
Specification<T> spec = baseSpec.and(DataTablesSpecification.build(dt, searchable));
final Specification<T>[] holder = new Specification[] { spec };
filters.forEach(h -> h.accept(extra -> holder[0] = holder[0].and(extra), dt));
spec = holder[0];
// Sort
// Sort
// Hooks externos
filters.forEach(h -> h.accept(extra -> holder[0] = holder[0].and(extra), dt));
// 2) Filtros por columna “custom” (virtuales)
for (var col : dt.columns) {
if (col == null || !col.searchable)
continue;
if (col.name == null || col.name.isBlank())
continue;
if (!filterCustom.containsKey(col.name))
continue;
if (col.search == null || col.search.value == null || col.search.value.isBlank())
continue;
var value = col.search.value;
var filterFn = filterCustom.get(col.name);
holder[0] = holder[0].and((root, query, cb) -> {
Predicate p = filterFn.apply(root, query, cb, value);
return p != null ? p : cb.conjunction();
});
}
// 3) Orden:
// - Para campos “simples” (no custom): con Sort (Spring)
// - Para campos “custom” (virtuales/expresiones): query.orderBy(...) dentro de
// una spec
Sort sort = Sort.unsorted();
List<Sort.Order> simpleOrders = new ArrayList<>();
boolean customApplied = false;
if (!dt.order.isEmpty() && !dt.columns.isEmpty()) {
List<Sort.Order> orders = new ArrayList<>();
for (var o : dt.order) {
var col = dt.columns.get(o.column);
String field = col != null ? col.name : null;
if (col == null)
continue;
String field = col.name;
if (field == null || field.isBlank())
continue;
if (!col.orderable)
continue;
if (!getOrderable().contains(field))
continue; // << usa tu whitelist
continue;
orders.add(new Sort.Order(
"desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC,
field));
}
if (!orders.isEmpty()) {
sort = Sort.by(orders);
} else {
for (var c : dt.columns) {
if (c != null && c.orderable && c.name != null && !c.name.isBlank()
&& getOrderable().contains(c.name)) {
sort = Sort.by(c.name);
break;
}
if (orderCustom.containsKey(field)) {
final boolean asc = !"desc".equalsIgnoreCase(o.dir);
final FieldOrder<T> orderFn = orderCustom.get(field);
// aplica el ORDER BY custom dentro de la Specification (con Criteria)
holder[0] = holder[0].and((root, query, cb) -> {
Expression<?> expr = orderFn.apply(root, query, cb);
if (expr != null) {
query.orderBy(asc ? cb.asc(expr) : cb.desc(expr));
}
return cb.conjunction();
});
customApplied = true;
} else {
// orden simple por nombre de propiedad real
simpleOrders.add(new Sort.Order(
"desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC,
field));
}
}
}
// Page
if (!simpleOrders.isEmpty()) {
sort = Sort.by(simpleOrders);
}
// 4) Paginación (Sort para simples; custom order ya va dentro de la spec)
int page = dt.length > 0 ? dt.start / dt.length : 0;
Pageable pageable = dt.length > 0 ? PageRequest.of(page, dt.length, sort) : Pageable.unpaged();
var p = repo.findAll(holder[0], pageable);
long filtered = p.getTotalElements();
// Mapear entidad -> Map base (via Jackson) + add/edit
// 5) Mapeo a Map + add/edit
List<Map<String, Object>> data = new ArrayList<>();
for (T e : p.getContent()) {
Map<String, Object> row = om.convertValue(e, Map.class);
row.put("__entity", e); // para editores que necesiten la entidad
Map<String, Object> row;
if (onlyAdded) {
row = new HashMap<>();
} else {
try {
row = om.convertValue(e, Map.class);
} catch (IllegalArgumentException ex) {
row = new HashMap<>();
}
}
row.put("__entity", e);
for (var ad : adders)
row.putAll(ad.apply(e));
for (var ed : editors)
@ -157,6 +254,12 @@ public class DataTable<T> {
row.remove("__entity");
data.add(row);
}
return new DataTablesResponse<>(dt.draw, totalCount, filtered, data);
}
}
private Predicate nullSafePredicate(CriteriaBuilder cb) {
// Devuelve conjunción para no interferir con los demás predicados
return cb.conjunction();
}
}

View File

@ -23,9 +23,15 @@ public class DataTablesSpecification {
DataTablesRequest.Column col = dt.columns.get(i);
if (col.searchable && col.search != null && col.search.value != null && !col.search.value.isEmpty()) {
try {
ands.add(like(cb, root.get(col.name), col.search.value));
Path<?> path = root;
String[] parts = col.name.split("\\.");
for (String part : parts) {
path = path.get(part);
}
ands.add(like(cb, path, col.search.value));
} catch (IllegalArgumentException ex) {
// columna no mapeada o relación: la ignoramos
//System.out.println("[DT] columna no mapeada o relación: " + col.name);
}
}
}

View File

@ -1,6 +1,7 @@
package com.imprimelibros.erp.externalApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
@ -13,13 +14,14 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuestoDao;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.function.Supplier;
import java.util.Locale;
@Service
public class skApiClient {
@ -30,11 +32,13 @@ public class skApiClient {
private final AuthService authService;
private final RestTemplate restTemplate;
private final MargenPresupuestoDao margenPresupuestoDao;
private final MessageSource messageSource;
public skApiClient(AuthService authService, MargenPresupuestoDao margenPresupuestoDao) {
public skApiClient(AuthService authService, MargenPresupuestoDao margenPresupuestoDao, MessageSource messageSource) {
this.authService = authService;
this.restTemplate = new RestTemplate();
this.margenPresupuestoDao = margenPresupuestoDao;
this.messageSource = messageSource;
}
public String getPrice(Map<String, Object> requestBody, TipoEncuadernacion tipoEncuadernacion,
@ -90,7 +94,7 @@ public class skApiClient {
margen.getMargenMax(),
margen.getMargenMin());
double nuevoPrecio = precios.get(i) * (1 + margenValue / 100.0);
precios.set(i, nuevoPrecio);
precios.set(i, Math.round(nuevoPrecio * 10000.0) / 10000.0); // redondear a 2 decimales
} else {
System.out.println("No se encontró margen para tirada " + tirada);
}
@ -113,7 +117,7 @@ public class skApiClient {
});
}
public Integer getMaxSolapas(Map<String, Object> requestBody) {
public Integer getMaxSolapas(Map<String, Object> requestBody, Locale locale) {
try {
String jsonResponse = performWithRetry(() -> {
String url = this.skApiUrl + "api/calcular-solapas";
@ -150,7 +154,7 @@ public class skApiClient {
JsonNode root = mapper.readTree(jsonResponse);
if (root.get("data") == null || !root.get("data").isInt()) {
throw new RuntimeException("Respuesta inesperada de calcular-solapas: " + jsonResponse);
throw new RuntimeException(messageSource.getMessage("presupuesto.errores.error-interior", new Object[]{1} , locale));
}
return root.get("data").asInt();

View File

@ -35,19 +35,23 @@ public class HomeController {
"presupuesto.plantilla-cubierta",
"presupuesto.plantilla-cubierta-text",
"presupuesto.impresion-cubierta",
"presupuesto.impresion-cubierta-help");
"presupuesto.impresion-cubierta-help",
"presupuesto.iva-reducido",
"presupuesto.iva-reducido-descripcion");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
model.addAttribute("pod", variableService.getValorEntero("POD"));
model.addAttribute("ancho_alto_min", variableService.getValorEntero("ancho_alto_min"));
model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
model.addAttribute("appMode", "public");
}
else{
// empty translations for authenticated users
Map<String, String> translations = Map.of();
model.addAttribute("languageBundle", translations);
}
return "imprimelibros/home";
return "imprimelibros/home/home";
}
}

View File

@ -4,9 +4,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoImpresion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoImpresion;
import java.util.*;

View File

@ -0,0 +1,11 @@
package com.imprimelibros.erp.pdf;
import java.util.Locale;
import java.util.Map;
public record DocumentSpec(
DocumentType type,
String templateId, // p.ej. "presupuesto-a4"
Locale locale,
Map<String, Object> model // data del documento
) {}

View File

@ -0,0 +1,10 @@
package com.imprimelibros.erp.pdf;
public enum DocumentType {
PRESUPUESTO, PEDIDO, FACTURA;
@Override
public String toString() {
return name().toLowerCase();
}
}

View File

@ -0,0 +1,47 @@
package com.imprimelibros.erp.pdf;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ContentDisposition;
import java.util.Locale;
@RestController
@RequestMapping("/api/pdf")
public class PdfController {
private final PdfService pdfService;
public PdfController(PdfService pdfService) {
this.pdfService = pdfService;
}
@GetMapping(value = "/{type}/{id}", produces = "application/pdf")
public ResponseEntity<byte[]> generate(
@PathVariable("type") String type,
@PathVariable String id,
@RequestParam(defaultValue = "inline") String mode,
Locale locale) {
if (type.equals(DocumentType.PRESUPUESTO.toString()) && id == null) {
throw new IllegalArgumentException("Falta el ID del presupuesto para generar el PDF");
}
if (type.equals(DocumentType.PRESUPUESTO.toString())) {
Long presupuestoId = Long.valueOf(id);
byte[] pdf = pdfService.generaPresupuesto(presupuestoId, locale);
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDisposition(
("download".equals(mode)
? ContentDisposition.attachment()
: ContentDisposition.inline()).filename("presupuesto-" + id + ".pdf").build());
return new ResponseEntity<>(pdf, headers, HttpStatus.OK);
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
}
}

View File

@ -0,0 +1,15 @@
// com.imprimelibros.erp.pdf.PdfModuleConfig.java
package com.imprimelibros.erp.pdf;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.HashMap;
import java.util.Map;
@ConfigurationProperties(prefix = "imprimelibros.pdf")
public class PdfModuleConfig {
private Map<String, String> templates = new HashMap<>();
public Map<String, String> getTemplates() { return templates; }
public void setTemplates(Map<String, String> templates) { this.templates = templates; }
}

View File

@ -0,0 +1,44 @@
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;
import java.io.ByteArrayOutputStream;
@Service
public class PdfRenderer {
@Value("classpath:/static/")
private org.springframework.core.io.Resource staticRoot;
public byte[] renderHtmlToPdf(String html) {
try (var baos = new ByteArrayOutputStream()) {
var builder = new com.openhtmltopdf.pdfboxout.PdfRendererBuilder();
builder.useFastMode();
// 👇 Base URL para que pueda resolver /assets/css/ y /img/
builder.withHtmlContent(html, staticRoot.getURL().toString()); // .../target/classes/static/
// (Opcional) Registrar fuentes TTF
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Regular.ttf"),
"Open Sans", 400, BaseRendererBuilder.FontStyle.NORMAL, true);
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-SemiBold.ttf"),
"Open Sans", 600, BaseRendererBuilder.FontStyle.NORMAL, true);
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Bold.ttf"),
"Open Sans", 700, BaseRendererBuilder.FontStyle.NORMAL, true);
builder.toStream(baos);
builder.run();
return baos.toByteArray();
} catch (Exception e) {
throw new IllegalStateException("Error generando PDF", e);
}
}
}

View File

@ -0,0 +1,205 @@
package com.imprimelibros.erp.pdf;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.common.Utils;
@Service
public class PdfService {
private final TemplateRegistry registry;
private final PdfTemplateEngine engine;
private final PdfRenderer renderer;
private final PresupuestoRepository presupuestoRepository;
private final Utils utils;
private final Map<String, String> empresa = Map.of(
"nombre", "ImprimeLibros ERP",
"direccion", "C/ Dirección 123, 28000 Madrid",
"telefono", "+34 600 000 000",
"email", "info@imprimelibros.com",
"cif", "B-12345678",
"cp", "28000",
"poblacion", "Madrid",
"web", "www.imprimelibros.com");
private static class PrecioTirada {
private Double peso;
@JsonProperty("iva_importe_4")
private Double ivaImporte4;
@JsonProperty("total_con_iva")
private Double totalConIva;
@JsonProperty("base_imponible")
private Double baseImponible;
@JsonProperty("iva_importe_21")
private Double ivaImporte21;
@JsonProperty("precio_unitario")
private Double precioUnitario;
@JsonProperty("servicios_total")
private Double serviciosTotal;
@JsonProperty("precio_total_tirada")
private Double precioTotalTirada;
public Double getPeso() {
return peso;
}
public Double getIvaImporte4() {
return ivaImporte4;
}
public Double getTotalConIva() {
return totalConIva;
}
public Double getBaseImponible() {
return baseImponible;
}
public Double getIvaImporte21() {
return ivaImporte21;
}
public Double getPrecioUnitario() {
return precioUnitario;
}
public Double getServiciosTotal() {
return serviciosTotal;
}
public Double getPrecioTotalTirada() {
return precioTotalTirada;
}
}
public PdfService(TemplateRegistry registry, PdfTemplateEngine engine, PdfRenderer renderer,
PresupuestoRepository presupuestoRepository, Utils utils) {
this.registry = registry;
this.engine = engine;
this.renderer = renderer;
this.presupuestoRepository = presupuestoRepository;
this.utils = utils;
}
private byte[] generate(DocumentSpec spec) {
var template = registry.resolve(spec.type(), spec.templateId());
if (template == null) {
throw new IllegalArgumentException("Plantilla no registrada: " + spec.type() + ":" + spec.templateId());
}
var html = engine.render(template, spec.locale(), spec.model());
return renderer.renderHtmlToPdf(html);
}
public byte[] generaPresupuesto(Long presupuestoId, Locale locale) {
try {
Presupuesto presupuesto = presupuestoRepository.findById(presupuestoId)
.orElseThrow(() -> new IllegalArgumentException("Presupuesto no encontrado: " + presupuestoId));
Map<String, Object> model = new HashMap<>();
model.put("numero", presupuesto.getId());
model.put("fecha", presupuesto.getUpdatedAt());
model.put("empresa", empresa);
model.put("cliente", Map.of(
"nombre", presupuesto.getUser() != null ? presupuesto.getUser().getFullName() : ""));
model.put("titulo", presupuesto.getTitulo());
/*
* Map<String, Object> resumen = presupuestoService.getTextosResumen(
* presupuesto, null, model, model, null)
*/
model.put("lineas", List.of(
Map.of("descripcion", "Impresión interior B/N offset 80 g",
"meta", "300 páginas · tinta negra · papel 80 g",
"uds", 1000,
"precio", 2.15,
"dto", 0,
"importe", 2150.0),
Map.of("descripcion", "Cubierta color 300 g laminado mate",
"meta", "Lomo 15 mm · 4/0 · laminado mate",
"uds", 1000,
"precio", 0.38,
"dto", 5.0,
"importe", 361.0)));
model.put("servicios", List.of(
Map.of("descripcion", "Transporte península", "unidades", 1, "precio", 90.00)));
Map<String, Object> specs = utils.getTextoPresupuesto(presupuesto, locale);
model.put("specs", specs);
Map<String, Object> pricing = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();
// Si quieres parsear directamente a un Map:
Map<Integer, PrecioTirada> snapshot = mapper.readValue(presupuesto.getPricingSnapshotJson(),
mapper.getTypeFactory().constructMapType(Map.class, Integer.class, PrecioTirada.class));
List<Integer> tiradas = snapshot.keySet().stream().toList();
pricing.put("tiradas", tiradas);
pricing.put("impresion", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getPrecioTotalTirada(), locale))
.toList());
pricing.put("servicios", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getServiciosTotal(), locale))
.toList());
pricing.put("peso", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getPeso(), locale))
.toList());
pricing.put("iva_4", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getIvaImporte4(), locale))
.toList());
pricing.put("iva_21", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getIvaImporte21(), locale))
.toList());
pricing.put("total", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getTotalConIva(), locale))
.toList());
pricing.put("show_iva_4", presupuesto.getIvaImporte4().floatValue() > 0);
pricing.put("show_iva_21", presupuesto.getIvaImporte21().floatValue() > 0);
model.put("pricing", pricing);
var spec = new DocumentSpec(
DocumentType.PRESUPUESTO,
"presupuesto-a4",
Locale.forLanguageTag("es-ES"),
model);
byte[] pdf = this.generate(spec);
// HTML
// (Opcional) generar HTML de depuración con CSS incrustado
try {
String templateName = registry.resolve(DocumentType.PRESUPUESTO, "presupuesto-a4");
String html = engine.render(templateName, Locale.forLanguageTag("es-ES"), model);
String css = Files.readString(Path.of("src/main/resources/static/assets/css/presupuestopdf.css"));
String htmlWithCss = html.replaceFirst("(?i)</head>", "<style>\n" + css + "\n</style>\n</head>");
Path htmlPath = Path.of("target/presupuesto-test.html");
Files.writeString(htmlPath, htmlWithCss, StandardCharsets.UTF_8);
} catch (Exception ignore) {
/* solo para depuración */ }
return pdf;
} catch (Exception e) {
throw new RuntimeException("Error generando presupuesto PDF", e);
}
}
}

View File

@ -0,0 +1,22 @@
package com.imprimelibros.erp.pdf;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.springframework.stereotype.Service;
import java.util.Locale;
import java.util.Map;
@Service
public class PdfTemplateEngine {
private final SpringTemplateEngine thymeleaf;
public PdfTemplateEngine(SpringTemplateEngine thymeleaf) {
this.thymeleaf = thymeleaf;
}
public String render(String templateName, Locale locale, Map<String,Object> model) {
Context ctx = new Context(locale);
if (model != null) model.forEach(ctx::setVariable);
return thymeleaf.process(templateName, ctx);
}
}

View File

@ -0,0 +1,21 @@
package com.imprimelibros.erp.pdf;
import org.springframework.stereotype.Service;
@Service
public class TemplateRegistry {
private final PdfModuleConfig config;
public TemplateRegistry(PdfModuleConfig config) {
this.config = config;
}
public String resolve(DocumentType type, String templateId) {
String key = type.name() + ":" + templateId;
String keyAlt = type.name() + "_" + templateId; // compatibilidad con properties
if (config.getTemplates() == null) return null;
String value = config.getTemplates().get(key);
if (value == null) value = config.getTemplates().get(keyAlt);
return value;
}
}

View File

@ -0,0 +1,37 @@
package com.imprimelibros.erp.presupuesto;
import java.util.Optional;
public interface GeoIpService {
class GeoData {
public final String pais;
public final String region;
public final String ciudad;
public GeoData(String pais, String region, String ciudad) {
this.pais = pais;
this.region = region;
this.ciudad = ciudad;
}
public String getPais() {
return pais;
}
public String getRegion() {
return region;
}
public String getCiudad() {
return ciudad;
}
}
/**
* @param ip Ip original (no anonimizada) - la implementación debe manejar IPv4/IPv6.
* @return GeoData si se pudo resolver; Optional.empty() en caso de error o IP privada.
*/
Optional<GeoData> lookup(String ip);
}

View File

@ -1,40 +1,68 @@
package com.imprimelibros.erp.presupuesto;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import java.time.Instant;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import jakarta.validation.Validator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.datatables.*;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.presupuesto.classes.ImagenPresupuesto;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMaquetacion;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMarcapaginas;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.presupuesto.validation.PresupuestoValidationGroups;
import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.UserDetailsImpl;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto;
import com.imprimelibros.erp.common.web.IpUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Valid;
@Controller
@RequestMapping("/presupuesto")
public class PresupuestoController {
private final PresupuestoRepository presupuestoRepository;
@Autowired
protected PresupuestoService presupuestoService;
@ -44,10 +72,26 @@ public class PresupuestoController {
@Autowired
protected MessageSource messageSource;
private final ObjectMapper objectMapper;
@Autowired
private Validator validator;
public PresupuestoController(ObjectMapper objectMapper) {
private final ObjectMapper objectMapper;
private final TranslationService translationService;
private final PresupuestoDatatableService dtService;
private final VariableService variableService;
private final PresupuestoFormDataMapper formDataMapper;
private final UserDao userRepo;
public PresupuestoController(ObjectMapper objectMapper, TranslationService translationService,
PresupuestoDatatableService dtService, PresupuestoRepository presupuestoRepository,
VariableService variableService, PresupuestoFormDataMapper formDataMapper, UserDao userRepo) {
this.objectMapper = objectMapper;
this.translationService = translationService;
this.dtService = dtService;
this.presupuestoRepository = presupuestoRepository;
this.variableService = variableService;
this.formDataMapper = formDataMapper;
this.userRepo = userRepo;
}
@PostMapping("/public/validar/datos-generales")
@ -57,12 +101,14 @@ public class PresupuestoController {
Map<String, String> errores = new HashMap<>();
// errores de campos individuales
result.getFieldErrors().forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
// errores globales (@ConsistentTiradas...)
result.getGlobalErrors().forEach(error -> errores.put("global", error.getDefaultMessage()));
result.getFieldErrors().forEach(error -> {
String code = Objects.requireNonNullElse(error.getDefaultMessage(), "").replace("{", "").replace("}", "");
String msg = messageSource.getMessage(code, null, locale);
errores.put(error.getField(), msg);
});
result.getGlobalErrors().forEach(error -> {
errores.put("global", error.getDefaultMessage());
});
if (!errores.isEmpty()) {
return ResponseEntity.badRequest().body(errores);
}
@ -86,7 +132,11 @@ public class PresupuestoController {
Map<String, String> errores = new HashMap<>();
// errores de campos individuales
result.getFieldErrors().forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
result.getFieldErrors().forEach(error -> {
String code = Objects.requireNonNullElse(error.getDefaultMessage(), "").replace("{", "").replace("}", "");
String msg = messageSource.getMessage(code, null, locale);
errores.put(error.getField(), msg);
});
// errores globales (@ConsistentTiradas...)
result.getGlobalErrors().forEach(error -> errores.put("global", error.getDefaultMessage()));
@ -95,7 +145,7 @@ public class PresupuestoController {
return ResponseEntity.badRequest().body(errores);
}
Map<String, Object> resultado = new HashMap<>();
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto)));
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale));
resultado.putAll(presupuestoService.obtenerOpcionesAcabadosCubierta(presupuesto, locale));
return ResponseEntity.ok(resultado);
}
@ -110,7 +160,11 @@ public class PresupuestoController {
Map<String, String> errores = new HashMap<>();
// errores de campos individuales
result.getFieldErrors().forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
result.getFieldErrors().forEach(error -> {
String code = Objects.requireNonNullElse(error.getDefaultMessage(), "").replace("{", "").replace("}", "");
String msg = messageSource.getMessage(code, null, locale);
errores.put(error.getField(), msg);
});
// errores globales (@ConsistentTiradas...)
result.getGlobalErrors().forEach(error -> errores.put("global", error.getDefaultMessage()));
@ -141,7 +195,11 @@ public class PresupuestoController {
Map<String, String> errores = new HashMap<>();
// errores de campos individuales
result.getFieldErrors().forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
result.getFieldErrors().forEach(error -> {
String code = Objects.requireNonNullElse(error.getDefaultMessage(), "").replace("{", "").replace("}", "");
String msg = messageSource.getMessage(code, null, locale);
errores.put(error.getField(), msg);
});
// errores globales (@ConsistentTiradas...)
result.getGlobalErrors().forEach(error -> errores.put("global", error.getDefaultMessage()));
@ -168,7 +226,11 @@ public class PresupuestoController {
Map<String, String> errores = new HashMap<>();
// errores de campos individuales
result.getFieldErrors().forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
result.getFieldErrors().forEach(error -> {
String code = Objects.requireNonNullElse(error.getDefaultMessage(), "").replace("{", "").replace("}", "");
String msg = messageSource.getMessage(code, null, locale);
errores.put(error.getField(), msg);
});
if (!errores.isEmpty()) {
return ResponseEntity.badRequest().body(errores);
@ -180,7 +242,19 @@ public class PresupuestoController {
// opciones gramaje interior
resultado.putAll(presupuestoService.obtenerOpcionesGramajeInterior(presupuesto));
List<String> opciones = new ObjectMapper().convertValue(resultado.get("opciones_papel_interior"),
List<ImagenPresupuesto> opciones_papel = new ObjectMapper().convertValue(
presupuestoService
.obtenerOpcionesPapelInterior(presupuesto, locale)
.get("opciones_papel_interior"),
new TypeReference<List<ImagenPresupuesto>>() {
});
if (opciones_papel != null && opciones_papel.stream().noneMatch(
o -> o.getExtra_data().get("sk-id").equals(String.valueOf(presupuesto.getPapelInteriorId())))) {
presupuesto.setPapelInteriorId(Integer.valueOf(opciones_papel.get(0).getExtra_data().get("sk-id")));
}
List<String> opciones = new ObjectMapper().convertValue(resultado.get("opciones_gramaje_interior"),
new TypeReference<List<String>>() {
});
@ -191,19 +265,23 @@ public class PresupuestoController {
}
}
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto)));
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale));
return ResponseEntity.ok(resultado);
}
@PostMapping("/public/get-gramaje-interior")
public ResponseEntity<?> getGramajeInterior(
@Validated(PresupuestoValidationGroups.Interior.class) Presupuesto presupuesto,
BindingResult result) {
BindingResult result, Locale locale) {
Map<String, String> errores = new HashMap<>();
// errores de campos individuales
result.getFieldErrors().forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
result.getFieldErrors().forEach(error -> {
String code = Objects.requireNonNullElse(error.getDefaultMessage(), "").replace("{", "").replace("}", "");
String msg = messageSource.getMessage(code, null, locale);
errores.put(error.getField(), msg);
});
if (!errores.isEmpty()) {
return ResponseEntity.badRequest().body(errores);
@ -220,26 +298,30 @@ public class PresupuestoController {
presupuesto.setGramajeInterior(Integer.parseInt(opciones.get(0))); // Asignar primera opción
}
}
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto)));
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale));
return ResponseEntity.ok(resultado);
}
@PostMapping("/public/get-max-solapas")
public ResponseEntity<?> getMaxSolapas(
@Validated(PresupuestoValidationGroups.Interior.class) Presupuesto presupuesto,
BindingResult result) {
BindingResult result, Locale locale) {
Map<String, String> errores = new HashMap<>();
// errores de campos individuales
result.getFieldErrors().forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
result.getFieldErrors().forEach(error -> {
String code = Objects.requireNonNullElse(error.getDefaultMessage(), "").replace("{", "").replace("}", "");
String msg = messageSource.getMessage(code, null, locale);
errores.put(error.getField(), msg);
});
if (!errores.isEmpty()) {
return ResponseEntity.badRequest().body(errores);
}
Map<String, Object> resultado = new HashMap<>();
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto)));
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale));
return ResponseEntity.ok(resultado);
}
@ -388,13 +470,298 @@ public class PresupuestoController {
// Se hace un post para no tener problemas con la longitud de la URL
@PostMapping("/public/resumen")
public ResponseEntity<?> getResumen(@RequestBody Map<String, Object> body, Locale locale) {
Presupuesto p = objectMapper.convertValue(body.get("presupuesto"), Presupuesto.class);
public ResponseEntity<?> getResumen(
@RequestBody Map<String, Object> body,
Locale locale,
HttpServletRequest request) {
Presupuesto p = objectMapper.convertValue(body.get("presupuesto"), Presupuesto.class);
Boolean save = objectMapper.convertValue(body.get("save"), Boolean.class);
String mode = objectMapper.convertValue(body.get("mode"), String.class);
@SuppressWarnings("unchecked")
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
@SuppressWarnings("unchecked")
Map<String, Object> datosMaquetacion = (Map<String, Object>) objectMapper
.convertValue(body.get("datosMaquetacion"), Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> datosMarcapaginas = (Map<String, Object>) objectMapper
.convertValue(body.get("datosMarcapaginas"), Map.class);
return ResponseEntity.ok(presupuestoService.getResumen(p, serviciosList, locale));
String sessionId = request.getSession(true).getId();
String ip = IpUtils.getClientIp(request);
var resumen = presupuestoService.getResumen(p, serviciosList, datosMaquetacion, datosMarcapaginas, save, mode, locale, sessionId, ip);
return ResponseEntity.ok(resumen);
}
// =============================================
// MÉTODOS PARA USUARIOS AUTENTICADOS
// =============================================
@GetMapping
public String getPresupuestoList(Model model, Authentication authentication, Locale locale) {
List<String> keys = List.of(
"presupuesto.delete.title",
"presupuesto.delete.text",
"presupuesto.eliminar",
"presupuesto.delete.button",
"app.yes",
"app.cancelar",
"presupuesto.delete.ok.title",
"presupuesto.delete.ok.text",
"presupuesto.add.tipo",
"presupuesto.add.anonimo",
"presupuesto.add.cliente",
"presupuesto.add.next",
"presupuesto.add.cancel",
"presupuesto.add.select-client",
"presupuesto.add.error.options",
"presupuesto.add.error.options-client");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/presupuestos/presupuesto-list";
}
@GetMapping(value = { "/edit/{id}", "/view/{id}" })
public String getPresupuestoEditForm(
@PathVariable(name = "id", required = true) Long id,
RedirectAttributes redirectAttributes,
Model model,
Authentication authentication,
Locale locale) {
List<String> keys = List.of(
"presupuesto.plantilla-cubierta",
"presupuesto.plantilla-cubierta-text",
"presupuesto.impresion-cubierta",
"presupuesto.impresion-cubierta-help",
"presupuesto.exito.guardado",
"presupuesto.add.error.save.title",
"presupuesto.iva-reducido",
"presupuesto.iva-reducido-descripcion");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
model.addAttribute("pod", variableService.getValorEntero("POD"));
model.addAttribute("ancho_alto_min", variableService.getValorEntero("ancho_alto_min"));
model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
// Buscar el presupuesto
Optional<Presupuesto> presupuestoOpt = presupuestoRepository.findById(id);
if (presupuestoOpt.isEmpty()) {
// Añadir mensaje flash para mostrar alerta
redirectAttributes.addFlashAttribute("errorMessage",
messageSource.getMessage("presupuesto.errores.presupuesto-no-existe", new Object[] { id }, locale));
// Redirigir a la vista de lista
return "redirect:/presupuesto";
}
if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) {
// Añadir mensaje flash para mostrar alerta
redirectAttributes.addFlashAttribute("errorMessage",
messageSource.getMessage("app.errors.403", null, locale));
// Redirigir a la vista de lista
return "redirect:/presupuesto";
}
model.addAttribute("presupuesto_id", presupuestoOpt.get().getId());
String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest().getRequestURI();
String mode = path.contains("/view/") ? "view" : "edit";
if (mode.equals("view")) {
model.addAttribute("appMode", "view");
} else {
model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId());
model.addAttribute("appMode", "edit");
}
model.addAttribute("id", presupuestoOpt.get().getId());
return "imprimelibros/presupuestos/presupuesto-form";
}
@GetMapping(value = { "/add/{mode}", "/add/{mode}/{cliente_id}", "/add2/{cliente_id}" })
public String getPresupuestoEditForm(
@PathVariable(name = "mode", required = false) String mode,
@PathVariable(name = "cliente_id", required = false) Long clienteId,
RedirectAttributes redirectAttributes,
Model model,
Authentication authentication,
Locale locale) {
List<String> keys = List.of(
"presupuesto.plantilla-cubierta",
"presupuesto.plantilla-cubierta-text",
"presupuesto.impresion-cubierta",
"presupuesto.impresion-cubierta-help",
"presupuesto.exito.guardado",
"presupuesto.add.error.save.title",
"presupuesto.iva-reducido",
"presupuesto.iva-reducido-descripcion");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
model.addAttribute("pod", variableService.getValorEntero("POD"));
model.addAttribute("ancho_alto_min", variableService.getValorEntero("ancho_alto_min"));
model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
model.addAttribute("appMode", "add");
if (!mode.equals("public")) {
model.addAttribute("cliente_id", clienteId);
}
model.addAttribute("mode", mode);
return "imprimelibros/presupuestos/presupuesto-form";
}
@GetMapping(value = "/datatable/{tipo}", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatable(
HttpServletRequest request, Authentication auth, Locale locale,
@PathVariable("tipo") String tipo) {
DataTablesRequest dt = DataTablesParser.from(request);
if ("anonimos".equals(tipo)) {
return dtService.datatablePublicos(dt, locale);
} else if ("clientes".equals(tipo)) {
return dtService.datatablePrivados(dt, locale);
} else {
throw new IllegalArgumentException("Tipo de datatable no válido");
}
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
Presupuesto p = presupuestoRepository.findById(id).orElse(null);
if (p == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", messageSource.getMessage("presupuesto.error.not-found", null, locale)));
}
boolean isUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
Long ownerId = p.getUser() != null ? p.getUser().getId() : null;
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);
}
boolean isOwner = ownerId != null && ownerId.equals(currentUserId);
if (isUser && !isOwner) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.delete-permission-denied", null, locale)));
}
if (p.getEstado() != null && !p.getEstado().equals(Presupuesto.Estado.borrador)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.delete-not-draft", null, locale)));
}
try {
p.setDeleted(true);
p.setDeletedAt(Instant.now());
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
p.setDeletedBy(userRepo.getReferenceById(udi.getId()));
} else if (auth != null) {
userRepo.findByUserNameIgnoreCase(auth.getName()).ifPresent(p::setDeletedBy);
}
presupuestoRepository.saveAndFlush(p);
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("presupuesto.exito.eliminado", null, locale)));
} catch (Exception ex) {
// Devuelve SIEMPRE algo en el catch
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.delete-internal-error", null, locale),
"detail",
ex.getClass().getSimpleName() + ": " + (ex.getMessage() != null ? ex.getMessage() : "")));
}
}
@GetMapping(value = "/api/get", produces = "application/json")
public ResponseEntity<PresupuestoFormDataDto> getPresupuesto(
@RequestParam("id") Long id, Authentication authentication) {
Optional<Presupuesto> presupuestoOpt = presupuestoRepository.findById(id);
if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) {
return ResponseEntity.status(403).build();
}
if (presupuestoOpt.isPresent()) {
PresupuestoFormDataDto vm = formDataMapper.toFormData(presupuestoOpt.get());
return ResponseEntity.ok(vm);
} else {
return ResponseEntity.notFound().build();
}
}
@PostMapping(path = "/api/save")
public ResponseEntity<?> save(
@RequestBody Map<String, Object> body,
Locale locale, HttpServletRequest request) {
Presupuesto presupuesto = objectMapper.convertValue(body.get("presupuesto"), Presupuesto.class);
Long id = objectMapper.convertValue(body.get("id"), Long.class);
String mode = objectMapper.convertValue(body.get("mode"), String.class);
@SuppressWarnings("unchecked")
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
Long cliente_id = objectMapper.convertValue(body.get("cliente_id"), Long.class);
Map<String, Object> datosMaquetacion = (Map<String, Object>) objectMapper
.convertValue(body.get("datosMaquetacion"), Map.class);
Map<String, Object> datosMarcapaginas = (Map<String, Object>) objectMapper
.convertValue(body.get("datosMarcapaginas"), Map.class);
Set<ConstraintViolation<Presupuesto>> violations = validator.validate(presupuesto,
PresupuestoValidationGroups.All.class);
if (!violations.isEmpty()) {
Map<String, String> errores = new HashMap<>();
for (ConstraintViolation<Presupuesto> v : violations) {
String campo = v.getPropertyPath().toString();
String mensaje = messageSource.getMessage(v.getMessage().replace("{", "").replace("}", ""), null,
locale);
errores.put(campo, mensaje);
}
return ResponseEntity.badRequest().body(errores);
}
try {
Map<String, Object> saveResult = presupuestoService.guardarPresupuesto(
presupuesto,
serviciosList,
datosMaquetacion,
datosMarcapaginas,
mode,
cliente_id,
id,
request,
locale);
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.save-internal-error", null, locale),
"detail",
ex.getClass().getSimpleName() + ": " + (ex.getMessage() != null ? ex.getMessage() : "")));
}
}
}

View File

@ -0,0 +1,118 @@
package com.imprimelibros.erp.presupuesto;
import com.imprimelibros.erp.common.Utils;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Service
public class PresupuestoDatatableService {
private final MessageSource messageSource;
private final PresupuestoRepository repo;
public PresupuestoDatatableService(MessageSource messageSource, PresupuestoRepository repo) {
this.messageSource = messageSource;
this.repo = repo;
}
@Transactional(readOnly = true)
public DataTablesResponse<Map<String, Object>> datatablePublicos(DataTablesRequest dt, Locale locale) {
return commonDataTable(dt, locale, "publico", true);
}
@Transactional(readOnly = true)
public DataTablesResponse<Map<String, Object>> datatablePrivados(DataTablesRequest dt, Locale locale) {
return commonDataTable(dt, locale, "privado", false);
}
private DataTablesResponse<Map<String, Object>> commonDataTable(DataTablesRequest dt, Locale locale, String origen,
boolean publico) {
Long count = repo.findAllByOrigen(Presupuesto.Origen.valueOf(origen)).stream().count();
List<String> orderable = List.of(
"id", "titulo", "user.fullName", "tipoEncuadernacion", "tipoCubierta", "tipoImpresion",
"selectedTirada", "estado", "totalConIva", "paginas", "pais", "region", "ciudad", "updatedAt");
return DataTable.of(repo, Presupuesto.class, dt,
List.of("")) // búsqueda global solo por campos simples
.orderable(orderable)
.where((root, query, cb) -> cb.equal(root.get("origen"), Presupuesto.Origen.valueOf(origen)))
.onlyAddedColumns()
.add("id", Presupuesto::getId)
.add("titulo", Presupuesto::getTitulo)
.add("tipoEncuadernacion", p -> msg(p.getTipoEncuadernacion().getMessageKey(), locale))
.add("tipoCubierta", p -> msg(p.getTipoCubierta().getMessageKey(), locale))
.add("tipoImpresion", p -> msg(p.getTipoImpresion().getMessageKey(), locale))
.add("selectedTirada", Presupuesto::getSelectedTirada)
.add("paginas", p -> n(p.getPaginasColor()) + n(p.getPaginasNegro()))
.filter("paginas", (root, q, cb, value) -> {
Expression<Integer> sum = cb.sum(
cb.coalesce(root.get("paginasColor"), cb.literal(0)),
cb.coalesce(root.get("paginasNegro"), cb.literal(0)));
Expression<String> asStr = cb.function("CONCAT", String.class, cb.literal(""), sum);
return cb.like(asStr, "%" + value.trim() + "%");
})
.orderable("paginas", (root, q, cb) -> cb.sum(
cb.coalesce(root.get("paginasColor"), cb.literal(0)),
cb.coalesce(root.get("paginasNegro"), cb.literal(0))))
.add("estado", p -> msg(p.getEstado().getMessageKey(), locale))
.add("totalConIva", p -> Utils.formatCurrency(p.getTotalConIva(), locale))
.addIf(publico, "pais", Presupuesto::getPais)
.addIf(publico, "region", Presupuesto::getRegion)
.addIf(publico, "ciudad", Presupuesto::getCiudad)
.add("updatedAt", p -> formatDate(p.getUpdatedAt(), locale))
.addIf(!publico, "user", p -> p.getUser() != null ? p.getUser().getFullName() : "")
.add("actions", this::generarBotones)
.toJson(count);
}
/* ---------- helpers ---------- */
private String msg(String key, Locale locale) {
try {
return messageSource.getMessage(key, null, locale);
} catch (Exception e) {
return key;
}
}
private int n(Integer v) {
return v == null ? 0 : v;
}
private String formatDate(Instant instant, Locale locale) {
if (instant == null)
return "";
ZoneId zone = (locale != null && locale.getCountry() != null && !locale.getCountry().isEmpty())
? TimeZone.getTimeZone(locale.toLanguageTag()).toZoneId()
: ZoneId.systemDefault();
var df = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(zone);
return df.format(instant);
}
private String generarBotones(Presupuesto p) {
boolean borrador = p.getEstado() == Presupuesto.Estado.borrador;
String id = String.valueOf(p.getId());
String editBtn = "<a href=\"javascript:void(0);\" data-id=\"" + id + "\" class=\"link-success btn-edit-" +
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado") + " fs-15\"><i class=\"ri-" +
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "eye" : "pencil") + "-line\"></i></a>";
String deleteBtn = borrador ? "<a href=\"javascript:void(0);\" data-id=\"" + id
+ "\" class=\"link-danger btn-delete-"
+ (p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado")
+ " fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>" : "";
return "<div class=\"hstack gap-3 flex-wrap\">" + editBtn + deleteBtn + "</div>";
}
}

View File

@ -0,0 +1,30 @@
package com.imprimelibros.erp.presupuesto;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import java.util.*;
@Repository
public interface PresupuestoRepository extends JpaRepository<Presupuesto, Long>, JpaSpecificationExecutor<Presupuesto> {
Optional<Presupuesto> findFirstBySessionIdAndOrigenAndEstadoInOrderByUpdatedAtDesc(
String sessionId,
Presupuesto.Origen origen,
Collection<Presupuesto.Estado> estados);
List<Presupuesto> findByOrigenAndEstado(Presupuesto.Origen origen, Presupuesto.Estado estado);
// Incluye borrados (ignora @Where) usando native
@Query(value = "SELECT * FROM presupuesto WHERE id = :id", nativeQuery = true)
Optional<Presupuesto> findAnyById(@Param("id") Long id);
Optional<Presupuesto> findTopBySessionIdAndEstadoOrderByCreatedAtDesc(String sessionId, Presupuesto.Estado estado);
List<Presupuesto> findAllByOrigen(Presupuesto.Origen origen);
}

View File

@ -3,7 +3,8 @@ package com.imprimelibros.erp.presupuesto.classes;
import org.springframework.stereotype.Component;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.presupuesto.Presupuesto;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import org.springframework.context.MessageSource;
import java.util.Locale;
@ -37,8 +38,8 @@ public class PresupuestoFormatter {
Object[] args = {
p.getSelectedTirada(),
encuadernacion,
tipoImpresion,
encuadernacion.toLowerCase(),
tipoImpresion.toLowerCase(),
(p.getPaginasColorTotal() != null ? p.getPaginasColorTotal() : p.getPaginasColor())
+ p.getPaginasNegro(),
p.getAncho(), p.getAlto(),

View File

@ -1,22 +1,42 @@
package com.imprimelibros.erp.presupuesto;
package com.imprimelibros.erp.presupuesto.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import com.imprimelibros.erp.presupuesto.validation.ConsistentTiradas;
import com.imprimelibros.erp.presupuesto.validation.PaginasCosidoGrapado;
import com.imprimelibros.erp.presupuesto.validation.Par;
import com.imprimelibros.erp.presupuesto.validation.PresupuestoValidationGroups;
import com.imprimelibros.erp.presupuesto.validation.Tamanio;
import com.imprimelibros.erp.common.HtmlStripConverter;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntity;
import jakarta.persistence.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import com.imprimelibros.erp.users.User;
@ConsistentTiradas(groups = PresupuestoValidationGroups.DatosGenerales.class)
@PaginasCosidoGrapado(groups = PresupuestoValidationGroups.DatosGenerales.class)
@Tamanio(groups = PresupuestoValidationGroups.DatosGenerales.class)
@EntityListeners(AuditingEntityListener.class)
@Entity
@Table(name = "presupuesto")
public class Presupuesto implements Cloneable{
@Table(name = "presupuesto", indexes = {
@Index(name = "idx_presupuesto_origen_estado", columnList = "origen, estado"),
@Index(name = "idx_presupuesto_session", columnList = "session_id"),
@Index(name = "idx_presupuesto_user", columnList = "user_id"),
@Index(name = "idx_presupuesto_deleted", columnList = "deleted"),
@Index(name = "idx_presupuesto_geo", columnList = "pais, region, ciudad")
})
@SQLDelete(sql = "UPDATE presupuesto SET deleted = 1, deleted_at = NOW(3) WHERE id = ?")
@SQLRestriction("deleted = 0")
public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
public enum TipoEncuadernacion {
fresado("presupuesto.fresado"),
@ -69,6 +89,38 @@ public class Presupuesto implements Cloneable{
}
}
public enum Origen {
publico, privado
}
public enum Estado {
borrador("presupuesto.estado.borrador"),
aceptado("presupuesto.estado.aceptado"),
modificado("presupuesto.estado.modificado");
private final String messageKey;
Estado(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
}
public enum Entrega{
peninsula("presupuesto.entrega.peninsula"),
canarias("presupuesto.entrega.canarias"),
paises_ue("presupuesto.entrega.paises-ue");
private final String messageKey;
Entrega(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
}
@Override
public Presupuesto clone() {
try {
@ -78,8 +130,100 @@ public class Presupuesto implements Cloneable{
}
}
// ====== NUEVOS: Origen/Estado/Usuario/Session/Geo/IP/Totales/JSONs ======
@Enumerated(EnumType.STRING)
@Column(name = "origen", nullable = false)
private Origen origen = Origen.publico;
@Enumerated(EnumType.STRING)
@Column(name = "estado", nullable = false)
private Estado estado = Estado.borrador;
// Usuario autenticado (nullable en público)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
// Continuidad en público
@Column(name = "session_id", length = 64)
private String sessionId;
@Column(name = "visitor_id", length = 64)
private String visitorId;
// IP anonimizada / truncada + geolocalización resumida (para estadísticas)
@Column(name = "ip_hash", length = 88) // SHA-256 base64 44 chars; dejamos margen
private String ipHash;
@Column(name = "ip_trunc", length = 64)
private String ipTrunc; // p.ej. "192.168.0.0" o "2a02:xxxx::"
@Column(name = "pais", length = 64)
private String pais;
@Column(name = "region", length = 128)
private String region;
@Column(name = "ciudad", length = 128)
private String ciudad;
// Totales de la tirada seleccionada y del presupuesto
@Column(name = "precio_unitario", precision = 12, scale = 4)
private BigDecimal precioUnitario;
@Column(name = "precio_total_tirada", precision = 12, scale = 2)
private BigDecimal precioTotalTirada;
@Column(name = "servicios_total", precision = 12, scale = 2)
private BigDecimal serviciosTotal;
@Column(name = "base_imponible", precision = 12, scale = 2)
private BigDecimal baseImponible;
@Column(name = "iva_reducido")
private Boolean ivaReducido;
@Column(name = "entrega_tipo")
@Enumerated(EnumType.STRING)
private Entrega entregaTipo;
@Column(name = "iva_importe_4", precision = 12, scale = 2)
private BigDecimal ivaImporte4;
@Column(name = "iva_importe_21", precision = 12, scale = 2)
private BigDecimal ivaImporte21;
@Column(name = "total_con_iva", precision = 12, scale = 2)
private BigDecimal totalConIva;
// JSONs de apoyo (todas las tiradas, servicios, bloques de
// maquetación/marcapáginas y snapshot)
@Lob
@Column(name = "precios_por_tirada_json", columnDefinition = "json")
private String preciosPorTiradaJson; // [{tirada, precio_unitario, precio_total_tirada}, ...]
@Lob
@Column(name = "servicios_json", columnDefinition = "json")
private String serviciosJson;
@Lob
@Column(name = "datos_maquetacion_json", columnDefinition = "json")
private String datosMaquetacionJson;
@Lob
@Column(name = "datos_marcapaginas_json", columnDefinition = "json")
private String datosMarcapaginasJson;
@Lob
@Column(name = "pricing_snapshot", columnDefinition = "json")
private String pricingSnapshotJson;
// ====== TUS CAMPOS ORIGINALES ======
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@NotNull(message = "{presupuesto.errores.tipo-encuadernacion}", groups = PresupuestoValidationGroups.DatosGenerales.class)
@ -192,39 +336,270 @@ public class Presupuesto implements Cloneable{
@NotNull(message = "{presupuesto.errores.acabado-cubierta}", groups = PresupuestoValidationGroups.Cubierta.class)
@Column(name = "acabado")
private Integer acabado = 1;
private Integer acabado = 1;
@Column(name = "sobrecubierta")
private Boolean sobrecubierta = false;
@Column(name = "papel_sobrecubierta_id")
private Integer papelSobrecubiertaId = 2;
@Column(name = "gramaje_sobrecubierta")
private Integer gramajeSobrecubierta = 170;
@Column(name = "tamanio_solapas_sobrecubierta")
private Integer tamanioSolapasSobrecubierta = 80;
@Column(name = "acabado_sobrecubierta")
private Integer acabadoSobrecubierta = 0; // 0: sin acabado,
private Integer acabadoSobrecubierta = 0;
@Column(name = "faja")
private Boolean faja = false;
@Column(name = "papel_faja_id")
private Integer papelFajaId = 2;
@Column(name = "gramaje_faja")
private Integer gramajeFaja = 170;
@Column(name = "tamanio_solapas_faja")
private Integer tamanioSolapasFaja = 80;
@Column(name = "acabado_faja")
private Integer acabadoFaja = 0; // 0: sin acabado
private Integer acabadoFaja = 0;
@Column(name = "alto_faja")
private Integer altoFaja = 0;
@Column(name = "presupuesto_maquetacion")
private Boolean presupuestoMaquetacion = false;
@Column(name = "presupuesto_maquetacion_data")
private String presupuestoMaquetacionData;
// ====== MÉTODOS AUX ======
public String resumenPresupuesto() {
return String.format("%s - %s - %dx%d mm - %d Páginas (N:%d C:%d) - Tira:%d",
this.titulo,
this.tipoEncuadernacion,
this.ancho,
this.alto,
(this.paginasNegro != null ? this.paginasNegro : 0)
+ (this.paginasColorTotal != null ? this.paginasColorTotal : 0),
this.paginasNegro != null ? this.paginasNegro : 0,
this.paginasColorTotal != null ? this.paginasColorTotal : 0,
this.selectedTirada != null ? this.selectedTirada : 0);
}
public Integer[] getTiradas() {
return new Integer[] { tirada1, tirada2, tirada3, tirada4 };
}
// ====== GETTERS/SETTERS (incluye nuevos y existentes) ======
public Origen getOrigen() {
return origen;
}
public void setOrigen(Origen origen) {
this.origen = origen;
}
public Estado getEstado() {
return estado;
}
public void setEstado(Estado estado) {
this.estado = estado;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getVisitorId() {
return visitorId;
}
public void setVisitorId(String visitorId) {
this.visitorId = visitorId;
}
public String getIpHash() {
return ipHash;
}
public void setIpHash(String ipHash) {
this.ipHash = ipHash;
}
public String getIpTrunc() {
return ipTrunc;
}
public void setIpTrunc(String ipTrunc) {
this.ipTrunc = ipTrunc;
}
public String getPais() {
return pais;
}
public void setPais(String pais) {
this.pais = pais;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public String getCiudad() {
return ciudad;
}
public void setCiudad(String ciudad) {
this.ciudad = ciudad;
}
public BigDecimal getPrecioUnitario() {
return precioUnitario;
}
public void setPrecioUnitario(BigDecimal precioUnitario) {
this.precioUnitario = precioUnitario;
}
public BigDecimal getPrecioTotalTirada() {
return precioTotalTirada;
}
public void setPrecioTotalTirada(BigDecimal precioTotalTirada) {
this.precioTotalTirada = precioTotalTirada;
}
public BigDecimal getServiciosTotal() {
return serviciosTotal;
}
public void setServiciosTotal(BigDecimal serviciosTotal) {
this.serviciosTotal = serviciosTotal;
}
public BigDecimal getBaseImponible() {
return baseImponible;
}
public void setBaseImponible(BigDecimal baseImponible) {
this.baseImponible = baseImponible;
}
public Boolean getIvaReducido() {
return ivaReducido;
}
public void setIvaReducido(Boolean ivaReducido) {
this.ivaReducido = ivaReducido;
}
public Entrega getEntregaTipo() {
return entregaTipo;
}
public void setEntregaTipo(Entrega entregaTipo) {
this.entregaTipo = entregaTipo;
}
public BigDecimal getIvaImporte4() {
return ivaImporte4;
}
public void setIvaImporte4(BigDecimal ivaImporte4) {
this.ivaImporte4 = ivaImporte4;
}
public BigDecimal getIvaImporte21() {
return ivaImporte21;
}
public void setIvaImporte21(BigDecimal ivaImporte21) {
this.ivaImporte21 = ivaImporte21;
}
public BigDecimal getTotalConIva() {
return totalConIva;
}
public void setTotalConIva(BigDecimal totalConIva) {
this.totalConIva = totalConIva;
}
public String getPreciosPorTiradaJson() {
return preciosPorTiradaJson;
}
public void setPreciosPorTiradaJson(String preciosPorTiradaJson) {
this.preciosPorTiradaJson = preciosPorTiradaJson;
}
public String getServiciosJson() {
return serviciosJson;
}
public void setServiciosJson(String serviciosJson) {
this.serviciosJson = serviciosJson;
}
public String getDatosMaquetacionJson() {
return datosMaquetacionJson;
}
public void setDatosMaquetacionJson(String datosMaquetacionJson) {
this.datosMaquetacionJson = datosMaquetacionJson;
}
public String getDatosMarcapaginasJson() {
return datosMarcapaginasJson;
}
public void setDatosMarcapaginasJson(String datosMarcapaginasJson) {
this.datosMarcapaginasJson = datosMarcapaginasJson;
}
public String getPricingSnapshotJson() {
return pricingSnapshotJson;
}
public void setPricingSnapshotJson(String pricingSnapshotJson) {
this.pricingSnapshotJson = pricingSnapshotJson;
}
public TipoEncuadernacion getTipoEncuadernacion() {
return tipoEncuadernacion;
}
public void setTipoEncuadernacion(TipoEncuadernacion tipoEncuadernacion) {
this.tipoEncuadernacion = tipoEncuadernacion;
}
public String getTitulo() {
return titulo;
}
public void setTitulo(String titulo) {
this.titulo = titulo;
}
// Getters y Setters
public String getAutor() {
return autor;
}
@ -269,14 +644,18 @@ public class Presupuesto implements Cloneable{
return tirada4;
}
public Integer[] getTiradas() {
return new Integer[] { tirada1, tirada2, tirada3, tirada4 };
}
public void setTirada4(Integer tirada4) {
this.tirada4 = tirada4;
}
public Integer getSelectedTirada() {
return selectedTirada;
}
public void setSelectedTirada(Integer selectedTirada) {
this.selectedTirada = selectedTirada;
}
public Integer getAncho() {
return ancho;
}
@ -301,22 +680,6 @@ public class Presupuesto implements Cloneable{
this.formatoPersonalizado = formatoPersonalizado;
}
public String getTitulo() {
return titulo;
}
public void setTitulo(String titulo) {
this.titulo = titulo;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Integer getPaginasNegro() {
return paginasNegro;
}
@ -333,14 +696,6 @@ public class Presupuesto implements Cloneable{
this.paginasColor = paginasColor;
}
public TipoEncuadernacion getTipoEncuadernacion() {
return tipoEncuadernacion;
}
public void setTipoEncuadernacion(TipoEncuadernacion tipoEncuadernacion) {
this.tipoEncuadernacion = tipoEncuadernacion;
}
public String getPosicionPaginasColor() {
return posicionPaginasColor;
}
@ -557,36 +912,12 @@ public class Presupuesto implements Cloneable{
this.altoFaja = altoFaja;
}
public Integer getSelectedTirada() {
return selectedTirada;
public Long getId(){
return id;
}
public void setSelectedTirada(Integer selectedTirada) {
this.selectedTirada = selectedTirada;
}
public Boolean getPresupuestoMaquetacion() {
return presupuestoMaquetacion;
}
public void setPresupuestoMaquetacion(Boolean presupuestoMaquetacion) {
this.presupuestoMaquetacion = presupuestoMaquetacion;
}
public String getPresupuestoMaquetacionData() {
return presupuestoMaquetacionData;
}
public void setPresupuestoMaquetacionData(String presupuestoMaquetacionData) {
this.presupuestoMaquetacionData = presupuestoMaquetacionData;
}
public String resumenPresupuesto() {
return String.format("%s - %s - %dx%d mm - %d Páginas (N:%d C:%d) - Tira:%d",
this.titulo,
this.tipoEncuadernacion,
this.ancho,
this.alto,
this.paginasNegro + this.paginasColorTotal,
this.paginasNegro,
this.paginasColorTotal,
this.selectedTirada != null ? this.selectedTirada : 0);
public void setId(Long id){
this.id = id;
}
}

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import java.util.List;
import java.util.Optional;
public class CompositeGeoIpService implements GeoIpService {
private final List<GeoIpService> delegates;
public CompositeGeoIpService(List<GeoIpService> delegates) {
this.delegates = delegates;
}
@Override
public Optional<GeoData> lookup(String ip) {
for (GeoIpService d : delegates) {
try {
Optional<GeoData> r = d.lookup(ip);
if (r.isPresent()) return r;
} catch (Exception ignore) { /* tolerante */ }
}
return Optional.empty();
}
}

View File

@ -0,0 +1,66 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.ResourceLoader;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.List;
@Configuration
public class GeoIpConfig {
// MaxMind (local). Activar con: geoip.maxmind.enabled=true
@Bean
@ConditionalOnProperty(prefix = "geoip.maxmind", name = "enabled", havingValue = "true")
public GeoIpService maxMindGeoIpService(ResourceLoader loader,
GeoIpProperties props) {
String path = props.getMaxmind().dbClasspathLocation;
return new MaxMindGeoIpService(loader, path);
}
// HTTP. Activar con: geoip.http.enabled=true
@Bean
@ConditionalOnProperty(prefix = "geoip.http", name = "enabled", havingValue = "true")
public GeoIpService httpGeoIpService(GeoIpProperties props) {
RestTemplate rt = new RestTemplate();
return new HttpGeoIpService(rt, props.getHttp().endpointTemplate);
}
// Composite. Activo si cualquiera de los anteriores lo está: geoip.enabled=true
@Bean
@ConditionalOnProperty(prefix = "geoip", name = "enabled", havingValue = "true", matchIfMissing = true)
@Primary
public GeoIpService compositeGeoIpService(List<GeoIpService> delegates) {
// Si no hay ninguno, lista vacía → Composite devuelve empty y no rompe nada
return new CompositeGeoIpService(new ArrayList<>(delegates));
}
@Bean
public GeoIpProperties geoIpProperties() {
return new GeoIpProperties();
}
// --- Props holder simple ---
public static class GeoIpProperties {
private MaxMindProps maxmind = new MaxMindProps();
private HttpProps http = new HttpProps();
public MaxMindProps getMaxmind() { return maxmind; }
public HttpProps getHttp() { return http; }
public static class MaxMindProps {
public boolean enabled = false;
public String dbClasspathLocation = "classpath:geoip/GeoLite2-City.mmdb";
}
public static class HttpProps {
public boolean enabled = false;
// {ip} será reemplazado por la IP
public String endpointTemplate = "https://ipapi.co/{ip}/json/";
}
}
}

View File

@ -0,0 +1,72 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import java.util.Optional;
public class HttpGeoIpService implements GeoIpService {
private final RestTemplate restTemplate;
private final String endpointTemplate; // p.ej. "https://ipapi.co/{ip}/json/"
public HttpGeoIpService(RestTemplate restTemplate, String endpointTemplate) {
this.restTemplate = restTemplate;
this.endpointTemplate = endpointTemplate;
}
@Override
public Optional<GeoData> lookup(String ip) {
try {
if (ip == null || ip.isBlank()) return Optional.empty();
if (isPrivateIp(ip)) return Optional.empty();
String url = endpointTemplate.replace("{ip}", ip);
ResponseEntity<Map> resp = restTemplate.getForEntity(url, Map.class);
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
return Optional.empty();
}
Map body = resp.getBody();
// Campos más comunes según proveedor
String pais = firstNonBlank(
(String) body.get("country_name"),
(String) body.get("country")
);
String region = firstNonBlank(
(String) body.get("region"),
(String) body.get("regionName"),
(String) body.get("state")
);
String ciudad = firstNonBlank(
(String) body.get("city")
);
if (isBlank(pais) && isBlank(region) && isBlank(ciudad)) {
return Optional.empty();
}
return Optional.of(new GeoData(pais, region, ciudad));
} catch (Exception e) {
return Optional.empty();
}
}
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
private static String firstNonBlank(String... vals) {
for (String v : vals) if (!isBlank(v)) return v;
return null;
}
private static boolean isPrivateIp(String ip) {
return ip.startsWith("10.") ||
ip.startsWith("192.168.") ||
ip.matches("^172\\.(1[6-9]|2\\d|3[0-1])\\..*") ||
ip.equals("127.0.0.1") ||
ip.startsWith("169.254.") ||
ip.equals("::1") ||
ip.startsWith("fe80:") ||
ip.startsWith("fc00:") || ip.startsWith("fd00:");
}
}

View File

@ -0,0 +1,77 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.exception.GeoIp2Exception;
import com.maxmind.geoip2.model.CityResponse;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import java.io.IOException;
import java.net.InetAddress;
import java.util.Optional;
public class MaxMindGeoIpService implements GeoIpService, AutoCloseable {
private final DatabaseReader dbReader;
public MaxMindGeoIpService(ResourceLoader resourceLoader, String dbClasspathLocation) {
try {
Resource resource = resourceLoader.getResource(dbClasspathLocation);
this.dbReader = new DatabaseReader.Builder(resource.getInputStream()).build();
} catch (IOException e) {
throw new RuntimeException("No se pudo cargar la base de MaxMind desde: " + dbClasspathLocation, e);
}
}
@Override
public Optional<GeoData> lookup(String ip) {
try {
if (ip == null || ip.isBlank()) return Optional.empty();
// Ignora IPs privadas habituales
if (isPrivateIp(ip)) return Optional.empty();
InetAddress addr = InetAddress.getByName(ip);
CityResponse resp = dbReader.city(addr);
String pais = safe(resp.getCountry() != null ? resp.getCountry().getNames().get("es") : null,
resp.getCountry() != null ? resp.getCountry().getName() : null);
String region = safe(resp.getMostSpecificSubdivision() != null ? resp.getMostSpecificSubdivision().getNames().get("es") : null,
resp.getMostSpecificSubdivision() != null ? resp.getMostSpecificSubdivision().getName() : null);
String ciudad = safe(resp.getCity() != null ? resp.getCity().getNames().get("es") : null,
resp.getCity() != null ? resp.getCity().getName() : null);
if (isAllBlank(pais, region, ciudad)) return Optional.empty();
return Optional.of(new GeoData(pais, region, ciudad));
} catch (IOException | GeoIp2Exception e) {
return Optional.empty();
}
}
private static String safe(String... candidates) {
for (String c : candidates) if (c != null && !c.isBlank()) return c;
return null;
}
private static boolean isAllBlank(String... vals) {
for (String v : vals) if (v != null && !v.isBlank()) return false;
return true;
}
private static boolean isPrivateIp(String ip) {
// Simplificado: rangos privados IPv4, y link-local/loopback. IPv6 simplificado.
return ip.startsWith("10.") ||
ip.startsWith("192.168.") ||
ip.matches("^172\\.(1[6-9]|2\\d|3[0-1])\\..*") ||
ip.equals("127.0.0.1") ||
ip.startsWith("169.254.") ||
ip.equals("::1") ||
ip.startsWith("fe80:") ||
ip.startsWith("fc00:") || ip.startsWith("fd00:");
}
@Override
public void close() throws Exception {
if (dbReader != null) dbReader.close();
}
}

View File

@ -7,11 +7,29 @@ import jakarta.persistence.*;
public class MaquetacionMatrices {
public enum Formato{
A5, _17x24_, A4
A5, _17x24_, A4;
private final String label;
Formato() {
this.label = this.name().indexOf('_') > -1 ? this.name().replace("_", "") + " mm" : this.name();
}
public String getLabel() {
return label;
}
}
public enum FontSize{
small, medium, big
small("presupuesto.maquetacion.cuerpo-texto-pequeño"),
medium("presupuesto.maquetacion.cuerpo-texto-medio"),
big("presupuesto.maquetacion.cuerpo-texto-grande");
private final String messageKey;
FontSize(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
}
@Id

View File

@ -10,23 +10,61 @@ import jakarta.validation.constraints.Min;
public class Marcapaginas {
public enum Acabado{
ninguno,
plastificado_brillo_1c,
plastificado_brillo_2c,
plastificado_mate_1c,
plastificado_mate_2c
ninguno("presupuesto.marcapaginas.acabado.ninguno"),
plastificado_brillo_1c("presupuesto.marcapaginas.acabado.plastificado-brillo-1c"),
plastificado_brillo_2c("presupuesto.marcapaginas.acabado.plastificado-brillo-2c"),
plastificado_mate_1c("presupuesto.marcapaginas.acabado.plastificado-mate-1c"),
plastificado_mate_2c("presupuesto.marcapaginas.acabado.plastificado-mate-2c");
private final String messageKey;
Acabado(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
};
public enum Tamanios{
_50x140_, _50x170_, _50x210_
_50x140_,
_50x170_,
_50x210_;
private final String label;
Tamanios() {
this.label = this.name().replace("_", "") + " mm";
}
public String getLabel() {
return label;
}
};
public enum Papeles{
cartulina_grafica, estucado_mate
cartulina_grafica,
estucado_mate;
private final String messageKey;
Papeles() {
this.messageKey = "presupuesto.marcapaginas.papel." + this.name().replace("_", "-");
}
public String getMessageKey() {
return messageKey;
}
};
public enum Caras_Impresion{
una_cara, dos_caras
una_cara("presupuesto.marcapaginas.caras-impresion-1"),
dos_caras("presupuesto.marcapaginas.caras-impresion-2");
private final String messageKey;
Caras_Impresion(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
};
@Id

View File

@ -0,0 +1,264 @@
package com.imprimelibros.erp.presupuesto.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class PresupuestoFormDataMapper {
public class PresupuestoFormDataDto {
public DatosGenerales datosGenerales = new DatosGenerales();
public Interior interior = new Interior();
public Cubierta cubierta = new Cubierta();
public Servicios servicios = new Servicios();
public Integer selectedTirada;
// ===== Datos Generales =====
public static class DatosGenerales {
public String titulo = "";
public String autor = "";
public String isbn = "";
public String tirada1 = "";
public String tirada2 = "";
public String tirada3 = "";
public String tirada4 = "";
public Integer ancho;
public Integer alto;
public boolean formatoPersonalizado = false;
public String paginasNegro = "";
public String paginasColor = "";
public String posicionPaginasColor = "";
public String tipoEncuadernacion = "fresado"; // enum name
public String entregaTipo = "peninsula"; // enum name
public boolean ivaReducido = true;
}
// ===== Interior =====
public static class Interior {
public String tipoImpresion = "negro"; // enum name
public Integer papelInteriorId = 3;
public Integer gramajeInterior = 80;
}
// ===== Cubierta =====
public static class Cubierta {
public String tipoCubierta = "tapaBlanda"; // enum name
public int solapasCubierta = 0; // 0/1 para tu UI
public String tamanioSolapasCubierta = "80";
public Integer cubiertaCaras = 2;
public Integer papelGuardasId = 3;
public Integer gramajeGuardas = 170;
public Integer guardasImpresas = 0;
public String cabezada = "WHI";
public Integer papelCubiertaId = 3;
public Integer gramajeCubierta = 170;
public Integer acabado = 1;
public SobreCubierta sobrecubierta = new SobreCubierta();
public Faja faja = new Faja();
public static class SobreCubierta {
public boolean activo = false;
public Integer papelSobrecubiertaId = 2;
public Integer gramajeSobrecubierta = 170;
public Integer tamanioSolapasSobrecubierta = 80;
public Integer acabado = 0;
}
public static class Faja {
public boolean activo = false;
public Integer papelFajaId = 2;
public Integer gramajeFaja = 170;
public Integer alto = 50;
public Integer tamanioSolapasFaja = 80;
public Integer acabado = 0;
}
}
// ===== Servicios / Extras =====
public static class Servicios {
public List<DatosServicios> servicios = new ArrayList<DatosServicios>();
public DatosMarcapaginas datosMarcapaginas = new DatosMarcapaginas();
public DatosMaquetacion datosMaquetacion = new DatosMaquetacion();
public static class DatosServicios {
public String id;
public String label;
public Integer units;
public Double price;
}
public static class DatosMarcapaginas {
public Integer marcapaginas_tirada = 100;
public String tamanio = "_50x140_";
public String carasImpresion = "una_cara";
public String papel = "cartulina_grafica";
public Integer gramaje = 300;
public String acabado = "ninguno";
public Resultado resultado = new Resultado();
}
public static class DatosMaquetacion {
public Integer num_caracteres = 200000;
public String formato_maquetacion = "A5";
public String cuerpo_texto = "medium";
public Integer num_columnas = 1;
public Integer num_tablas = 0;
public Integer num_fotos = 0;
public boolean correccion_ortotipografica = false;
public boolean texto_mecanografiado = false;
public boolean disenio_portada = false;
public boolean epub = false;
public Resultado resultado = new Resultado();
}
public static class Resultado {
public Integer num_paginas_estimadas = 0; // solo para maquetación
public Integer precio_pagina_estimado = 0;
public Number precio_unitario = 0; // solo para marcapáginas
public Number precio = 0;
}
}
}
private final ObjectMapper om = new ObjectMapper();
public PresupuestoFormDataDto toFormData(Presupuesto p) {
PresupuestoFormDataDto vm = new PresupuestoFormDataDto();
// ===== Datos Generales
vm.datosGenerales.titulo = nz(p.getTitulo(), "");
vm.datosGenerales.autor = nz(p.getAutor(), "");
vm.datosGenerales.isbn = nz(p.getIsbn(), "");
vm.datosGenerales.tirada1 = nzStr(p.getTirada1());
vm.datosGenerales.tirada2 = nzStr(p.getTirada2());
vm.datosGenerales.tirada3 = nzStr(p.getTirada3());
vm.datosGenerales.tirada4 = nzStr(p.getTirada4());
vm.datosGenerales.ancho = p.getAncho();
vm.datosGenerales.alto = p.getAlto();
vm.datosGenerales.formatoPersonalizado = Boolean.TRUE.equals(p.getFormatoPersonalizado());
vm.datosGenerales.paginasNegro = nzStr(p.getPaginasNegro());
vm.datosGenerales.paginasColor = nzStr(p.getPaginasColor());
vm.datosGenerales.posicionPaginasColor = nz(p.getPosicionPaginasColor(), "");
vm.datosGenerales.tipoEncuadernacion = enumName(p.getTipoEncuadernacion(), "fresado");
vm.datosGenerales.entregaTipo = enumName(p.getEntregaTipo(), "peninsula");
vm.datosGenerales.ivaReducido = Boolean.TRUE.equals(p.getIvaReducido());
// ===== Interior
vm.interior.tipoImpresion = enumName(p.getTipoImpresion(), "negro");
vm.interior.papelInteriorId = nz(p.getPapelInteriorId(), 3);
vm.interior.gramajeInterior = nz(p.getGramajeInterior(), 80);
// ===== Cubierta
vm.cubierta.tipoCubierta = enumName(p.getTipoCubierta(), "tapaBlanda");
vm.cubierta.solapasCubierta = Boolean.TRUE.equals(p.getSolapasCubierta()) ? 1 : 0;
vm.cubierta.tamanioSolapasCubierta = String.valueOf(nz(p.getTamanioSolapasCubierta(), 80));
vm.cubierta.cubiertaCaras = nz(p.getCubiertaCaras(), 2);
vm.cubierta.papelGuardasId = nz(p.getPapelGuardasId(), 3);
vm.cubierta.gramajeGuardas = nz(p.getGramajeGuardas(), 170);
vm.cubierta.guardasImpresas = nz(p.getGuardasImpresas(), 0);
vm.cubierta.cabezada = nz(p.getCabezada(), "WHI");
vm.cubierta.papelCubiertaId = nz(p.getPapelCubiertaId(), 3);
vm.cubierta.gramajeCubierta = nz(p.getGramajeCubierta(), 170);
vm.cubierta.acabado = nz(p.getAcabado(), 1);
vm.cubierta.sobrecubierta.activo = Boolean.TRUE.equals(p.getSobrecubierta());
vm.cubierta.sobrecubierta.papelSobrecubiertaId = nz(p.getPapelSobrecubiertaId(), 2);
vm.cubierta.sobrecubierta.gramajeSobrecubierta = nz(p.getGramajeSobrecubierta(), 170);
vm.cubierta.sobrecubierta.tamanioSolapasSobrecubierta = nz(p.getTamanioSolapasSobrecubierta(), 80);
vm.cubierta.sobrecubierta.acabado = nz(p.getAcabadoSobrecubierta(), 0);
vm.cubierta.faja.activo = Boolean.TRUE.equals(p.getFaja());
vm.cubierta.faja.papelFajaId = nz(p.getPapelFajaId(), 2);
vm.cubierta.faja.gramajeFaja = nz(p.getGramajeFaja(), 170);
vm.cubierta.faja.tamanioSolapasFaja = nz(p.getTamanioSolapasFaja(), 80);
vm.cubierta.faja.acabado = nz(p.getAcabadoFaja(), 0);
vm.cubierta.faja.alto = nz(p.getAltoFaja(), 50);
// ===== Selected tirada
vm.selectedTirada = p.getSelectedTirada();
// ===== Servicios desde JSONs
vm.servicios.servicios = parse(p.getServiciosJson(),
new TypeReference<List<PresupuestoFormDataDto.Servicios.DatosServicios>>() {
});
if (vm.servicios.servicios == null)
vm.servicios.servicios = new ArrayList<>();
// datos_maquetacion_json
PresupuestoFormDataDto.Servicios.DatosMaquetacion maq = parse(p.getDatosMaquetacionJson(),
PresupuestoFormDataDto.Servicios.DatosMaquetacion.class);
if (maq != null)
vm.servicios.datosMaquetacion = merge(vm.servicios.datosMaquetacion, maq);
// datos_marcapaginas_json
PresupuestoFormDataDto.Servicios.DatosMarcapaginas mp = parse(p.getDatosMarcapaginasJson(),
PresupuestoFormDataDto.Servicios.DatosMarcapaginas.class);
if (mp != null)
vm.servicios.datosMarcapaginas = merge(vm.servicios.datosMarcapaginas, mp);
return vm;
}
// ===== Helpers =====
private static String enumName(Enum<?> e, String def) {
return e != null ? e.name() : def;
}
private static String nz(String v, String def) {
return v != null ? v : def;
}
private static Integer nz(Integer v, Integer def) {
return v != null ? v : def;
}
private static String nzStr(Integer v) {
return v == null ? "" : String.valueOf(v);
}
private <T> T parse(String json, Class<T> type) {
try {
if (json == null || json.isBlank())
return null;
return om.readValue(json, type);
} catch (Exception e) {
return null;
}
}
private <T> T parse(String json, TypeReference<T> typeRef) {
try {
if (json == null || json.isBlank())
return null;
return om.readValue(json, typeRef);
} catch (Exception e) {
return null;
}
}
private <T> T merge(T base, T override) {
// merge muy simple: si override != null, devuélvelo; si no, base
// (si quisieras merge campo a campo, usa BeanUtils o MapStruct)
return override != null ? override : base;
}
}

View File

@ -1,4 +1,4 @@
package com.imprimelibros.erp.presupuesto;
package com.imprimelibros.erp.presupuesto.service;
import java.util.ArrayList;
import java.util.Arrays;
@ -8,11 +8,12 @@ import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.Locale;
import java.text.NumberFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
@ -21,8 +22,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.math.RoundingMode;
import com.imprimelibros.erp.common.web.IpUtils;
import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
import com.imprimelibros.erp.presupuesto.classes.ImagenPresupuesto;
import com.imprimelibros.erp.presupuesto.classes.PresupuestadorItems;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
@ -31,8 +34,15 @@ import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionPreciosRepositor
import com.imprimelibros.erp.presupuesto.marcapaginas.Marcapaginas;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMaquetacion;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMarcapaginas;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatrices;
import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatricesRepository;
import com.imprimelibros.erp.presupuesto.marcapaginas.MarcapaginasRepository;
import com.imprimelibros.erp.users.UserDao;
import jakarta.servlet.http.HttpServletRequest;
import com.imprimelibros.erp.externalApi.skApiClient;
@Service
@ -53,18 +63,25 @@ public class PresupuestoService {
@Autowired
protected MarcapaginasRepository marcapaginasRepository;
@Autowired
protected PresupuestoRepository presupuestoRepository;
private final PresupuestadorItems presupuestadorItems;
private final PresupuestoFormatter presupuestoFormatter;
private final skApiClient apiClient;
private final GeoIpService geoIpService;
private final UserDao userRepo;
public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter, skApiClient apiClient) {
public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter,
skApiClient apiClient, GeoIpService geoIpService, UserDao userRepo) {
this.presupuestadorItems = presupuestadorItems;
this.presupuestoFormatter = presupuestoFormatter;
this.apiClient = apiClient;
this.geoIpService = geoIpService;
this.userRepo = userRepo;
}
public boolean validateDatosGenerales(int[] tiradas) {
for (int tirada : tiradas) {
if (tirada <= 0) {
return false; // Invalid tirada found
@ -74,7 +91,6 @@ public class PresupuestoService {
}
public Boolean isPOD(Presupuesto presupuesto) {
int pod_value = variableService.getValorEntero("POD");
return (presupuesto.getTirada1() != null && presupuesto.getTirada1() <= pod_value) ||
(presupuesto.getTirada2() != null && presupuesto.getTirada2() <= pod_value) ||
@ -83,7 +99,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesColor(Presupuesto presupuesto, Locale locale) {
List<ImagenPresupuesto> opciones = new ArrayList<>();
if (presupuesto.getPaginasColor() > 0) {
@ -91,7 +106,10 @@ public class PresupuestoService {
// POD solo color foto
ImagenPresupuesto opcionColor = this.presupuestadorItems.getImpresionColor(locale);
opcionColor.setSelected(Presupuesto.TipoImpresion.color.equals(presupuesto.getTipoImpresion()));
if (Presupuesto.TipoImpresion.color.equals(presupuesto.getTipoImpresion()))
opcionColor.setSelected(true);
opciones.add(opcionColor);
}
ImagenPresupuesto opcionColorHq = this.presupuestadorItems.getImpresionColorPremium(locale);
if (Presupuesto.TipoImpresion.colorhq.equals(presupuesto.getTipoImpresion()))
@ -102,6 +120,7 @@ public class PresupuestoService {
if (Presupuesto.TipoImpresion.negro.equals(presupuesto.getTipoImpresion()))
opcionNegro.setSelected(true);
opciones.add(opcionNegro);
ImagenPresupuesto opcionNegroHq = this.presupuestadorItems.getImpresionNegroPremium(locale);
if (Presupuesto.TipoImpresion.negrohq.equals(presupuesto.getTipoImpresion()))
opcionNegroHq.setSelected(true);
@ -121,16 +140,17 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesPapelInterior(Presupuesto presupuesto, Locale locale) {
List<ImagenPresupuesto> opciones = new ArrayList<>();
opciones.add(this.presupuestadorItems.getPapelOffsetBlanco(locale));
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro ||
if ((presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro
&& !this.isPOD(presupuesto)) ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
opciones.add(this.presupuestadorItems.getPapelOffsetBlancoVolumen(locale));
}
opciones.add(this.presupuestadorItems.getPapelOffsetAhuesado(locale));
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro ||
if ((presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro
&& !this.isPOD(presupuesto)) ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
opciones.add(this.presupuestadorItems.getPapelOffsetAhuesadoVolumen(locale));
}
@ -144,7 +164,6 @@ public class PresupuestoService {
}
boolean yaSeleccionado = opciones.stream().anyMatch(ImagenPresupuesto::isSelected);
if (!yaSeleccionado && !opciones.isEmpty()) {
ImagenPresupuesto primeraOpcion = opciones.get(0);
primeraOpcion.setSelected(true);
@ -153,12 +172,10 @@ public class PresupuestoService {
Map<String, Object> response = new HashMap<>();
response.put("opciones_papel_interior", opciones);
return response;
}
public Map<String, Object> obtenerOpcionesGramajeInterior(Presupuesto presupuesto) {
List<String> gramajes = new ArrayList<>();
final int BLANCO_OFFSET_ID = 3;
@ -168,7 +185,6 @@ public class PresupuestoService {
final int ESTUCADO_MATE_ID = 2;
if (presupuesto.getPapelInteriorId() != null && presupuesto.getPapelInteriorId() == BLANCO_OFFSET_ID) {
gramajes.add("80");
gramajes.add("90");
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negrohq ||
@ -180,32 +196,25 @@ public class PresupuestoService {
}
} else if (presupuesto.getPapelInteriorId() != null
&& presupuesto.getPapelInteriorId() == BLANCO_OFFSET_VOLUMEN_ID) {
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
gramajes.add("80");
}
} else if (presupuesto.getPapelInteriorId() != null && presupuesto.getPapelInteriorId() == AHUESADO_OFFSET_ID) {
gramajes.add("80");
gramajes.add("90");
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negrohq ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.colorhq) {
gramajes.add("100");
}
} else if (presupuesto.getPapelInteriorId() != null
&& presupuesto.getPapelInteriorId() == AHUESADO_OFFSET_VOLUMEN_ID) {
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
gramajes.add("70");
gramajes.add("80");
}
} else if (presupuesto.getPapelInteriorId() != null && presupuesto.getPapelInteriorId() == ESTUCADO_MATE_ID) {
if (presupuesto.getTipoImpresion() != Presupuesto.TipoImpresion.color) {
gramajes.add("90");
}
@ -214,7 +223,8 @@ public class PresupuestoService {
gramajes.add("100");
gramajes.add("115");
}
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro ||
if ((presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro
&& !this.isPOD(presupuesto)) ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
gramajes.add("120");
}
@ -234,7 +244,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesPapelCubierta(Presupuesto presupuesto, Locale locale) {
List<ImagenPresupuesto> opciones = new ArrayList<>();
if (presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaBlanda) {
@ -255,7 +264,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesGramajeCubierta(Presupuesto presupuesto) {
List<String> gramajes = new ArrayList<>();
final int CARTULINA_GRAFICA_ID = 5;
@ -268,7 +276,6 @@ public class PresupuestoService {
gramajes.add("350");
} else if (presupuesto.getPapelCubiertaId() != null && presupuesto.getPapelCubiertaId() == ESTUCADO_MATE_ID) {
if (presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaBlanda) {
gramajes.add("250");
gramajes.add("300");
gramajes.add("350");
@ -283,7 +290,6 @@ public class PresupuestoService {
}
public Map<String, Object> toSkApiRequest(Presupuesto presupuesto) {
final int SK_CLIENTE_ID = 1284;
final int SK_PAGINAS_CUADERNILLO = 32;
@ -343,7 +349,6 @@ public class PresupuestoService {
}
public Integer getTipoImpresionId(Presupuesto presupuesto) {
if (presupuesto.getTipoEncuadernacion() == Presupuesto.TipoEncuadernacion.fresado) {
if (presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaDura ||
presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaDuraLomoRedondo) {
@ -437,12 +442,6 @@ public class PresupuestoService {
return resultado;
}
public Map<String, Object> aplicarMargenTiradas(Map<String, Object> data) {
// implementar margenes
return (Map<String, Object>) data;
}
public String obtenerPrecioRetractilado(Presupuesto presupuesto, Locale locale) {
Integer[] tiradas = presupuesto.getTiradas();
Integer tirada_min = Arrays.stream(tiradas)
@ -454,13 +453,21 @@ public class PresupuestoService {
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : tirada_min);
Double precio_retractilado = apiClient.getRetractilado(requestBody);
return precio_retractilado != null
? NumberFormat.getNumberInstance(locale)
.format(Math.round(precio_retractilado * 100.0) / 100.0)
: "0,00";
? String.valueOf(Math.round(precio_retractilado * 100.0) / 100.0)
: "0.00";
}
private String obtenerPrecioRetractilado(Integer tirada) {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("tirada", tirada != null ? tirada : 0);
Double precio_retractilado = apiClient.getRetractilado(requestBody);
return precio_retractilado != null
? String.valueOf(Math.round(precio_retractilado * 100.0) / 100.0)
: "0.00";
}
public Map<String, Object> obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) {
List<Object> opciones = new ArrayList<>();
Double price_prototipo = this.obtenerPrototipo(presupuesto);
@ -532,8 +539,8 @@ public class PresupuestoService {
put("price", messageSource.getMessage("presupuesto.consultar-soporte", null, locale));
put("priceUnit", "");
} else {
put("price", NumberFormat.getNumberInstance(locale)
.format(Math.round(price_prototipo * 100.0) / 100.0));
put("price",
String.valueOf(Math.round(price_prototipo * 100.0) / 100.0));
put("priceUnit", messageSource.getMessage("app.currency-symbol", null, locale));
}
}
@ -560,42 +567,10 @@ public class PresupuestoService {
return response;
}
private Double obtenerPrototipo(Presupuesto presupuesto) {
// Obtenemos el precio de 1 unidad para el ejemplar de prueba
HashMap<String, Object> price = new HashMap<>();
// make a copy of "presupuesto" to avoid modifying the original object
Presupuesto presupuestoTemp = presupuesto.clone();
presupuestoTemp.setTirada1(1);
presupuestoTemp.setTirada2(null);
presupuestoTemp.setTirada3(null);
presupuestoTemp.setTirada4(null);
if (presupuestoTemp.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
presupuestoTemp.setTipoImpresion(Presupuesto.TipoImpresion.colorhq);
} else if (presupuestoTemp.getTipoImpresion() == Presupuesto.TipoImpresion.negro) {
presupuestoTemp.setTipoImpresion(Presupuesto.TipoImpresion.negrohq);
}
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp), presupuestoTemp.getTipoEncuadernacion(), presupuestoTemp.getTipoCubierta());
Double price_prototipo = 0.0;
try {
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
});
price_prototipo = ((List<Double>) ((Map<String, Object>) price.get("data")).get("precios")).get(0);
if (price_prototipo < 25) {
price_prototipo = 25.0;
}
} catch (JsonProcessingException e) {
} catch (Exception exception) {
}
return price_prototipo;
}
public HashMap<String, Object> getPrecioMaquetacion(PresupuestoMaquetacion presupuestoMaquetacion, Locale locale) {
try {
List<MaquetacionPrecios> lista = maquetacionPreciosRepository.findAll();
// helper para obtener un precio por clave
java.util.function.Function<String, Double> price = key -> lista.stream()
.filter(p -> key.equals(p.getKey()))
.map(MaquetacionPrecios::getValue)
@ -604,13 +579,10 @@ public class PresupuestoService {
BigDecimal precio = BigDecimal.ZERO;
// millar_maquetacion * (numCaracteres / 1000.0)
BigDecimal millares = BigDecimal.valueOf(presupuestoMaquetacion.getNumCaracteres()).divide(
BigDecimal.valueOf(1000), 6,
RoundingMode.HALF_UP);
BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP);
precio = precio.add(millares.multiply(BigDecimal.valueOf(price.apply("millar_maquetacion"))));
// Numero de paginas estimado
int numPaginas = 0;
Integer matricesPorPagina = maquetacionMatricesRepository.findMatrices(
presupuestoMaquetacion.getFormato(),
@ -618,7 +590,7 @@ public class PresupuestoService {
if (matricesPorPagina != null && matricesPorPagina > 0) {
numPaginas = presupuestoMaquetacion.getNumCaracteres() / matricesPorPagina;
}
// Precio por pagina estimado
BigDecimal precioRedondeado = precio.setScale(2, RoundingMode.HALF_UP);
double precioPaginaEstimado = 0.0;
if (numPaginas > 0) {
@ -627,7 +599,6 @@ public class PresupuestoService {
.doubleValue();
}
// tabla, columna, foto
precio = precio
.add(BigDecimal.valueOf(presupuestoMaquetacion.getNumTablas())
.multiply(BigDecimal.valueOf(price.apply("tabla"))));
@ -652,7 +623,6 @@ public class PresupuestoService {
precio = precio.add(BigDecimal.valueOf(price.apply("epub")));
}
// redondeo final
precioRedondeado = precio.setScale(2, RoundingMode.HALF_UP);
HashMap<String, Object> out = new HashMap<>();
@ -684,9 +654,7 @@ public class PresupuestoService {
public HashMap<String, Object> getPrecioMarcapaginas(PresupuestoMarcapaginas presupuestoMarcapaginas,
Locale locale) {
try {
List<Marcapaginas> m = marcapaginasRepository.findPrecios(presupuestoMarcapaginas);
if (m.isEmpty() || m.get(0) == null) {
HashMap<String, Object> out = new HashMap<>();
@ -774,47 +742,60 @@ public class PresupuestoService {
return out;
}
public Map<String, Object> getResumen(Presupuesto presupuesto, List<Map<String, Object>> servicios, Locale locale) {
/**
* Calcula el resumen (SIN persistir cambios de estado).
* Mantiene firma para no romper llamadas existentes.
*/
public Map<String, Object> getTextosResumen(Presupuesto presupuesto, List<Map<String, Object>> servicios,
Map<String, Object> datosMaquetacion, Map<String, Object> datosMarcapaginas, Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
Presupuesto pressupuestoTemp = presupuesto.clone();
resumen.put("imagen", "/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion() + ".png");
resumen.put("imagen_alt", messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null, locale));
resumen.put("imagen",
"/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion() + ".png");
resumen.put("imagen_alt",
messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null, locale));
boolean hayDepositoLegal = servicios != null && servicios.stream()
.map(m -> java.util.Objects.toString(m.get("id"), "")) // null-safe -> String
.map(m -> java.util.Objects.toString(m.get("id"), ""))
.map(String::trim)
.anyMatch("deposito-legal"::equals);
if(hayDepositoLegal){
pressupuestoTemp.setSelectedTirada(presupuesto.getSelectedTirada()+4);
if (hayDepositoLegal) {
pressupuestoTemp.setSelectedTirada(
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() + 4 : 4);
for (Integer i = 0; i < pressupuestoTemp.getTiradas().length; i++) {
Integer tirada = pressupuestoTemp.getTiradas()[i];
if (tirada != null && tirada >= 4) {
tirada = tirada + 4;
}
pressupuestoTemp.getTiradas()[i] = tirada;
}
}
HashMap<String, Object> precios = this.calcularPresupuesto(pressupuestoTemp, locale);
if(precios.containsKey("error")){
if (precios.containsKey("error")) {
resumen.put("error", precios.get("error"));
return resumen;
}
resumen.put("precios", precios);
HashMap<String, Object> linea = new HashMap<>();
Double precio_unitario = 0.0;
Double precio_total = 0.0;
Integer counter = 0;
linea.put("descripcion", presupuestoFormatter.resumen(presupuesto, servicios, locale));
linea.put("cantidad", presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0);
precio_unitario = ((List<Double>) ((Map<String, Object>) precios.get("data")).get("precios"))
.get(0);
precio_total = precio_unitario * presupuesto.getSelectedTirada();
precio_unitario = ((List<Double>) ((Map<String, Object>) precios.get("data")).get("precios")).get(0);
precio_total = precio_unitario
* (presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0);
linea.put("precio_unitario", precio_unitario);
linea.put("precio_total", BigDecimal.valueOf(precio_total).setScale(2, RoundingMode.HALF_UP));
resumen.put("linea" + counter, linea);
counter++;
if(hayDepositoLegal) {
if (hayDepositoLegal) {
linea = new HashMap<>();
linea.put("descripcion", messageSource.getMessage("presupuesto.resumen-deposito-legal", null, locale));
linea.put("cantidad", 4);
@ -825,15 +806,69 @@ public class PresupuestoService {
}
List<Map<String, Object>> serviciosExtras = new ArrayList<>();
if(servicios != null){
if (servicios != null) {
for (Map<String, Object> servicio : servicios) {
HashMap<String, Object> servicioData = new HashMap<>();
servicioData.put("id", servicio.get("id"));
servicioData.put("descripcion", servicio.get("label"));
servicioData.put("precio", servicio.get("id").equals("marcapaginas") ?
Double.parseDouble(servicio.get("price").toString())/Double.parseDouble(servicio.get("units").toString()) :
servicio.get("price"));
if (servicio.get("id").equals("marcapaginas")) {
String descripcion = servicio.get("label").toString();
descripcion += "<br><ul><li>";
descripcion += Marcapaginas.Tamanios.valueOf(datosMarcapaginas.get("tamanio").toString()).getLabel()
+ ", ";
descripcion += Marcapaginas.Caras_Impresion
.valueOf(datosMarcapaginas.get("carasImpresion").toString()).getMessageKey() + ", ";
descripcion += messageSource
.getMessage(Marcapaginas.Papeles.valueOf(datosMarcapaginas.get("papel").toString())
.getMessageKey(), null, locale)
+ " - " +
datosMarcapaginas.get("gramaje").toString() + " gr, ";
descripcion += messageSource.getMessage(
Marcapaginas.Acabado.valueOf(datosMarcapaginas.get("acabado").toString()).getMessageKey(),
null, locale);
descripcion += "</li></ul>";
servicioData.put("descripcion", descripcion);
} else if (servicio.get("id").equals("maquetacion")) {
String descripcion = servicio.get("label").toString();
descripcion += "<br><ul><li>";
descripcion += (datosMaquetacion.get("num_caracteres") + " "
+ messageSource.getMessage("presupuesto.maquetacion.caracteres", null, locale)) + ", ";
descripcion += MaquetacionMatrices.Formato
.valueOf(datosMaquetacion.get("formato_maquetacion").toString()).getLabel() + ", ";
descripcion += messageSource.getMessage(MaquetacionMatrices.FontSize
.valueOf(datosMaquetacion.get("cuerpo_texto").toString()).getMessageKey(), null, locale)
+ ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-columnas", null, locale) + ": "
+ datosMaquetacion.get("num_columnas").toString() + ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-tablas", null, locale) + ": "
+ datosMaquetacion.get("num_tablas").toString() + ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-fotos", null, locale) + ": "
+ datosMaquetacion.get("num_fotos").toString();
if ((boolean) datosMaquetacion.get("correccion_ortotipografica")) {
descripcion += ", " + messageSource
.getMessage("presupuesto.maquetacion.correccion-ortotipografica", null, locale);
}
if ((boolean) datosMaquetacion.get("texto_mecanografiado")) {
descripcion += ", " + messageSource.getMessage("presupuesto.maquetacion.texto-mecanografiado",
null, locale);
}
if ((boolean) datosMaquetacion.get("disenio_portada")) {
descripcion += ", "
+ messageSource.getMessage("presupuesto.maquetacion.diseno-portada", null, locale);
}
if ((boolean) datosMaquetacion.get("epub")) {
descripcion += ", " + messageSource.getMessage("presupuesto.maquetacion.epub", null, locale);
}
descripcion += "</li></ul>";
servicioData.put("descripcion", descripcion);
} else {
servicioData.put("descripcion", servicio.get("label"));
}
servicioData.put("precio", servicio.get("id").equals("marcapaginas")
? Double.parseDouble(servicio.get("price").toString())
/ Double.parseDouble(servicio.get("units").toString())
: servicio.get("price"));
servicioData.put("unidades", servicio.get("units"));
serviciosExtras.add(servicioData);
}
@ -843,10 +878,343 @@ public class PresupuestoService {
return resumen;
}
public HashMap<String, Object> calcularPresupuesto(Presupuesto presupuesto, Locale locale) {
/**
* PÚBLICO: calcula el resumen y GUARDA el presupuesto completo como BORRADOR.
* Se invoca al entrar en la pestaña "Resumen" del presupuestador público.
*/
// PresupuestoService.java
public Map<String, Object> getResumen(
Presupuesto presupuesto,
List<Map<String, Object>> servicios,
Map<String, Object> datosMaquetacion,
Map<String, Object> datosMarcapaginas,
Boolean save,
String mode,
Locale locale,
String sessionId,
String ip) {
// 1) Calcula el resumen (como ya haces)
try {
presupuesto.setDatosMaquetacionJson(
datosMaquetacion != null ? new ObjectMapper().writeValueAsString(datosMaquetacion) : null);
presupuesto.setDatosMarcapaginasJson(
datosMarcapaginas != null ? new ObjectMapper().writeValueAsString(datosMarcapaginas) : null);
} catch (Exception e) {
System.out.println("Error guardando datos adicionales: " + e.getMessage());
}
Map<String, Object> resumen = getTextosResumen(presupuesto, servicios, datosMaquetacion, datosMarcapaginas,
locale);
if (resumen.containsKey("error"))
return resumen;
presupuesto = generateTotalizadores(presupuesto, servicios, resumen, locale);
// 3) Enriquecer el Presupuesto a persistir
presupuesto.setEstado(Presupuesto.Estado.borrador);
if (mode.equals("public")) {
presupuesto = getDatosLocalizacion(presupuesto, sessionId, ip);
presupuesto.setOrigen(Presupuesto.Origen.publico);
presupuesto = this.getDatosLocalizacion(presupuesto, sessionId, ip);
} else
presupuesto.setOrigen(Presupuesto.Origen.privado);
// 4) UPSERT: si viene id -> actualiza; si no, reusa el último borrador de la
// sesión
Presupuesto entidad;
if (presupuesto.getId() != null) {
entidad = presupuestoRepository.findById(presupuesto.getId()).orElse(presupuesto);
} else {
entidad = presupuestoRepository
.findTopBySessionIdAndEstadoOrderByCreatedAtDesc(sessionId, Presupuesto.Estado.borrador)
.orElse(presupuesto);
// Si se reutiliza un borrador existente, copia el ID a nuestro objeto para
// hacer merge
presupuesto.setId(entidad.getId());
}
// 5) Guardar/actualizar
entidad = mergePresupuesto(entidad, presupuesto);
if (save != null && save) {
// Si NO es para guardar (solo calcular resumen), devolver sin persistir
presupuestoRepository.saveAndFlush(presupuesto);
}
// Opcional: devolver el id guardado al frontend para que lo envíe en llamadas
// siguientes
resumen.put("presupuesto_id", entidad.getId());
resumen.put("precio_unitario", presupuesto.getPrecioUnitario());
resumen.put("precio_total_tirada", presupuesto.getPrecioTotalTirada());
resumen.put("servicios_total", presupuesto.getServiciosTotal());
resumen.put("base_imponible", presupuesto.getBaseImponible());
resumen.put("iva_importe_4", presupuesto.getIvaImporte4());
resumen.put("iva_importe_21", presupuesto.getIvaImporte21());
resumen.put("total_con_iva", presupuesto.getTotalConIva());
return resumen;
}
public Presupuesto getDatosLocalizacion(Presupuesto presupuesto, String sessionId, String ip) {
presupuesto.setOrigen(Presupuesto.Origen.publico);
presupuesto.setSessionId(sessionId);
// IP: guarda hash y trunc (si tienes campos). Si no, guarda tal cual en
// ip_trunc/ip_hash según tu modelo.
String ipTrunc = anonymizeIp(ip);
presupuesto.setIpTrunc(ipTrunc);
presupuesto.setIpHash(Integer.toHexString(ip.hashCode()));
// ubicación (si tienes un servicio GeoIP disponible; si no, omite estas tres
// líneas)
try {
GeoIpService.GeoData geo = geoIpService.lookup(ip).orElse(null);
presupuesto.setPais(geo.getPais());
presupuesto.setRegion(geo.getRegion());
presupuesto.setCiudad(geo.getCiudad());
} catch (Exception ignore) {
}
return presupuesto;
}
public Presupuesto generateTotalizadores(
Presupuesto presupuesto,
List<Map<String, Object>> servicios,
Map<String, Object> resumen,
Locale locale) {
Map<Integer, Map<String, Object>> pricing_snapshot = new HashMap<>();
@SuppressWarnings("unchecked")
Map<String, Object> preciosNode = (Map<String, Object>) resumen.getOrDefault("precios", Map.of());
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) preciosNode.getOrDefault("data", Map.of());
@SuppressWarnings("unchecked")
List<Integer> tiradas = (List<Integer>) data.getOrDefault("tiradas", List.of());
@SuppressWarnings("unchecked")
List<Double> precios = (List<Double>) data.getOrDefault("precios", List.of());
@SuppressWarnings("unchecked")
List<Double> pesos = (List<Double>) data.getOrDefault("peso", List.of());
boolean hayDepositoLegal = servicios != null && servicios.stream()
.map(m -> java.util.Objects.toString(m.get("id"), ""))
.map(String::trim)
.anyMatch("deposito-legal"::equals);
if (precios.isEmpty()) {
var preciosCalc = this.calcularPresupuesto(presupuesto, locale);
precios = (List<Double>) ((Map<String, Object>) preciosCalc.get("data")).getOrDefault("precios", List.of());
}
// iterate getTiradas with a foreach with not null
for (Integer tirada : presupuesto.getTiradas()) {
if (tirada == null) {
continue;
}
// Genera los totalizadores (precio unitario, total tirada, etc.) sin guardar
double precioUnit = 0.0;
int cantidad = tirada != null ? tirada : 0;
int index = tiradas.indexOf(tirada);
try {
if (index >= 0 && index < precios.size()) {
precioUnit = precios.get(index);
} else if (!precios.isEmpty()) {
precioUnit = precios.get(0); // fallback al primero
}
// guarda el snapshot completo de precios para auditoría
presupuesto.setPreciosPorTiradaJson(new ObjectMapper().writeValueAsString(precios));
} catch (Exception ignore) {
precioUnit = 0.0;
}
BigDecimal precioTotalTirada = BigDecimal.valueOf(precioUnit)
.multiply(BigDecimal.valueOf(cantidad))
.setScale(2, RoundingMode.HALF_UP);
if (hayDepositoLegal) {
precioTotalTirada = precioTotalTirada
.add(BigDecimal.valueOf(precioUnit).multiply(BigDecimal.valueOf(4)))
.setScale(6, RoundingMode.HALF_UP);
}
// servicios_total
BigDecimal serviciosIva4 = BigDecimal.ZERO;
BigDecimal serviciosTotal = BigDecimal.ZERO;
if (servicios != null) {
for (Map<String, Object> s : servicios) {
try {
// retractilado: recalcular precio
if (s.get("id").equals("retractilado")) {
double precio_retractilado = obtenerPrecioRetractilado(cantidad) != null
? Double.parseDouble(obtenerPrecioRetractilado(cantidad))
: 0.0;
s.put("price", precio_retractilado);
}
// si tiene protitipo, guardamos el valor para el IVA al 4%
else if (s.get("id").equals("ejemplar-prueba")) {
serviciosIva4 = BigDecimal.valueOf(
s.get("price") != null ? Double.parseDouble(String.valueOf(s.get("price"))) : 0.0);
}
double unidades = Double.parseDouble(String.valueOf(s.getOrDefault("units", 0)));
double precio = Double.parseDouble(String.valueOf(
s.get("id").equals("marcapaginas")
? (Double.parseDouble(String.valueOf(s.get("price"))) / unidades) // unidad
: s.getOrDefault("price", 0)));
serviciosTotal = serviciosTotal.add(
BigDecimal.valueOf(precio).multiply(BigDecimal.valueOf(unidades)));
} catch (Exception ignore) {
}
}
try {
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios));
} catch (Exception ignore) {
}
}
BigDecimal baseImponible = precioTotalTirada;
BigDecimal ivaImporte4 = BigDecimal.ZERO;
BigDecimal ivaImporte21 = BigDecimal.ZERO;
// Si la entrega es en peninsula, se mira el valor del iva
// Canarias y paises UE no llevan IVA
if (presupuesto.getEntregaTipo() == Presupuesto.Entrega.peninsula) {
// Si el iva es reducido, el precio de la tirada y el del prototipo llevan IVA
// 4%
if (presupuesto.getIvaReducido()) {
ivaImporte4 = baseImponible.add(serviciosIva4).multiply(BigDecimal.valueOf(4)).divide(
BigDecimal.valueOf(100), 2,
RoundingMode.HALF_UP);
ivaImporte21 = serviciosTotal.subtract(serviciosIva4).multiply(BigDecimal.valueOf(21)).divide(
BigDecimal.valueOf(100), 2,
RoundingMode.HALF_UP);
} else {
ivaImporte21 = baseImponible.add(serviciosTotal).multiply(BigDecimal.valueOf(21)).divide(
BigDecimal.valueOf(100), 2,
RoundingMode.HALF_UP);
}
}
baseImponible = baseImponible.add(serviciosTotal);
BigDecimal totalConIva = baseImponible.add(ivaImporte21).add(ivaImporte4);
// precios y totales
if (tirada == (presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0)) {
presupuesto.setPrecioUnitario(BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP));
presupuesto.setPrecioTotalTirada(precioTotalTirada);
presupuesto.setServiciosTotal(serviciosTotal);
presupuesto.setBaseImponible(baseImponible);
presupuesto.setIvaImporte4(ivaImporte4);
presupuesto.setIvaImporte21(ivaImporte21);
presupuesto.setTotalConIva(totalConIva);
}
Map<String, Object> snap = new HashMap<>();
snap.put("precio_unitario", BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP));
snap.put("precio_total_tirada", precioTotalTirada);
snap.put("servicios_total", serviciosTotal);
snap.put("base_imponible", baseImponible);
snap.put("iva_importe_4", ivaImporte4);
snap.put("iva_importe_21", ivaImporte21);
snap.put("total_con_iva", totalConIva);
snap.put("peso", (index >= 0 && index < pesos.size()) ? pesos.get(index) : 0.0);
pricing_snapshot.put(tirada, snap);
}
try {
String json = new ObjectMapper()
.writer()
.withDefaultPrettyPrinter() // opcional
.writeValueAsString(pricing_snapshot);
presupuesto.setPricingSnapshotJson(pricing_snapshot.isEmpty() ? null : json);
} catch (Exception ignore) {
}
return presupuesto;
}
@Transactional
public HashMap<String, Object> guardarPresupuesto(
Presupuesto presupuesto,
List<Map<String, Object>> serviciosList,
Map<String, Object> datosMaquetacion,
Map<String, Object> datosMarcapaginas,
String mode,
Long cliente_id,
Long id,
HttpServletRequest request,
Locale locale) {
HashMap<String, Object> result = new HashMap<>();
try {
presupuesto.setDatosMaquetacionJson(
datosMaquetacion != null ? new ObjectMapper().writeValueAsString(datosMaquetacion) : null);
presupuesto.setDatosMarcapaginasJson(
datosMarcapaginas != null ? new ObjectMapper().writeValueAsString(datosMarcapaginas) : null);
var resumen = this.getTextosResumen(presupuesto, serviciosList, datosMaquetacion, datosMarcapaginas,
locale);
Object serviciosObj = resumen.get("servicios");
if (serviciosObj instanceof List<?> servicios && !servicios.isEmpty()) {
// serializa a JSON válido
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(servicios);
presupuesto.setServiciosJson(json);
} else {
// decide tu política: null o "[]"
presupuesto.setServiciosJson(null); // o presupuesto.setServiciosJson("[]");
}
if (cliente_id != null && !mode.equals("public")) {
presupuesto.setUser(userRepo.findById(cliente_id).orElse(null));
presupuesto.setOrigen(Presupuesto.Origen.privado);
if (id != null) {
presupuesto.setId(id);
}
}
if (mode.equals("public")) {
presupuesto.setOrigen(Presupuesto.Origen.publico);
String sessionId = request.getSession(true).getId();
String ip = IpUtils.getClientIp(request);
presupuesto = this.getDatosLocalizacion(presupuesto, sessionId, ip);
if (id != null) {
presupuesto.setId(id); // para que actualice, no cree uno nuevo
}
}
presupuesto = this.generateTotalizadores(presupuesto, serviciosList, resumen, locale);
presupuestoRepository.saveAndFlush(presupuesto);
result.put("success", true);
result.put("presupuesto_id", presupuesto.getId());
return result;
} catch (Exception e) {
System.out.println("Error guardando presupuesto: " + e.getMessage());
result.put("success", false);
return result;
}
}
/**
* PRIVADO (futuro botón "Guardar"): persiste el presupuesto como borrador.
*/
public HashMap<String, Object> calcularPresupuesto(Presupuesto presupuesto, Locale locale) {
HashMap<String, Object> price = new HashMap<>();
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto), presupuesto.getTipoEncuadernacion(), presupuesto.getTipoCubierta());
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto), presupuesto.getTipoEncuadernacion(),
presupuesto.getTipoCubierta());
try {
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
@ -855,7 +1223,145 @@ public class PresupuestoService {
price = new HashMap<>();
price.put("error", messageSource.getMessage("presupuesto.error-obtener-precio", null, locale));
}
return price;
}
public Boolean canAccessPresupuesto(Presupuesto presupuesto, Authentication authentication) {
boolean isUser = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
if (isUser) {
// Si es usuario, solo puede ver sus propios presupuestos
String username = authentication.getName();
if (presupuesto.getUser() == null || !presupuesto.getUser().getUserName().equals(username)) {
return false;
}
}
return true;
}
// =======================================================================
// Métodos privados
// =======================================================================
/**
* Copia de campos "actualizables" para no machacar otros (created_at, etc.)
*/
private Presupuesto mergePresupuesto(Presupuesto target, Presupuesto src) {
// Campos funcionales
target.setTitulo(src.getTitulo());
target.setTipoEncuadernacion(src.getTipoEncuadernacion());
target.setTipoCubierta(src.getTipoCubierta());
target.setTipoImpresion(src.getTipoImpresion());
target.setPaginasNegro(src.getPaginasNegro());
target.setPaginasColor(src.getPaginasColor());
target.setPaginasColorTotal(src.getPaginasColorTotal());
target.setPosicionPaginasColor(src.getPosicionPaginasColor());
target.setAncho(src.getAncho());
target.setAlto(src.getAlto());
target.setPapelInteriorId(src.getPapelInteriorId());
target.setGramajeInterior(src.getGramajeInterior());
target.setPapelCubiertaId(src.getPapelCubiertaId());
target.setGramajeCubierta(src.getGramajeCubierta());
target.setCubiertaCaras(src.getCubiertaCaras());
target.setSolapasCubierta(src.getSolapasCubierta());
target.setTamanioSolapasCubierta(src.getTamanioSolapasCubierta());
target.setSobrecubierta(src.getSobrecubierta());
target.setPapelSobrecubiertaId(src.getPapelSobrecubiertaId());
target.setGramajeSobrecubierta(src.getGramajeSobrecubierta());
target.setTamanioSolapasSobrecubierta(src.getTamanioSolapasSobrecubierta());
target.setAcabado(src.getAcabado());
target.setCabezada(src.getCabezada());
target.setTipoCubierta(src.getTipoCubierta());
target.setSelectedTirada(src.getSelectedTirada());
target.setTirada1(src.getTirada1());
target.setTirada2(src.getTirada2());
target.setTirada3(src.getTirada3());
target.setTirada4(src.getTirada4());
// Metadatos y totales
target.setEstado(Presupuesto.Estado.borrador);
target.setOrigen(src.getOrigen());
target.setSessionId(src.getSessionId());
target.setIpHash(src.getIpHash());
target.setIpTrunc(src.getIpTrunc());
target.setPais(src.getPais());
target.setRegion(src.getRegion());
target.setCiudad(src.getCiudad());
target.setServiciosJson(src.getServiciosJson());
target.setPreciosPorTiradaJson(src.getPreciosPorTiradaJson());
target.setPrecioUnitario(src.getPrecioUnitario());
target.setPrecioTotalTirada(src.getPrecioTotalTirada());
target.setServiciosTotal(src.getServiciosTotal());
target.setBaseImponible(src.getBaseImponible());
target.setIvaReducido(src.getIvaReducido());
target.setEntregaTipo(src.getEntregaTipo());
target.setIvaImporte4(src.getIvaImporte4());
target.setIvaImporte21(src.getIvaImporte21());
target.setTotalConIva(src.getTotalConIva());
target.setCreatedBy(target.getCreatedBy() == null ? src.getCreatedBy() : target.getCreatedBy()); // no pisar si
// ya existe
return target;
}
private Double obtenerPrototipo(Presupuesto presupuesto) {
// Obtenemos el precio de 1 unidad para el ejemplar de prueba
HashMap<String, Object> price = new HashMap<>();
Presupuesto presupuestoTemp = presupuesto.clone();
presupuestoTemp.setTirada1(1);
presupuestoTemp.setTirada2(null);
presupuestoTemp.setTirada3(null);
presupuestoTemp.setTirada4(null);
if (presupuestoTemp.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
presupuestoTemp.setTipoImpresion(Presupuesto.TipoImpresion.colorhq);
} else if (presupuestoTemp.getTipoImpresion() == Presupuesto.TipoImpresion.negro) {
presupuestoTemp.setTipoImpresion(Presupuesto.TipoImpresion.negrohq);
}
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp),
presupuestoTemp.getTipoEncuadernacion(), presupuestoTemp.getTipoCubierta());
Double price_prototipo = 0.0;
try {
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
});
price_prototipo = ((List<Double>) ((Map<String, Object>) price.get("data")).get("precios")).get(0);
if (price_prototipo < 25) {
price_prototipo = 25.0;
}
} catch (JsonProcessingException e) {
} catch (Exception exception) {
}
return price_prototipo;
}
// Utilidad local (puedes moverla a una clase Utils si quieres)
private static String anonymizeIp(String ip) {
if (ip == null)
return null;
// IPv4
if (ip.contains(".") && !ip.contains(":")) {
String[] p = ip.split("\\.");
if (p.length == 4) {
return p[0] + "." + p[1] + "." + p[2] + ".0";
}
}
// IPv6: quedarnos con /64 -> primera mitad y rellenar
if (ip.contains(":")) {
String[] parts = ip.split(":", -1);
// expand no estricta, nos quedamos con primeros 4 bloques y completamos
int blocks = Math.min(parts.length, 4);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < blocks; i++) {
if (i > 0)
sb.append(":");
sb.append(parts[i].isEmpty() ? "0" : parts[i]);
}
// completar a /64 con ceros
for (int i = blocks; i < 8; i++)
sb.append(":0");
return sb.toString();
}
return ip;
}
}

View File

@ -1,7 +1,7 @@
package com.imprimelibros.erp.presupuesto.validation;
import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.presupuesto.Presupuesto;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

View File

@ -0,0 +1,16 @@
package com.imprimelibros.erp.presupuesto.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = PaginasCosidoGrapadoValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PaginasCosidoGrapado {
String message() default "Las tiradas deben ser todas mayores o todas menores al valor POD";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -0,0 +1,38 @@
package com.imprimelibros.erp.presupuesto.validation;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
public class PaginasCosidoGrapadoValidator implements ConstraintValidator<PaginasCosidoGrapado, Presupuesto> {
@Autowired
private MessageSource messageSource;
@Override
public boolean isValid(Presupuesto presupuesto, ConstraintValidatorContext context) {
if (presupuesto == null)
return true;
if (presupuesto.getTipoEncuadernacion() != null &&
(presupuesto.getTipoEncuadernacion() == Presupuesto.TipoEncuadernacion.cosido ||
presupuesto.getTipoEncuadernacion() == Presupuesto.TipoEncuadernacion.grapado)) {
if (presupuesto.getPaginasColor() > 0 && presupuesto.getPaginasNegro() > 0) {
String mensajeInterpolado = messageSource.getMessage(
"presupuesto.errores.tipo-paginas-cosido-grapado",
null,
LocaleContextHolder.getLocale() // respeta el idioma actual
);
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(mensajeInterpolado)
.addConstraintViolation();
return false;
}
}
return true;
}
}

View File

@ -1,9 +1,14 @@
package com.imprimelibros.erp.presupuesto.validation;
import jakarta.validation.GroupSequence;
public class PresupuestoValidationGroups {
public interface DatosGenerales {}
public interface Interior {}
public interface Cubierta {}
@GroupSequence({DatosGenerales.class, Interior.class, Cubierta.class})
public interface All {}
}

View File

@ -1,7 +1,7 @@
package com.imprimelibros.erp.presupuesto.validation;
import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.presupuesto.Presupuesto;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

View File

@ -7,7 +7,6 @@ import java.lang.reflect.Method;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.FlushModeType;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.PersistenceUnit;
import jakarta.persistence.criteria.*;
import jakarta.validation.ConstraintValidator;

View File

@ -255,7 +255,6 @@ public class User {
", fullName='" + fullName + '\'' +
", userName='" + userName + '\'' +
", enabled=" + enabled +
", roles=" + getRoles() +
'}';
}

View File

@ -8,8 +8,10 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.MessageSource;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
@ -25,6 +27,9 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.config.Sanitizer;
@ -34,6 +39,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import org.springframework.web.bind.annotation.RequestParam;
@ -52,6 +58,7 @@ public class UserController {
private Sanitizer sanitizer;
private PasswordEncoder passwordEncoder;
private TranslationService translationService;
private UserService userService;
public UserController(UserDao repo, UserService userService, MessageSource messageSource, Sanitizer sanitizer,
PasswordEncoder passwordEncoder, RoleDao roleRepo, TranslationService translationService) {
@ -61,6 +68,7 @@ public class UserController {
this.roleRepo = roleRepo;
this.passwordEncoder = passwordEncoder;
this.translationService = translationService;
this.userService = userService;
}
@GetMapping
@ -346,4 +354,35 @@ public class UserController {
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", messageSource.getMessage("usuarios.error.not-found", null, locale))));
}
@ResponseBody
@GetMapping(value = "api/get-users", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> getUsers(
@RequestParam(required = false) String role, // puede venir ausente
@RequestParam(required = false) String q,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(Math.max(0, page - 1), size);
Page<User> users = userService.findByRoleAndSearch(role, q, pageable);
boolean more = users.hasNext();
List<Map<String, Object>> results = users.getContent().stream()
.map(u -> {
Map<String, Object> m = new HashMap<>();
m.put("id", u.getId());
m.put("text", (u.getFullName() != null && !u.getFullName().isBlank())
? u.getFullName()
: u.getUserName());
return m;
})
.collect(Collectors.toList());
return Map.of(
"results", results,
"pagination", Map.of("more", more));
}
}

View File

@ -19,39 +19,60 @@ import org.springframework.lang.Nullable;
@Repository
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
// Aplicamos EntityGraph a la versión con Specification+Pageable
@Override
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
@NonNull
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
// Aplicamos EntityGraph a la versión con Specification+Pageable
@Override
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
@NonNull
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
Optional<User> findByUserNameIgnoreCase(String userName);
Optional<User> findByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCase(String userName);
// Para comprobar si existe al hacer signup
@Query(value = """
SELECT id, deleted, enabled
FROM users
WHERE LOWER(username) = LOWER(:userName)
LIMIT 1
""", nativeQuery = true)
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
// Para comprobar si existe al hacer signup
@Query(value = """
SELECT id, deleted, enabled
FROM users
WHERE LOWER(username) = LOWER(:userName)
LIMIT 1
""", nativeQuery = true)
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
// Nuevo: para login/negocio "activo"
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
// Nuevo: para login/negocio "activo"
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
// Para poder restaurar, necesitas leer ignorando @Where (native):
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
// Para poder restaurar, necesitas leer ignorando @Where (native):
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
List<User> findAllDeleted();
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
List<User> findAllDeleted();
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
@Query(value = """
SELECT DISTINCT u
FROM User u
JOIN u.rolesLink rl
JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""", countQuery = """
SELECT COUNT(DISTINCT u.id)
FROM User u
JOIN u.rolesLink rl
JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""")
Page<User> searchUsers(@Param("role") String role,
@Param("q") String q,
Pageable pageable);
}

View File

@ -1,7 +1,19 @@
package com.imprimelibros.erp.users;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface UserService extends UserDetailsService {
/**
* Busca usuarios por rol y texto libre (en username, email o fullName),
* con paginación.
*
* @param role nombre del rol (ej. "ROL_USER")
* @param query texto de búsqueda (puede ser null o vacío)
* @param pageable paginación
* @return página de usuarios
*/
Page<User> findByRoleAndSearch(String role, String query, Pageable pageable);
}

View File

@ -2,6 +2,8 @@ package com.imprimelibros.erp.users;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
@ -13,10 +15,19 @@ public class UserServiceImpl implements UserService {
this.userDao = userDao;
}
@Override
@Override
public UserDetails loadUserByUsername(String username) {
User user = userDao.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(username)
.orElseThrow(() -> new UsernameNotFoundException("No existe usuario activo: " + username));
return new UserDetailsImpl(user);
}
// ===== Búsqueda para Select2 =====
@Override
public Page<User> findByRoleAndSearch(String role, String query, Pageable pageable) {
if (query == null || query.isBlank()) query = null;
return userDao.searchUsers(role, query, pageable);
}
}

View File

@ -3,8 +3,8 @@ spring.application.name=erp
#
# Logging
#
logging.level.org.springframework.security=DEBUG
logging.level.root=WARN
logging.level.org.springframework.security=ERROR
logging.level.root=ERROR
logging.level.org.springframework=ERROR
@ -18,7 +18,7 @@ spring.datasource.password=om91irrDctd
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.show-sql=false
#
@ -33,12 +33,10 @@ safekat.api.password=Safekat2024
#
# Debug JPA / Hibernate
#
#spring.jpa.show-sql=true
#logging.level.org.hibernate.SQL=DEBUG
#logging.level.org.hibernate.orm.jdbc.bind=TRACE
#spring.jpa.properties.hibernate.format_sql=true
#
# Resource chain
# Activa el resource chain y versionado por contenido
@ -73,4 +71,24 @@ spring.mail.properties.mail.smtp.starttls.enable=true
#
# Remove JSESSIONID from URL
#
server.servlet.session.persistent=false
server.servlet.session.persistent=false
#
# GeoIP
#
geoip.enabled=true
geoip.maxmind.enabled=true
geoip.http.enabled=true
#
# Hibernate Timezone
#
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
#
# PDF Templates
#
# PDF Templates
imprimelibros.pdf.templates.PRESUPUESTO_presupuesto-a4=imprimelibros/pdf/presupuesto-a4
imprimelibros.pdf.templates.FACTURA_factura-a4=imprimelibros/pdf/factura-a4

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 MiB

View File

@ -7,6 +7,8 @@ app.guardar=Guardar
app.editar=Editar
app.eliminar=Eliminar
app.imprimir=Imprimir
app.acciones.siguiente=Siguiente
app.acciones.anterior=Anterior
app.bienvenido=Bienvenido
app.perfil=Perfil
@ -14,5 +16,8 @@ app.mensajes=Mensajes
app.logout=Cerrar sesión
app.sidebar.inicio=Inicio
app.sidebar.presupuestos=Presupuestos
app.sidebar.configuracion=Configuración
app.sidebar.usuarios=Usuarios
app.sidebar.configuracion=Configuración
app.errors.403=No tienes permiso para acceder a esta página.

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,14 @@
cart.title=Cesta de la compra
cart.empty=Tu cesta de la compra está vacía.
cart.item.presupuesto-numero=Presupuesto #
cart.precio=Precio
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.tramitar=Tramitar pedido
cart.resumen.fidelizacion=Si tiene descuento por fidelización, se aplicará al tramitar el pedido.

View File

@ -0,0 +1,34 @@
pdf.company.name=Impresión ImprimeLibros S.L.
pdf.company.address=C/ José Picón, 28 local A
pdf.company.postalcode=28028
pdf.company.city=Madrid
pdf.company.phone=+34 910052574
pdf.presupuesto=PRESUPUESTO
pdf.factura=FACTURA
pdf.pedido=PEDIDO
# Presupuesto
pdf.presupuesto.number=PRESUPUESTO Nº:
pdf.presupuesto.client=CLIENTE:
pdf.presupuesto.date=FECHA:
pdf.presupuesto.titulo=Título:
pdf.table.tirada=TIRADA
pdf.table.impresion=IMPRESIÓN
pdf.table.servicios=SERVICIOS
pdf.table.iva-4=IVA 4%
pdf.table.iva-21=IVA 21%
pdf.table.precio-total=PRECIO TOTAL
pdf.servicios-adicionales=Servicios adicionales:
pdf.ejemplares-deposito-legal=Impresión de {0} ejemplares de depósito legal<br />
pdf.datos-maquetacion=Datos de maquetación:
pdf.datos-marcapaginas=Datos de marcapáginas:
pdf.incluye-envio=El presupuesto incluye el envío a una dirección de la península.
pdf.politica-privacidad=Política de privacidad
pdf.politica-privacidad.responsable=Responsable: Impresión Imprime Libros - CIF: B04998886 - Teléfono de contacto: 910052574
pdf.politica-privacidad.correo-direccion=Correo electrónico: info@imprimelibros.com - Dirección postal: Calle José Picón, Nº 28 Local A, 28028, Madrid
pdf.politica-privacidad.aviso=Le comunicamos que los datos que usted nos facilite quedarán incorporados en nuestro registro interno de actividades de tratamiento con el fin de llevar a cabo una adecuada gestión fiscal y contable. Los datos proporcionados se conservarán mientras se mantenga la relación comercial o durante los años necesarios para cumplir con las obligaciones legales. Así mismo, los datos no serán cedidos a terceros salvo en aquellos casos en que exista una obligación legal. Tiene derecho a acceder a sus datos personales, rectificar los datos inexactos, solicitar su supresión, limitar alguno de los tratamientos u oponerse a algún uso vía e-mail, personalmente o mediante correo postal.

View File

@ -1,3 +1,6 @@
presupuesto.title=Presupuestos
presupuesto.editar.title=Editar presupuesto
presupuesto.eliminar=Eliminar presupuesto
presupuesto.datos-generales=Datos Generales
presupuesto.interior=Interior
presupuesto.cubierta=Cubierta
@ -6,6 +9,33 @@ presupuesto.extras=Extras
presupuesto.resumen=Resumen
presupuesto.add-to-presupuesto=Añadir al presupuesto
presupuesto.calcular=Calcular
presupuesto.add=Añadir presupuesto
presupuesto.guardar=Guardar
presupuesto.add-to-cart=Añadir a la cesta
presupuesto.nav.presupuestos-cliente=Presupuestos cliente
presupuesto.nav.presupuestos-anonimos=Presupuestos anónimos
presupuesto.estado.borrador=Borrador
presupuesto.estado.aceptado=Aceptado
presupuesto.estado.modificado=Modificado
presupuesto.tabla.id=ID
presupuesto.tabla.numero=Número
presupuesto.tabla.titulo=Título
presupuesto.tabla.cliente=Cliente
presupuesto.tabla.encuadernacion=Encuadernación
presupuesto.tabla.cubierta=Cubierta
presupuesto.tabla.tipo-impresion=Tipo de impresión
presupuesto.tabla.tirada=Tirada
presupuesto.tabla.paginas=Páginas
presupuesto.tabla.estado=Estado
presupuesto.tabla.total-iva=Total (con IVA)
presupuesto.tabla.updated-at=Actualizado el
presupuesto.tabla.pais=País
presupuesto.tabla.region=Región
presupuesto.tabla.ciudad=Ciudad
presupuesto.tabla.acciones=Acciones
# Pestaña datos generales de presupuesto
presupuesto.informacion-libro=Información del libro
@ -44,6 +74,15 @@ presupuesto.espiral-descripcion=Espiral (a partir de 20 páginas)
presupuesto.wire-o=Wire-O
presupuesto.wireo=Wire-O
presupuesto.wire-o-descripcion=Wire-O (a partir de 20 páginas)
presupuesto.informacion-adicional=Información adicional
presupuesto.informacion-adicional-descripcion=Datos adicionales
presupuesto.iva-reducido=I.V.A reducido
presupuesto.iva-reducido-descripcion=Se verificará que el pedido cumpla con los requisitos establecidos en el Artículo 91 de la Ley 37/1992, sobre inserción de publicidad, antes de proceder con su producción, lo que garantiza la aplicación del IVA reducido del 4%.
presupuesto.entrega=Entrega
presupuesto.entrega.peninsula=Península y Baleares
presupuesto.entrega.canarias=Canarias
presupuesto.entrega.paises-ue=Países UE
presupuesto.encuadernacion-descripcion=Seleccione la encuadernación del libro
presupuesto.continuar-interior=Continuar a diseño interior
@ -63,8 +102,8 @@ presupuesto.offset-blanco-volumen=Offset Blanco Volumen
presupuesto.offset-ahuesado=Offset Ahuesado
presupuesto.offset-ahuesado-volumen=Offset Ahuesado Volumen
presupuesto.estucado-mate=Estucado Mate
presupuesto.volver-datos-generales=Volver a datos generales
presupuesto.continuar-cubierta=Continuar a diseño cubierta
presupuesto.volver-datos-generales=Datos generales
presupuesto.continuar-cubierta=Diseño cubierta
# Pestaña cubierta
presupuesto.plantilla-cubierta=Plantilla de cubierta
@ -102,8 +141,8 @@ presupuesto.cartulina-grafica=Cartulina gráfica
presupuesto.estucado-mate-cubierta=Estucado mate
presupuesto.gramaje-cubierta=Gramaje cubierta
presupuesto.gramaje-cubierta-descripcion=Seleccione el gramaje para la cubierta
presupuesto.volver-interior=Volver a diseño interior
presupuesto.continuar-seleccion-tirada=Continuar a selección de tirada
presupuesto.volver-interior=Diseño interior
presupuesto.continuar-seleccion-tirada=Selección de tirada
presupuesto.offset=Offset
presupuesto.estucado=Estucado
presupuesto.verjurado=Verjurado
@ -139,8 +178,8 @@ presupuesto.precio-unidad=Precio por unidad
presupuesto.seleccionar-tirada=Seleccionar tirada
presupuesto.tirada-seleccionada=Seleccionada
presupuesto.unidades=UNIDADES
presupuesto.volver-seleccion-tirada=Volver a selección de tirada
presupuesto.continuar-extras-libro=Continuar a extras del libro
presupuesto.volver-seleccion-tirada=Selección de tirada
presupuesto.continuar-extras-libro=Extras del libro
presupuesto.error-obtener-precio=No se pudo obtener el precio para los datos introducidos. Por favor, contacte con el soporte técnico.
#pestaña extras del libro
@ -148,6 +187,7 @@ presupuesto.extras=Servicios Extras
presupuesto.extras-descripcion=Seleccione los servicios adicionales que desea añadir al presupuesto
presupuesto.extras-retractilado=Retractilado
presupuesto.extras-isbn=ISBN
presupuesto.extras-service-isbn=ISBN
presupuesto.extras-deposito-legal=Depósito Legal
presupuesto.extras-deposito-legal-descripcion=Se añadirán 4 ejemplares a la tirada
presupuesto.extras-revision-archivos=Revisión de archivos
@ -158,32 +198,34 @@ presupuesto.extras-marcapaginas=Marcapáginas
presupuesto.extras-maquetacion=Maquetación
presupuesto.extras-ferro-digital-ribbon=Incluido
presupuesto.extras-calcular=Calcular
presupuesto.volver-cubierta=Volver a diseño cubierta
presupuesto.volver-cubierta=Diseño cubierta
presupuesto.finalizar=Finalizar presupuesto
presupuesto.calcular-presupuesto=Calcular presupuesto
presupuesto.consultar-soporte=Consultar con soporte
# Pestaña resumen del presupuesto
presupuesto.resumen.tamanio=Tamaño
presupuesto.resumen.tabla.descripcion=Descripción
presupuesto.resumen.tabla.cantidad=Cantidad
presupuesto.resumen.tabla.precio-unidad=Precio/unidad
presupuesto.resumen.tabla.precio-total=Precio total
presupuesto.resumen.tabla.base=Base
presupuesto.resumen.tabla.iva=I.V.A. (4%)
presupuesto.resumen.tabla.iva4=I.V.A. (4%)
presupuesto.resumen.tabla.iva21=I.V.A. (21%)
presupuesto.resumen.tabla.total=Total presupuesto
presupuesto.resumen-texto=Impresion de {0} unidades encuadernadas en {1} en {2} con {3} páginas en formato {4} x {5} mm. \
presupuesto.resumen-texto=Impresion de {0} ejemplares con {3} páginas, tamaño {4}x{5} mm e impresas en {2} con encuadernación {1}. \
<ul> \
<li>Papel interior {6} {7} gr.</li> \
<li>Cubierta {8} en {9} {10} gr.</li>
presupuesto.resumen-texto-impresion-caras-cubierta=<li>Impresa a {0}.</li>
presupuesto.resumen-texto-solapas-cubierta=<li>Solapas de {0} mm.</li>
presupuesto.resumen-texto-guardas-cabezada= <li>Guardas {0} en {1} {2}. Color de la cabezada: {3}.</li>
presupuesto.resumen-texto-acabado-cubierta= <li>Acabado {0}. </li>
presupuesto.resumen-texto-acabado-cubierta= <li>Acabado: {0}. </li>
presupuesto.resumen-texto-end=</ul>
presupuesto.resumen-texto-sobrecubierta=<li>Sobrecubierta impresa en {0} {1} gr. <ul><li>Acabado {2}</li><li>Solapas: {3} mm.</li></ul></li>
presupuesto.resumen-texto-faja=<li>Faja impresa en {0} {1} gr. con un alto de {2} mm. <ul><li>Acabado {3}</li><li>Solapas: {4} mm.</li></ul></li>
presupuesto.resumen-texto-sobrecubierta=<li>Sobrecubierta impresa en {0} {1} gr. <ul><li>Acabado: {2}</li><li>Solapas: {3} mm.</li></ul></li>
presupuesto.resumen-texto-faja=<li>Faja impresa en {0} {1} gr. con un alto de {2} mm. <ul><li>Acabado: {3}</li><li>Solapas: {4} mm.</li></ul></li>
presupuesto.resumen-deposito-legal=Ejemplares para el Depósito Legal
presupuesto.volver-extras=Volver a extras
presupuesto.volver-extras=Extras del libro
presupuesto.resumen.inicie-sesion=Inicie sesión para continuar
presupuesto.resumen.agregar-cesta=Agregar a la cesta
@ -201,6 +243,7 @@ presupuesto.papel-gramaje=Papel y gramaje
# Presupuesto de maquetación
presupuesto.maquetacion=Presupuesto de maquetación
presupuesto.maquetacion.num-caracteres=Número de caracteres
presupuesto.maquetacion.caracteres=caracteres
presupuesto.maquetacion.num-caracteres-descripcion=Caracteres con espacios (obtenidos desde Word)
presupuesto.maquetacion.formato=Formato
presupuesto.maquetacion.formato-descripcion=Seleccione el tamaño que más se aproxime
@ -239,6 +282,32 @@ presupuesto.marcapaginas.acabado.plastificado-mate-2c=Plastificado mate 2/C
presupuesto.marcapaginas.precio-unidad=Precio por unidad
presupuesto.marcapaginas.precio-total=Precio total
# Mensajes de eliminación de presupuesto
presupuesto.delete.title=Eliminar presupuesto
presupuesto.delete.button=Si, ELIMINAR
presupuesto.delete.text=¿Está seguro de que desea eliminar este presupuesto?<br>Esta acción no se puede deshacer.
presupuesto.delete.ok.title=Presupuesto eliminado
presupuesto.delete.ok.text=El presupuesto ha sido eliminado con éxito.
presupuesto.exito.eliminado=Presupuesto eliminado con éxito.
presupuesto.error.delete-internal-error=No se puede eliminar: error interno.
presupuesto.error.delete-permission-denied=No se puede eliminar: permiso denegado.
presupuesto.error.delete-not-found=No se puede eliminar: presupuesto no encontrado.
presupuesto.error.delete-not-draft=Solo se pueden eliminar presupuestos en estado Borrador.
# Añadir presupuesto
presupuesto.add.tipo=Tipo de presupuesto
presupuesto.add.anonimo=Anónimo
presupuesto.add.cliente=De cliente
presupuesto.add.next=Siguiente
presupuesto.add.cancel=Cancelar
presupuesto.add.select-client=Seleccione cliente
presupuesto.add.error.options=Debe seleccionar una opción
presupuesto.add.error.options-client=Debe seleccionar un cliente
presupuesto.add.error.save.title=Error al guardar
presupuesto.error.save-internal-error=No se puede guardar: error interno.
presupuesto.exito.guardado=Presupuesto guardado con éxito.
presupuesto.exito.guardado-actualizado=Presupuesto actualizado con éxito.
# Errores
presupuesto.errores-title=Corrija los siguientes errores:
presupuesto.errores.titulo=El título es obligatorio
@ -248,6 +317,7 @@ presupuesto.errores.paginasNegro.required=El número de páginas en negro es obl
presupuesto.errores.paginasNegro.par=El número de páginas en negro debe ser par
presupuesto.errores.paginasColor.required=El número de páginas en color es obligatorio
presupuesto.errores.paginasColor.par=El número de páginas en color debe ser par
presupuesto.errores.tipo-paginas-cosido-grapado=Para encuadernación cosido o grapado, sólo se pueden seleccionar o bien páginas a color o páginas en blanco y negro. No se pueden mezclar.
presupuesto.errores.tipo-encuadernacion=Seleccione el tipo de libro
presupuesto.errores.ancho=El ancho no puede estar vacío
presupuesto.errores.ancho.min_max=El ancho tiene que estar en el rango [{0}, {1}] mm;
@ -261,6 +331,11 @@ presupuesto.errores.solapas-cubierta=Seleccione si desea o no solapas en la cubi
presupuesto.errores.papel-cubierta=Seleccione el tipo de papel para la cubierta
presupuesto.errores.gramaje-cubierta=Seleccione el gramaje del papel para la cubierta
presupuesto.errores.acabado-cubierta=Seleccione el acabado de la cubierta
presupuesto.errores.error-interior=Se ha producido un error al procesar el interior. Error {0}. Por favor, contacte con soporte.
presupuesto.errores.presupuesto-no-existe=El presupuesto con ID {0} no existe.
presupuesto.errores.presupuesto-maquetacion=No se pudo calcular el presupuesto de maquetación.
presupuesto.errores.presupuesto-marcapaginas=No se pudo calcular el presupuesto de marcapáginas.
presupuesto.errores.presupuesto-marcapaginas=No se pudo calcular el presupuesto de marcapáginas.
presupuesto.info.presupuestos-anonimos-view=Estos presupuestos son anónimos y no están asociados a ningún cliente. No se guardarán los cambios (sólo consulta).

View File

@ -928,7 +928,7 @@ File: Main Css File
font-family: "Public Sans", sans-serif;
}
.navbar-menu .navbar-nav .nav-sm .nav-link:before {
content: "";
content: none;
width: 6px;
height: 1.5px;
background-color: var(--vz-vertical-menu-sub-item-color);
@ -1780,15 +1780,15 @@ File: Main Css File
}
[data-layout=semibox] .main-content {
margin-left: calc(250px + 25px);
padding: 0 6%;
padding: 0 2%;
}
[data-layout=semibox] .footer {
left: calc(250px + 6% + 1.5rem + 25px);
right: calc(6% + 1.5rem);
left: calc(250px + 2% + 1.5rem + 25px);
right: calc(2% + 1.5rem);
}
[data-layout=semibox] #page-topbar {
left: calc(250px + 6% + 1.5rem + 25px);
right: calc(6% + 1.5rem);
left: calc(250px + 2% + 1.5rem + 25px);
right: calc(2% + 1.5rem);
top: 25px;
border-radius: 0.25rem;
-webkit-transition: all 0.5s ease;
@ -1801,10 +1801,10 @@ File: Main Css File
margin-left: calc(180px + 25px);
}
[data-layout=semibox][data-sidebar-size=md] #page-topbar {
left: calc(180px + 6% + 1.5rem + 25px);
left: calc(180px + 2% + 1.5rem + 25px);
}
[data-layout=semibox][data-sidebar-size=md] .footer {
left: calc(180px + 6% + 1.5rem + 25px);
left: calc(180px + 2% + 1.5rem + 25px);
}
[data-layout=semibox][data-sidebar-size=sm] .main-content {
margin-left: calc(70px + 25px);
@ -1813,19 +1813,19 @@ File: Main Css File
top: 25px;
}
[data-layout=semibox][data-sidebar-size=sm] #page-topbar {
left: calc(70px + 6% + 1.5rem + 25px);
left: calc(70px + 2% + 1.5rem + 25px);
}
[data-layout=semibox][data-sidebar-size=sm] .footer {
left: calc(70px + 6% + 1.5rem + 25px);
left: calc(70px + 2% + 1.5rem + 25px);
}
[data-layout=semibox][data-sidebar-size=sm-hover] .main-content {
margin-left: calc(70px + 25px);
}
[data-layout=semibox][data-sidebar-size=sm-hover] #page-topbar {
left: calc(70px + 6% + 1.5rem + 25px);
left: calc(70px + 2% + 1.5rem + 25px);
}
[data-layout=semibox][data-sidebar-size=sm-hover] .footer {
left: calc(70px + 6% + 1.5rem + 25px);
left: calc(70px + 2% + 1.5rem + 25px);
}
}
[data-layout=semibox] .mx-n4 {
@ -6714,6 +6714,38 @@ a {
border-left-color: #ff7f5d;
}
/* ---- NAV SECUNDARIO CON PESTAÑITA OUTLINE ---- */
/* Solo la pestaña activa: estilo outline + pestañita */
.nav-secondary-outline.arrow-navtabs .nav-link.active {
color: #ff7f5d;
background: transparent;
border: 1px solid #ff7f5d; /* como el botón Iniciar sesión */
border-radius: .5rem;
position: relative;
}
/* Si el tema ya dibuja una flecha sólida, la anulamos */
.nav-secondary-outline.arrow-navtabs .nav-link.active::before {
display: none !important;
}
/* Pestañita tipo outline */
.nav-secondary-outline.arrow-navtabs .nav-link.active::after {
content: "";
position: absolute;
left: 50%;
bottom: -7px; /* ajusta si lo necesitas */
width: 12px;
height: 12px;
transform: translateX(-50%) rotate(225deg);
background: var(--vz-body-bg, var(--bs-body-bg, #fff)); /* color del fondo de la página */
border-left: 1px solid #ff7f5d;
border-top: 1px solid #ff7f5d;
z-index: 2;
}
.nav-success .nav-link.active {
color: #fff;
background-color: #3cd188;
@ -16329,4 +16361,5 @@ span.flatpickr-weekday {
position: absolute;
bottom: -18px;
left: -35px;
}
}

View File

@ -0,0 +1,59 @@
/* =======================
Bootstrap for PDF (compatible con OpenHTMLtoPDF)
======================= */
/* -- TEXT ALIGN -- */
.text-start { text-align: left !important; }
.text-center { text-align: center !important; }
.text-end { text-align: right !important; }
/* -- FONT WEIGHT -- */
.fw-normal { font-weight: 400 !important; }
.fw-semibold { font-weight: 600 !important; }
.fw-bold { font-weight: 700 !important; }
/* -- SPACING (margin/padding) -- */
.mt-1 { margin-top: 0.25rem !important; }
.mt-2 { margin-top: 0.5rem !important; }
.mt-3 { margin-top: 1rem !important; }
.mb-1 { margin-bottom: 0.25rem !important; }
.mb-2 { margin-bottom: 0.5rem !important; }
.mb-3 { margin-bottom: 1rem !important; }
.p-1 { padding: 0.25rem !important; }
.p-2 { padding: 0.5rem !important; }
.p-3 { padding: 1rem !important; }
/* -- TABLE -- */
.table {
width: 100%;
border-collapse: collapse;
font-size: 10.5pt;
}
.table th,
.table td {
padding: 6px;
border: 1px solid #dee2e6;
}
.table thead th {
background-color: #f8f9fa;
font-weight: 700;
}
/* -- BORDER -- */
.border {
border: 1px solid #dee2e6 !important;
}
.border-0 { border: 0 !important; }
/* -- BACKGROUND -- */
.bg-light { background-color: #f8f9fa !important; }
.bg-white { background-color: #fff !important; }
/* -- DISPLAY UTILS (limited) -- */
.d-block { display: block !important; }
.d-inline { display: inline !important; }
.d-inline-block { display: inline-block !important; }

View File

@ -1,3 +1,10 @@
/* habilita container queries en tu contenedor principal */
#presupuesto-row {
container-type: inline-size;
container-name: presupuesto;
}
/* === Contenedor de cada opción === */
.image-container {
position: relative;
@ -23,10 +30,6 @@
transition: border 0.3s ease;
}
/* === Borde visible cuando está seleccionada === */
.image-container.selected {
border-color: #92b2a7;
}
/* === Imagen interna === */
.image-container img {
@ -37,6 +40,16 @@
transition: transform 0.3s ease;
}
/* Borde cuando está seleccionado, sin JS */
.image-container:has(input:checked) {
border-color: #92b2a7;
}
/* Si quieres la animación al seleccionar */
.image-container:has(input:checked) .image-presupuesto {
animation: zoomPop 800ms cubic-bezier(0.68,-0.55,0.27,1.55);
}
/* === Animación de zoom con rebote === */
.image-presupuesto.zoom-anim {
animation: zoomPop 800ms cubic-bezier(0.68, -0.55, 0.27, 1.55);
@ -242,6 +255,10 @@
transform-origin: center center;
will-change: transform;
transition: transform .22s ease, box-shadow .22s ease, border-color .22s ease, background .22s ease;
display: block;
width: clamp(155px, 28vw, 250px); /* crece fluido pero nunca <155 ni >250 */
min-width: 155px !important;
max-width: 250px !important;
}
/* sin elevación al hover */
@ -408,6 +425,7 @@
.form-switch-presupuesto .form-check-input:checked {
border-color: #92b2a7;
background-color: #cbcecd;
}
.form-switch-custom.form-switch-presupuesto .form-check-input:checked::before {
@ -478,4 +496,85 @@
#presupuesto-row .summary-col{
overflow: hidden;
}
}
/* ==== BOTONES ==== */
/* --- Base de la barra (dos filas) --- */
.buttons-bar{
display:flex;
flex-direction:column;
gap:.75rem;
width:100%;
}
/* Fila 1: acciones centradas */
.buttons-row.center{
display:flex;
flex-wrap:wrap;
justify-content:center;
gap:.75rem;
}
/* Fila 2: extremos (prev izq / next dcha) */
.buttons-row.split{
display:flex;
align-items:center;
justify-content:space-between;
gap:.75rem;
}
/* El “slot” derecho (puede contener Siguiente o Login) */
.buttons-row.split .right-slot{
margin-left:auto; /* en desktop empuja a la derecha */
display:flex;
align-items:center;
gap:.75rem;
}
/* --- Limpieza de márgenes heredados para consistencia de gap --- */
.buttons-bar .btn.mx-2{ margin-left:0 !important; margin-right:0 !important; }
/* ================= MOBILE / CONTENEDOR ESTRECHO ================= */
/* A partir de aquí, todo ocurre cuando el contenedor #presupuesto-row
es estrecho (p.ej., móvil u offcanvas abierto). Ajusta 576px si lo necesitas */
@container presupuesto (max-width: 576px){
/* Fila 1: acciones apiladas y a 100% ancho */
.buttons-row.center{
width:100%;
}
.buttons-row.center .btn{
width:100%;
justify-content:center; /* icono + texto centrados */
max-width: 100%; /* sin límites */
}
/* Fila 2: apilar elementos; cada bloque a 100% */
.buttons-row.split{
flex-direction:column;
align-items:stretch; /* hace que los hijos estiren a 100% */
justify-content:flex-start;
}
.buttons-row.split > *{
width:100%;
}
/* Botón “Anterior” si existe → 100% y centrado */
.buttons-row.split > button.btn{
width:100%;
justify-content:center;
}
/* Bloque derecho (Siguiente / Login) a 100% */
.buttons-row.split .right-slot{
width:100%;
margin-left:0; /* neutraliza el empuje a la derecha */
justify-content:stretch; /* estira su contenido */
}
.buttons-row.split .right-slot .btn{
width:100%;
justify-content:center; /* texto centrado */
}
}

View File

@ -0,0 +1,395 @@
:root {
--verde: #92b2a7;
--letterspace: 8px;
/* ← puedes ajustar este valor en el root */
-ink: #1b1e28;
--muted: #5b6472;
--accent: #0ea5e9;
/* azul tira a cyan */
--line: #e6e8ef;
--bg-tag: #f4f7fb;
}
/* Open Sans (rutas relativas desde css → fonts) */
@font-face {
font-family: "Open Sans";
src: url("../fonts/OpenSans-Regular.ttf") format("truetype");
font-weight: 400;
}
@font-face {
font-family: "Open Sans";
src: url("../fonts/OpenSans-SemiBold.ttf") format("truetype");
font-weight: 600;
}
@font-face {
font-family: "Open Sans";
src: url("../fonts/OpenSans-Bold.ttf") format("truetype");
font-weight: 700;
}
@page {
size: A4;
margin: 0;
}
html,
body {
font-family: "Open Sans" !important;
color: var(--ink);
font-size: 11pt;
line-height: 1.35;
}
.page-content {
padding: 15mm 14mm 28mm 14mm; /* ↑ deja 10mm extra para no pisar el footer */
box-sizing: border-box; /* para que el padding no desborde */
}
body.has-watermark {
background-image: none !important;
}
/* ====== HEADER (tabla) ====== */
.il-header {
width: 100%;
border-collapse: collapse;
margin: 0 0 8mm 0;
/* ↓ espacio bajo el header */
}
.il-left,
.il-right {
vertical-align: middle;
}
.il-left {
width: 50%;
}
.il-right {
width: 50%;
text-align: right;
}
.il-logo {
height: 70px;
}
/* ← tamaño logo */
/* Caja superior derecha con esquinas */
.il-company-box {
display: inline-block;
align-items: end;
/* para alinear a la derecha sin ocupar todo */
position: relative;
padding: 4mm 4mm;
/* ← espacio texto ↔ esquinas */
color: #000;
font-size: 10.5pt;
/* ← tamaño de letra */
line-height: 1;
/* ← separación entre líneas */
max-width: 75mm;
/* ← ancho máximo de la caja */
text-align: center;
}
/* Esquinas */
.il-company-box .corner {
position: absolute;
width: 20px;
/* ← anchura esquina */
height: 20px;
/* ← altura esquina */
border-color: #92b2a7;
/* ← color esquina */
}
.corner.tl {
top: 0;
left: 0;
border-top: 2px solid #92b2a7;
border-left: 2px solid #92b2a7;
}
.corner.tr {
top: 0;
right: 0;
border-top: 2px solid #92b2a7;
border-right: 2px solid #92b2a7;
}
.corner.bl {
bottom: 0;
left: 0;
border-bottom: 2px solid #92b2a7;
border-left: 2px solid #92b2a7;
}
.corner.br {
bottom: 0;
right: 0;
border-bottom: 2px solid #92b2a7;
border-right: 2px solid #92b2a7;
}
.company-line {
margin: 1.5mm 0;
}
/* Nueva banda verde PRESUPUESTO */
.doc-banner {
width: 100%;
background-color: #92b2a7 !important; /* ← tu verde corporativo */
color: white;
text-align: center;
padding: 2mm 0;
margin-bottom: 4mm;
display: block; /* evita conflictos */
}
.banner-text {
font-family: "Open Sans", Arial, sans-serif !important;
font-weight: 400;
font-size: 20pt;
letter-spacing: 8px; /* ← configurable */
}
/* ficha superior */
.sheet-info {
width: 100%;
border-collapse: collapse;
margin: 4mm 0 6mm 0;
font-size: 10.5pt;
}
.sheet-info td {
border: 1px solid var(--line);
padding: 4px 6px;
}
.sheet-info .lbl {
color: var(--muted);
margin-right: 4px;
}
/*.sheet-info .val {
}*/
/* Línea título libro */
.line-title {
font-family: "Open Sans", Arial, sans-serif !important;
margin: 3mm 0 5mm 0;
padding: 2px 4px;
font-size: 10.5pt;
font-weight: 600;
color: #5c5c5c;
}
.line-title .lbl {
margin-right: 6px;
font-weight: 600;
}
/* Specs 2 columnas */
.specs-wrapper {
width: 180mm;
margin-left: 15mm; /* ← margen izquierdo real del A4 */
margin-right: auto; /* opcional */
color: #5c5c5c;
font-size: 9pt;
}
.align-with-text {
margin-left: 1mm;
margin-right: 0;
width: auto;
}
.specs {
display: table;
width: 100%;
table-layout: fixed;
margin-bottom: 6mm;
}
.specs .col {
display: table-cell;
width: 50%;
padding-right: 6mm;
vertical-align: top;
}
.specs .col:last-child {
padding-right: 0;
}
/* Listas sin margen superior por defecto */
ul, ol {
margin-top: 0;
margin-bottom: 0rem; /* si quieres algo abajo */
padding-left: 1.25rem; /* sangría */
}
/* Párrafos con menos margen inferior */
p {
margin: 0 0 .5rem;
}
/* Si una lista va justo después de un texto o título, que no tenga hueco arriba */
p + ul, p + ol,
h1 + ul, h2 + ul, h3 + ul, h4 + ul, h5 + ul, h6 + ul,
div + ul, div + ol {
margin-top: 0;
}
.block-title {
text-transform: uppercase;
font-weight: 700;
color: var(--accent);
font-size: 8pt;
margin: 2mm 0 1mm 0;
}
.kv {
margin: 1mm 0;
}
.kv span {
color: var(--muted);
display: inline-block;
min-width: 55%;
}
.kv b {
font-weight: 600;
}
.subblock {
margin-top: 3mm;
}
.services {
margin: 0;
padding-left: 14px;
}
.services li {
margin: 1mm 0;
}
/* Bloque marcapáginas */
.bookmark {
margin-top: 4mm;
border: 1px dashed var(--line);
padding: 3mm;
background: #fff;
}
.bookmark .bk-title {
font-weight: 700;
margin-bottom: 2mm;
}
/* Tabla de precios (tiradas) */
.prices {
width: 100%;
border-collapse: collapse;
margin-top: 6mm;
font-size: 10.5pt;
}
.prices thead th {
text-align: left;
padding: 3px;
border-bottom: 2px solid var(--accent);
background: #eef8fe;
font-weight: 500;
}
.prices tbody td {
border-bottom: 1px solid var(--line);
padding: 3px;
}
.prices .col-tirada {
width: 22%;
font-weight: 500;
}
/* Footer */
.footer {
position: fixed;
left: 14mm;
right: 14mm;
bottom: 18mm;
border-top: 1px solid var(--line);
padding-top: 4mm;
font-size: 7.5pt;
color: var(--muted);
z-index: 10; /* sobre la marca */
background: transparent;
}
.footer .address {
display: table-cell;
width: 45%;
}
.footer .privacy {
display: table-cell;
width: 55%;
}
.pv-title {
font-weight: 700;
margin-bottom: 1mm;
color: var(--ink);
}
.pv-text {
line-height: 1.25;
}
.page-count {
margin-top: 2mm;
text-align: right;
font-size: 9pt;
color: var(--muted);
}
.page::after {
content: counter(page);
}
.pages::after {
content: counter(pages);
}
/* Caja a página completa SIN vw/vh y SIN z-index negativo */
.watermark {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0; /* ocupa toda la HOJA */
pointer-events: none;
z-index: 0; /* debajo del contenido */
}
.watermark img {
position: absolute;
top: 245mm; /* baja/sube (7085%) */
left: 155mm; /* desplaza a la derecha si quieres */
transform: translate(-50%, -50%) rotate(-15deg);
width: 60%; /* tamaño grande, ya no hay recorte por márgenes */
max-width: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -22,7 +22,7 @@
if (!lang || lang === getCurrentLang()) return;
// Guarda la preferencia (opcional)
try { localStorage.setItem("language", lang); } catch {}
try { localStorage.setItem("language", lang); } catch { }
// Redirige con ?lang=... para que Spring cambie el Locale y renderice en ese idioma
const url = new URL(location.href);
@ -48,7 +48,212 @@
url.searchParams.set("lang", saved);
location.replace(url); // alinea y no deja historial extra
}
if (document.getElementById("topnav-hamburger-icon")) {
document.getElementById("topnav-hamburger-icon").addEventListener("click", toggleHamburgerMenu);
}
window.addEventListener("resize", windowResizeHover);
windowResizeHover();
initsAlert();
}
function windowResizeHover() {
feather.replace();
var windowSize = document.documentElement.clientWidth;
if (windowSize < 1025 && windowSize > 767) {
document.body.classList.remove("twocolumn-panel");
if (sessionStorage.getItem("data-layout") == "twocolumn") {
document.documentElement.setAttribute("data-layout", "twocolumn");
if (document.getElementById("customizer-layout03")) {
document.getElementById("customizer-layout03").click();
}
twoColumnMenuGenerate();
initTwoColumnActiveMenu();
isCollapseMenu();
}
if (sessionStorage.getItem("data-layout") == "vertical") {
document.documentElement.setAttribute("data-sidebar-size", "sm");
}
if (document.querySelector(".hamburger-icon")) {
document.querySelector(".hamburger-icon").classList.add("open");
}
} else if (windowSize >= 1025) {
document.body.classList.remove("twocolumn-panel");
if (sessionStorage.getItem("data-layout") == "twocolumn") {
document.documentElement.setAttribute("data-layout", "twocolumn");
if (document.getElementById("customizer-layout03")) {
document.getElementById("customizer-layout03").click();
}
twoColumnMenuGenerate();
initTwoColumnActiveMenu();
isCollapseMenu();
}
if (sessionStorage.getItem("data-layout") == "vertical") {
document.documentElement.setAttribute(
"data-sidebar-size",
sessionStorage.getItem("data-sidebar-size")
);
}
if (document.querySelector(".hamburger-icon")) {
document.querySelector(".hamburger-icon").classList.remove("open");
}
} else if (windowSize <= 767) {
document.body.classList.remove("vertical-sidebar-enable");
document.body.classList.add("twocolumn-panel");
if (sessionStorage.getItem("data-layout") == "twocolumn") {
document.documentElement.setAttribute("data-layout", "vertical");
hideShowLayoutOptions("vertical");
isCollapseMenu();
}
if (sessionStorage.getItem("data-layout") != "horizontal") {
document.documentElement.setAttribute("data-sidebar-size", "lg");
}
if (document.querySelector(".hamburger-icon")) {
document.querySelector(".hamburger-icon").classList.add("open");
}
}
var isElement = document.querySelectorAll("#navbar-nav > li.nav-item");
Array.from(isElement).forEach(function (item) {
item.addEventListener("click", menuItem.bind(this), false);
item.addEventListener("mouseover", menuItem.bind(this), false);
});
}
function menuItem(e) {
if (e.target && e.target.matches("a.nav-link span")) {
if (elementInViewport(e.target.parentElement.nextElementSibling) == false) {
e.target.parentElement.nextElementSibling.classList.add("dropdown-custom-right");
e.target.parentElement.parentElement.parentElement.parentElement.classList.add("dropdown-custom-right");
var eleChild = e.target.parentElement.nextElementSibling;
Array.from(eleChild.querySelectorAll(".menu-dropdown")).forEach(function (item) {
item.classList.add("dropdown-custom-right");
});
} else if (elementInViewport(e.target.parentElement.nextElementSibling) == true) {
if (window.innerWidth >= 1848) {
var elements = document.getElementsByClassName("dropdown-custom-right");
while (elements.length > 0) {
elements[0].classList.remove("dropdown-custom-right");
}
}
}
}
if (e.target && e.target.matches("a.nav-link")) {
if (elementInViewport(e.target.nextElementSibling) == false) {
e.target.nextElementSibling.classList.add("dropdown-custom-right");
e.target.parentElement.parentElement.parentElement.classList.add("dropdown-custom-right");
var eleChild = e.target.nextElementSibling;
Array.from(eleChild.querySelectorAll(".menu-dropdown")).forEach(function (item) {
item.classList.add("dropdown-custom-right");
});
} else if (elementInViewport(e.target.nextElementSibling) == true) {
if (window.innerWidth >= 1848) {
var elements = document.getElementsByClassName("dropdown-custom-right");
while (elements.length > 0) {
elements[0].classList.remove("dropdown-custom-right");
}
}
}
}
}
function elementInViewport(el) {
if (el) {
var top = el.offsetTop;
var left = el.offsetLeft;
var width = el.offsetWidth;
var height = el.offsetHeight;
if (el.offsetParent) {
while (el.offsetParent) {
el = el.offsetParent;
top += el.offsetTop;
left += el.offsetLeft;
}
}
return (
top >= window.pageYOffset &&
left >= window.pageXOffset &&
top + height <= window.pageYOffset + window.innerHeight &&
left + width <= window.pageXOffset + window.innerWidth
);
}
}
function initsAlert() {
var alerts = document.querySelectorAll('.alert.alert-dismissible');
alerts.forEach(function (el) {
// Solo si está visible
if (el.classList.contains('show')) {
setTimeout(function () {
// Usa la API de Bootstrap para cerrar con transición
var bsAlert = bootstrap.Alert.getOrCreateInstance(el);
bsAlert.close();
}, 5000);
}
});
}
document.addEventListener("DOMContentLoaded", initLanguage);
function toggleHamburgerMenu() {
var windowSize = document.documentElement.clientWidth;
if (windowSize > 767)
document.querySelector(".hamburger-icon").classList.toggle("open");
//For collapse horizontal menu
if (document.documentElement.getAttribute("data-layout") === "horizontal") {
document.body.classList.contains("menu") ? document.body.classList.remove("menu") : document.body.classList.add("menu");
}
//For collapse vertical menu
if (document.documentElement.getAttribute("data-layout") === "vertical") {
if (windowSize <= 1025 && windowSize > 767) {
document.body.classList.remove("vertical-sidebar-enable");
document.documentElement.getAttribute("data-sidebar-size") == "sm" ?
document.documentElement.setAttribute("data-sidebar-size", "") :
document.documentElement.setAttribute("data-sidebar-size", "sm");
} else if (windowSize > 1025) {
document.body.classList.remove("vertical-sidebar-enable");
document.documentElement.getAttribute("data-sidebar-size") == "lg" ?
document.documentElement.setAttribute("data-sidebar-size", "sm") :
document.documentElement.setAttribute("data-sidebar-size", "lg");
} else if (windowSize <= 767) {
document.body.classList.add("vertical-sidebar-enable");
document.documentElement.setAttribute("data-sidebar-size", "lg");
}
}
// semibox menu
if (document.documentElement.getAttribute("data-layout") === "semibox") {
if (windowSize > 767) {
// (document.querySelector(".hamburger-icon").classList.contains("open")) ? document.documentElement.setAttribute('data-sidebar-visibility', "show"): '';
if (document.documentElement.getAttribute('data-sidebar-visibility') == "show") {
document.documentElement.getAttribute("data-sidebar-size") == "lg" ?
document.documentElement.setAttribute("data-sidebar-size", "sm") :
document.documentElement.setAttribute("data-sidebar-size", "lg");
} else {
document.getElementById("sidebar-visibility-show").click();
document.documentElement.setAttribute("data-sidebar-size", document.documentElement.getAttribute("data-sidebar-size"));
}
} else if (windowSize <= 767) {
document.body.classList.add("vertical-sidebar-enable");
document.documentElement.setAttribute("data-sidebar-size", "lg");
}
}
//Two column menu
if (document.documentElement.getAttribute("data-layout") == "twocolumn") {
document.body.classList.contains("twocolumn-panel") ?
document.body.classList.remove("twocolumn-panel") :
document.body.classList.add("twocolumn-panel");
}
}
})();

View File

@ -0,0 +1,28 @@
$(() => {
const badge = document.getElementById("cart-item-count");
if (!badge) return;
function updateCartCount() {
fetch("/cart/count")
.then(res => res.ok ? res.text() : "0")
.then(count => {
const n = parseInt(count || "0", 10);
if (isNaN(n) || n === 0) {
badge.classList.add("d-none");
} else {
badge.textContent = n;
badge.classList.remove("d-none");
}
})
.catch(() => badge.classList.add("d-none"));
}
// Actualizar al cargar
updateCartCount();
// Si quieres refrescar cada 60s:
setInterval(updateCartCount, 60000);
// generate a custom event to update the cart count from other scripts
document.addEventListener("update-cart", updateCartCount);
});

View File

@ -0,0 +1,67 @@
import { formateaMoneda } from '../../imprimelibros/utils.js';
$(() => {
updateTotal();
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;
}
$("#base-cesta").text(formateaMoneda(base));
if (iva4 > 0) {
$("#iva-4-cesta").text(formateaMoneda(iva4));
$("#tr-iva-4").show();
} else {
$("#tr-iva-4").hide();
}
if (iva21 > 0) {
$("#iva-21-cesta").text(formateaMoneda(iva21));
$("#tr-iva-21").show();
} else {
$("#tr-iva-21").hide();
}
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 card = $(this).closest('.card.product');
// 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/delete/item/${cartItemId}`, {
method: 'DELETE',
headers: { [csrfHeader]: csrfToken }
});
if (!res.ok) {
console.error('Error al eliminar. Status:', res.status);
return;
}
else{
card?.remove();
updateTotal();
}
} catch (err) {
console.error('Error en la solicitud:', err);
}
});
});

View File

@ -26,6 +26,22 @@
pageLength: 50,
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
dom: 'lrBtip',
buttons: {
dom: {
button: {
className: 'btn btn-sm btn-outline-primary me-1'
},
buttons: [
{ extend: 'copy' },
{ extend: 'csv' },
{ extend: 'excel' },
{ extend: 'pdf' },
{ extend: 'print' },
{ extend: 'colvis' }
],
}
},
ajax: {
url: '/configuracion/margenes-presupuesto/datatable',
method: 'GET',

View File

@ -7,17 +7,17 @@ class imagen_presupuesto {
this.group = data.group || ""; // Grupo al que pertenece el radio
this.texto = data.texto || ""; // Texto de la etiqueta
this.selected = data.selected || false;
this.extraClass = (data.extraClass && !(data.extraClass===undefined))? (data.extraClass + ' ') : '';
this.extraClass = (data.extraClass && !(data.extraClass === undefined)) ? (data.extraClass + ' ') : '';
this.extraData = data.extra_data || {}; // Datos extra opcionales
}
render() {
const contenedor = $('<div>', {
const contenedor = $('<label>', {
id: this.id,
class: `${this.extraClass + ' '}image-container imagen-selector${this.selected ? ' selected' : ''}`
class: `${this.extraClass + ' '}image-container imagen-selector}`
});
// Añadir atributos extra al contenedor
for (const [key, value] of Object.entries(this.extraData)) {
for (const [key, value] of Object.entries(this.extraData)) {
contenedor.attr(`data-${key}`, value);
}
contenedor.attr("data-summary-text", this.texto); // Para el resumen
@ -26,7 +26,8 @@ class imagen_presupuesto {
type: 'radio',
name: this.group,
value: this.id,
hidden: true
hidden: true,
checked: this.selected
});
@ -37,13 +38,12 @@ class imagen_presupuesto {
alt: this.alt
});
const etiqueta = $('<label>', {
for: this.id + '-img',
class: 'form-label',
const etiqueta = $('<div>', {
class: 'form-text text-center',
text: this.texto
});
contenedor.append(imagen, etiqueta);
contenedor.append(input, imagen, etiqueta);
return contenedor;
}

View File

@ -1,20 +1,24 @@
$('.imagen-container-group').on('click', '.image-container', function (e) {
e.preventDefault(); // <- evita que el label dispare el cambio nativo (2º change)
e.stopPropagation();
$('.imagen-container-group').on('click', '.image-container', function () {
const clicked = $(this);
const group = clicked.closest('.imagen-container-group');
// Limpiar selección anterior
// Si ya está seleccionado, no hagas nada
const $radio = clicked.find('input[type="radio"]');
if ($radio.prop('checked')) return;
// Limpiar selección anterior (solo clases/animación)
group.find('.image-container').removeClass('selected')
.find('.image-presupuesto').removeClass('zoom-anim');
// Marcar nuevo seleccionado
// Marcar nuevo seleccionado (clases/animación)
clicked.addClass('selected');
// Aplicar animación de zoom
const img = clicked.find('.image-presupuesto');
void img[0].offsetWidth; // Forzar reflow
void img[0].offsetWidth;
img.addClass('zoom-anim');
clicked.find('input[type="radio"]').prop('checked', true).trigger('change');
// Marca el radio y dispara UN único change
$radio.prop('checked', true).trigger('change');
});

View File

@ -3,7 +3,6 @@ import { formateaMoneda } from "../utils.js";
$(document).on('change', '#maquetacion', function (e) {
e.preventDefault();
if ($('#maquetacion').is(':checked')) {
$.get("/presupuesto/public/maquetacion/form", function (data) {

View File

@ -152,11 +152,11 @@ $(document).on('change', '.marcapaginas-item', () => {
const payload = {
marcapaginas_tirada: parseInt($('#marcapaginas-tirada').val()) || 100,
tamanio_marcapaginas: $('#tamanio-marcapaginas').val() || '_50x140_',
caras_impresion: $('#caras-impresion').val() || 'una_cara',
papel_marcapaginas: $('#papel-marcapaginas').val() || 'cartulina_grafica',
gramaje_marcapaginas: parseInt($('#gramaje-marcapaginas').val()) || 300,
acabado_marcapaginas: $('#acabado-marcapaginas').val() || 'ninguno',
tamanio: $('#tamanio-marcapaginas').val() || '_50x140_',
carasImpresion: $('#caras-impresion').val() || 'una_cara',
papel: $('#papel-marcapaginas').val() || 'cartulina_grafica',
gramaje: parseInt($('#gramaje-marcapaginas').val()) || 300,
acabado: $('#acabado-marcapaginas').val() || 'ninguno',
};
$(document).trigger('marcapaginas:update', [payload]);
});
@ -167,11 +167,11 @@ function loadMarcapaginasData() {
$(document).one('marcapaginas:response', (e, stored) => {
$('#marcapaginas-tirada').val(stored.marcapaginas_tirada);
$('#tamanio-marcapaginas').val(stored.tamanio_marcapaginas);
$('#caras-impresion').val(stored.caras_impresion);
$('#papel-marcapaginas').val(stored.papel_marcapaginas);
$('#gramaje-marcapaginas').val(stored.gramaje_marcapaginas);
$('#acabado-marcapaginas').val(stored.acabado_marcapaginas);
$('#tamanio-marcapaginas').val(stored.tamanio);
$('#caras-impresion').val(stored.carasImpresion);
$('#papel-marcapaginas').val(stored.papel);
$('#gramaje-marcapaginas').val(stored.gramaje);
$('#acabado-marcapaginas').val(stored.acabado);
});
$(document).trigger('marcapaginas:request');

View File

@ -7,9 +7,9 @@ export function updateEncuadernacion() {
}
}
export function updateFormato(){
export function updateFormato() {
if($('#formato-personalizado').is(':checked')) {
if ($('#formato-personalizado').is(':checked')) {
$('#summary-formato').text($('#ancho').val() + 'x' + $('#alto').val() + ' mm');
} else {
const $selected = $('#formato option:selected');
@ -21,29 +21,29 @@ export function updateFormato(){
export function updatePaginas() {
const paginas = $('#paginas').val();
$('#summary-paginas').text(paginas );
$('#summary-paginas').text(paginas);
const paginasColor = $('#paginas-color').val();
$('#summary-paginas-color').text(paginasColor );
$('#summary-paginas-color').text(paginasColor);
const paginasNegro = $('#paginas-negro').val();
$('#summary-paginas-negro').text(paginasNegro );
$('#summary-paginas-negro').text(paginasNegro);
}
export function updateTipoImpresion() {
const $selected = $('.opcion-color.selected');
const $selected = $('.opcion-color input:checked');
if ($selected.length > 0) {
const resumen = $selected.data('summary-text') || $selected.attr('id');
const resumen = $selected.closest('.opcion-color').data('summary-text') || $selected.closest('.opcion-color').attr('id');
$('#summary-tipo-interior').text(resumen);
}
}
export function updatePapelInterior() {
const $selected = $('.papel-interior.selected');
const $selected = $('.papel-interior input:checked');
if ($selected.length > 0) {
const resumen = $selected.data('summary-text') || $selected.val();
const resumen = $selected.closest('.papel-interior').data('summary-text') || $selected.closest('.papel-interior').val();
$('#summary-papel-interior').text(resumen);
}
}
@ -51,34 +51,36 @@ export function updatePapelInterior() {
export function updateGramajeInterior() {
const gramaje = $('input[name="gramaje-interior"]:checked');
if(gramaje.length > 0) {
if (gramaje.length > 0) {
$('#summary-gramaje-interior').text(gramaje.data('gramaje'));
}
}
export function updateTapaCubierta(){
export function updateTapaCubierta() {
const $selected = $('.tapa-cubierta.selected');
const $selected = $('.tapa-cubierta input:checked');
if ($selected.length > 0) {
const resumen = $selected.data('summary-text') || $selected.attr('id');
const resumen = $selected.closest('.tapa-cubierta').data('summary-text') || $selected.closest('.tapa-cubierta').attr('id');
$('#summary-tapa-cubierta').text(resumen);
}
if($selected.attr('id') === 'tapaBlanda') {
if ($selected.closest('.tapa-cubierta').attr('id') === 'tapaBlanda') {
$('.tapa-blanda-row').removeClass('d-none');
$('.tapa-dura-row').addClass('d-none');
$('#summary-cubierta-solapas').text($('#sin-solapas').hasClass('selected') ? $('#sin-solapas').data('summary-text') : $('#con-solapas').data('summary-text'));
if($('#con-solapas').hasClass('selected')) {
$('#summary-tamanio-solapa-row').removeClass('d-none');
const $solapasSelected = $('.solapas-cubierta input:checked');
if ($solapasSelected.val() === 'conSolapas') {
$('#summary-cubierta-solapas').text($('#con-solapas').data('summary-text'));
$('.summary-tamanio-solapa-row').removeClass('d-none');
$('#summary-tamanio-solapa').text($('#tamanio-solapas-cubierta').val() + ' mm');
} else {
$('#summary-tamanio-solapa-row').addClass('d-none');
$('#summary-cubierta-solapas').text($('#sin-solapas').data('summary-text'));
$('.summary-tamanio-solapa-row').addClass('d-none');
$('#summary-tamanio-solapa').text('');
}
$('#summary-impresion-cubierta-row').removeClass('d-none');
$('#summary-impresion-cubierta').text($('#impresion-cubierta option:selected').text());
}
else{
else {
$('.tapa-blanda-row').addClass('d-none');
$('.tapa-dura-row').removeClass('d-none');
$('#summary-papel-guardas').text($('#papel-guardas option:selected').text());
@ -88,26 +90,31 @@ export function updateTapaCubierta(){
}
export function updatePapelCubierta() {
const $selected = $('.papel-cubierta.selected');
const $selected = $('.papel-cubierta input:checked');
if ($selected.length > 0) {
const resumen = $selected.data('summary-text') || $selected.val();
const resumen = $selected.closest('.papel-cubierta').data('summary-text') || $selected.closest('.papel-cubierta').val();
$('#summary-papel-cubierta').text(resumen);
}
}
export function updateGramajeCubierta() {
const gramaje = $('input[name="gramaje-cubierta"]:checked');
if(gramaje.length > 0) {
$('#summary-gramaje-cubierta').text(gramaje.data('gramaje'));
export function updateGramajeCubierta(gramaje) {
if (!gramaje) {
const gramaje = $('input[name="gramaje-cubierta"]:checked');
if (gramaje.length > 0) {
$('#summary-gramaje-cubierta').text(gramaje.data('gramaje'));
}
} else {
$('#summary-gramaje-cubierta').text(gramaje);
}
}
export function updateAcabadoCubierta() {
const acabado = $('input[name="acabado-cubierta"]:checked');
if(acabado.length > 0) {
if (acabado.length > 0) {
let labelText = '';
const id = acabado.attr('id');
if (id) {
labelText = $(`label[for="${id}"]`).text().trim();
}
@ -123,20 +130,20 @@ export function updateAcabadoCubierta() {
export function updateSobreCubierta() {
if($('#sobrecubierta').hasClass('active')) {
if ($('#sobrecubierta').hasClass('active')) {
$('#summary-sobrecubierta-papel-gramaje').text($('#papel-sobrecubierta option:selected').text());
$('#summary-sobrecubierta-tamanio-solapa').text($('#tamanio-solapas-sobrecubierta').val() + ' mm');
$('#summary-sobrecubierta-acabado').text($('#sobrecubierta-acabado option:selected').text());
$('#summary-sobrecubierta-acabado').text($('#sobrecubierta-acabado option:selected').text());
}
}
export function updateFaja() {
if($('#faja').hasClass('active')) {
if ($('#faja').hasClass('active')) {
$('#summary-faja-papel-gramaje').text($('#papel-faja option:selected').text());
$('#summary-faja-alto-faja').text($('#alto-faja').val() + ' mm');
$('#summary-faja-tamanio-solapa').text($('#tamanio-solapas-faja').val() + ' mm');
$('#summary-faja-acabado').text($('#faja-acabado option:selected').text());
$('#summary-faja-acabado').text($('#faja-acabado option:selected').text());
}
}
@ -146,7 +153,7 @@ export function updateExtras() {
$tbody.empty();
// Agregar las filas de servicios extras
$('.service-checkbox:checked').each(function() {
$('.service-checkbox:checked').each(function () {
const $servicio = $(this);
const resumen = $(`label[for="${$servicio.attr('id')}"] .service-title`).text().trim() || $servicio.attr('id');
const price = $(`label[for="${$servicio.attr('id')}"] .service-price`).text().trim() || $servicio.attr('price');

View File

@ -36,7 +36,7 @@ class TiradaCard {
const col = $(`
<div class="col d-flex">
<label class="tirada-card ${this.selected ? 'selected' : ''} w-100 h-100" for="tirada-${this.id}">
<label class="tirada-card ${this.selected ? 'selected' : ''} h-100" for="tirada-${this.id}">
<input type="radio" name="${this.name}" id="tirada-${this.id}" value="${this.unidades}" ${this.selected ? 'checked' : ''}>
<div class="title">${this.#title()}</div>

View File

@ -0,0 +1,4 @@
import PresupuestoWizard from './wizard.js';
const app = new PresupuestoWizard({ mode: 'public' });
app.init();

View File

@ -0,0 +1,14 @@
import PresupuestoWizard from './wizard.js';
if($('#presupuesto_id').val() == null || $('#presupuesto_id').val() === '') {
sessionStorage.removeItem('formData');
}
const app = new PresupuestoWizard({
mode: 'private',
readonly: false,
presupuestoId: $('#presupuesto_id').val(),
canSave: true,
useSessionCache: true,
});
app.init();

View File

@ -0,0 +1,12 @@
import PresupuestoWizard from './wizard.js';
// remove formData from sessionStorage to avoid conflicts
sessionStorage.removeItem('formData');
const app = new PresupuestoWizard({
mode: 'public',
readonly: false,
canSave: true,
useSessionCache: false,
});
app.init();

View File

@ -0,0 +1,9 @@
import PresupuestoWizard from './wizard.js';
const app = new PresupuestoWizard({
mode: 'public',
readonly: true,
canSave: false,
useSessionCache: true,
});
app.init();

View File

@ -4,9 +4,16 @@ import TiradaCard from "./tirada-price-card.js";
import * as Summary from "./summary.js";
import { formateaMoneda } from "../utils.js";
class PresupuestoCliente {
export default class PresupuestoWizard {
constructor() {
constructor(options = {}) {
this.opts = Object.assign({
mode: 'public',
presupuestoId: null,
readonly: false,
canSave: false,
useSessionCache: true
}, options);
this.DEBUG = true; // Activar o desactivar el modo de depuración
@ -15,20 +22,22 @@ class PresupuestoCliente {
titulo: '',
autor: '',
isbn: '',
tirada1: '',
tirada1: 10,
tirada2: '',
tirada3: '',
tirada4: '',
ancho: '',
alto: '',
ancho: 148,
alto: 218,
formatoPersonalizado: false,
paginasNegro: '',
paginasColor: '',
paginasNegro: 32,
paginasColor: 0,
posicionPaginasColor: '',
tipoEncuadernacion: 'fresado',
entregaTipo: 'peninsula',
ivaReducido: true,
},
interior: {
tipoImpresion: 'negro',
tipoImpresion: 'negrohq',
papelInteriorId: 3,
gramajeInterior: 80,
},
@ -61,14 +70,19 @@ class PresupuestoCliente {
}
},
servicios: {
servicios: [],
servicios: [{
"id": "ferro-digital",
"label": "Ferro Digital",
"price": 0,
"units": 1
}],
datosMarcapaginas: {
marcapaginas_tirada: 100,
tamanio_marcapaginas: '_50x140_',
caras_impresion: 'una_cara',
papel_marcapaginas: 'cartulina_grafica',
gramaje_marcapaginas: 300,
acabado_marcapaginas: 'ninguno',
tamanio: '_50x140_',
carasImpresion: 'una_cara',
papel: 'cartulina_grafica',
gramaje: 300,
acabado: 'ninguno',
resultado: {
precio_unitario: 0,
precio: 0
@ -92,7 +106,7 @@ class PresupuestoCliente {
}
}
},
selectedTirada: null,
selectedTirada: 10,
}
// pestaña datos generales
@ -115,7 +129,9 @@ class PresupuestoCliente {
this.divPosicionPaginasColor = $('#div-posicion-paginas-color');
this.posicionPaginasColor = $('#posicionPaginasColor');
this.paginas = $('#paginas');
this.btn_next_datos_generales = $('#next-datos-generales');
this.entregaTipo = $('#entregaTipo');
this.ivaReducido = $('#iva-reducido');
this.btnIvaReducidoDetail = $('#btn-iva-reducido-detail');
this.datos_generales_alert = $('#datos-generales-alert');
// pestaña interior
@ -123,7 +139,7 @@ class PresupuestoCliente {
this.divOpcionesColor = $('#div-opciones-color');
this.divPapelInterior = $('#div-papel-interior');
this.divGramajeInterior = $("#div-gramaje-interior");
this.interior_alert = $('#interior-alert');
this.interior_alert = $('#form-errors');
// pestaña cubierta
this.divSolapasCubierta = $('#div-solapas-cubierta');
@ -166,20 +182,59 @@ class PresupuestoCliente {
this.summaryTableSobrecubierta = $('#summary-sobrecubierta');
this.summaryTableFaja = $('#summary-faja');
this.summaryTableExtras = $('#summary-servicios-extras');
// variable para evitar disparar eventos al cargar datos
this._hydrating = false;
}
init() {
async init() {
const stored = sessionStorage.getItem("formData");
const self = this;
$.ajaxSetup({
beforeSend: function (xhr) {
const token = document.querySelector('meta[name="_csrf"]')?.content;
const header = document.querySelector('meta[name="_csrf_header"]')?.content;
if (token && header) xhr.setRequestHeader(header, token);
}
});
const root = document.getElementById('presupuesto-app');
const mode = root?.dataset.mode || 'public';
const presupuestoId = this.opts.presupuestoId || null;
let stored = null;
if (this.opts.useSessionCache) {
stored = sessionStorage.getItem("formData");
}
this.#initDatosGenerales();
this.#initCubierta();
if (stored) {
this.formData = JSON.parse(stored);
this.#loadDatosGeneralesData();
if (presupuestoId && mode !== 'public') {
await fetch(`/presupuesto/api/get?id=${encodeURIComponent(presupuestoId)}`, {
headers: {
'Accept': 'application/json',
}
})
.then(r => r.json())
.then(dto => {
sessionStorage.removeItem("formData");
this.formData = dto;
this.#cacheFormData();
this.#loadDatosGeneralesData();
});
} else {
if (stored) {
sessionStorage.removeItem("formData");
this.formData = JSON.parse(stored);
this.#cacheFormData();
this.#loadDatosGeneralesData();
}
}
this.#initCubierta();
Summary.updateEncuadernacion();
Summary.updateFormato();
Summary.updatePaginas();
@ -205,10 +260,131 @@ class PresupuestoCliente {
}
});
if (this.opts.canSave) {
$('.guardar-presupuesto').on('click', async () => {
const success = await this.#guardarPresupuesto();
if (success) {
Swal.fire({
icon: 'success',
title: window.languageBundle?.get('presupuesto.exito.guardado') || 'Guardado',
timer: 1800,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
showConfirmButton: false
});
}
});
$('.add-cart-btn').on('click', async () => {
const success = await this.#guardarPresupuesto();
if (success) {
const res = await $.ajax({
url: `/cart/add/${this.opts.presupuestoId}`,
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
// Si el backend devuelve { redirect: "/cart" }
if (res?.redirect) {
window.location.assign(res.redirect); // o replace()
}
}
});
}
// Usa function() para que `this` sea el botón
$('.btn-imprimir').on('click', (e) => {
e.preventDefault();
// obtén el id de donde lo tengas (data-attr o variable global)
const id = this.opts.presupuestoId;
const url = `/api/pdf/presupuesto/${id}?mode=download`;
// Truco: crear <a> y hacer click
const a = document.createElement('a');
a.href = url;
a.target = '_self'; // descarga en la misma pestaña
// a.download = `presupuesto-${id}.pdf`; // opcional, tu server ya pone filename
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
async #guardarPresupuesto() {
const alert = $('#form-errors');
const servicios = [];
const payload = {
id: this.opts.presupuestoId,
mode: this.opts.mode,
presupuesto: this.#getPresupuestoData(),
servicios: this.formData.servicios.servicios,
datosMaquetacion: this.formData.servicios.datosMaquetacion,
datosMarcapaginas: this.formData.servicios.datosMarcapaginas,
cliente_id: $('#cliente_id').val() || null,
};
try {
alert.addClass('d-none').find('ul').empty();
return await $.ajax({
url: '/presupuesto/api/save',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload)
}).then((data) => {
if (data.id) this.opts.presupuestoId = data.id;
return true;
}).catch((xhr, status, error) => {
const errors = xhr.responseJSON;
if (errors && typeof errors === 'object') {
if (!this.DEBUG && xhr.responseJSON.error && xhr.responseJSON.error == 'Internal Server Error') {
console.error("Error al validar los datos generales. Internal Server Error");
return;
}
Object.values(errors).forEach(errorMsg => {
alert.find('ul').append(`<li>${errorMsg}</li>`);
});
alert.removeClass('d-none');
} else {
alert.find('ul').append('<li>Error desconocido. Por favor, inténtelo de nuevo más tarde.</li>');
$(window).scrollTop(0);
alert.removeClass('d-none');
}
window.scrollTo({ top: 0, behavior: 'smooth' });
return false;
});
} catch (e) {
Swal.fire({
icon: 'error',
title: window.languageBundle?.get('presupuesto.add.error.save.title') || 'Error',
text: e?.message || '',
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
return false;
}
}
#cacheFormData() {
sessionStorage.setItem("formData", JSON.stringify(this.formData));
if (!this.opts.useSessionCache) return;
sessionStorage.setItem('formData', JSON.stringify(this.formData));
}
#changeTab(idContenidoTab) {
@ -216,6 +392,7 @@ class PresupuestoCliente {
if (tabButton) {
bootstrap.Tab.getOrCreateInstance(tabButton).show();
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
#getPresupuestoData() {
@ -224,6 +401,8 @@ class PresupuestoCliente {
...this.#getInteriorData(),
...this.#getCubiertaData(),
selectedTirada: this.formData.selectedTirada
};
const sobrecubierta = data.sobrecubierta;
@ -279,6 +458,27 @@ class PresupuestoCliente {
******************************/
#initDatosGenerales() {
this.btnIvaReducidoDetail.on('click', () => {
Swal.fire({
position: 'top-end',
icon: 'info',
title: window.languageBundle.get('presupuesto.iva-reducido'),
html: `
<div class="acitivity-timeline p-4">
${window.languageBundle.get('presupuesto.iva-reducido-descripcion')}
</div>
`,
confirmButtonClass: 'btn btn-primary w-xs mt-2',
showConfirmButton: false,
showCloseButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
});
$('.datos-generales-data').on('change', () => {
const dataToStore = this.#getDatosGeneralesData();
this.#updateDatosGeneralesData(dataToStore);
@ -311,14 +511,15 @@ class PresupuestoCliente {
Summary.updateFormato();
});
this.btn_next_datos_generales.on('click', () => {
$('.btn-change-tab-datos-generales').on('click', () => {
this.#nextDatosGenerales();
});
// Eventos para el resumen
$(document).on('change', 'input[name="tipoEncuadernacion"]', (e) => {
if ($(e.target).is(':checked')) {
// Actualizar el resumen
Summary.updateEncuadernacion();
}
});
@ -381,6 +582,18 @@ class PresupuestoCliente {
$('.alto-faja-max').text(`max: ${this.formData.datosGenerales.alto} mm`);
// check if selected tirada is still valid
const tiradas = [
parseInt(this.formData.datosGenerales.tirada1),
parseInt(this.formData.datosGenerales.tirada2) || 0,
parseInt(this.formData.datosGenerales.tirada3) || 0,
parseInt(this.formData.datosGenerales.tirada4) || 0,
].filter(t => t > 0).sort((a, b) => a - b);
if (!tiradas.includes(this.formData.selectedTirada)) {
this.formData.selectedTirada = tiradas[0];
}
this.#loadInteriorData(data);
const interiorData = this.#getInteriorData();
@ -409,7 +622,9 @@ class PresupuestoCliente {
paginasNegro: this.paginasNegro.val(),
paginasColor: this.paginasColor.val(),
posicionPaginasColor: this.posicionPaginasColor.val(),
tipoEncuadernacion: ($('.tipo-libro.selected').length > 0) ? $('.tipo-libro.selected').attr('id') : 'fresado',
tipoEncuadernacion: $('.tipo-libro input:checked').val() || 'fresado',
entregaTipo: this.entregaTipo.val(),
ivaReducido: this.ivaReducido.is(':checked'),
};
}
@ -430,6 +645,8 @@ class PresupuestoCliente {
paginasColor: data.paginasColor,
posicionPaginasColor: data.posicionPaginasColor,
tipoEncuadernacion: data.tipoEncuadernacion,
entregaTipo: data.entregaTipo,
ivaReducido: data.ivaReducido,
};
}
@ -444,7 +661,7 @@ class PresupuestoCliente {
this.tirada4.val(this.formData.datosGenerales.tirada4);
this.paginasNegro.val(this.formData.datosGenerales.paginasNegro);
this.paginasColor.val(this.formData.datosGenerales.paginasColor);
this.paginasColor.val(this.formData.datosGenerales.paginasColor);;
this.posicionPaginasColor.val(this.formData.datosGenerales.posicionPaginasColor);
@ -454,8 +671,8 @@ class PresupuestoCliente {
this.alto.val(this.formData.datosGenerales.alto);
}
$('.tipo-libro').removeClass('selected');
$('.image-container#' + this.formData.datosGenerales.tipoEncuadernacion).trigger('click');
$('input[name="tipoEncuadernacion"][value="' + this.formData.datosGenerales.tipoEncuadernacion + '"]')
.prop('checked', true);
this.#updateTipoEncuadernacion();
this.formatoPersonalizado.trigger('change');
@ -475,6 +692,9 @@ class PresupuestoCliente {
this.formato.val(option.val()).trigger('change');
}
}
this.entregaTipo.val(this.formData.datosGenerales.entregaTipo);
this.ivaReducido.prop('checked', this.formData.datosGenerales.ivaReducido);
}
#getTamanio() {
@ -499,7 +719,7 @@ class PresupuestoCliente {
#updateTipoEncuadernacion() {
const paginas = parseInt($('#paginas').val());
const selectedTipo = $('.tipo-libro.selected').attr('id');
const selectedTipo = $('.tipo-libro input:checked').val();
$('.tipo-libro').removeClass('selected');
if (paginas < 32) {
@ -525,19 +745,17 @@ class PresupuestoCliente {
$('.tipo-libro#grapado').removeClass('d-none');
}
if (selectedTipo && $('.tipo-libro#' + selectedTipo).length > 0 && !$('.tipo-libro#' + selectedTipo).hasClass('d-none')) {
$('.tipo-libro#' + selectedTipo).addClass('selected');
}
else {
if (!(selectedTipo && $(`.tipo-libro input[value="${selectedTipo}"]`).length && !$(`.tipo-libro input[value="${selectedTipo}"]`).closest('.tipo-libro').hasClass('d-none'))) {
let firstVisible = $('.tipo-libro').not('.d-none').first();
if (firstVisible.length) {
firstVisible.addClass('selected');
firstVisible.trigger('click');
}
}
if ($('.tipo-libro.selected').length > 0) {
this.formData.datosGenerales.tipoEncuadernacion = $('.tipo-libro.selected').attr('id');
if ($('.tipo-libro input:checked').length > 0) {
this.formData.datosGenerales.tipoEncuadernacion = $('.tipo-libro input:checked').val();
Summary.updateEncuadernacion();
}
else {
@ -562,7 +780,9 @@ class PresupuestoCliente {
});
$(document).on('click', '.opcion-color', (e) => {
$(document).on('change', 'input[name="tipoImpresion"]', (e) => {
if (!$(e.target).is(':checked'))
return;
const data = this.#getPresupuestoData();
Summary.updateTipoImpresion();
@ -590,6 +810,7 @@ class PresupuestoCliente {
if (opcion.id === this.formData.interior.papelInteriorId) {
item.setSelected(true);
}
item.group = 'papel-interior';
this.divPapelInterior.append(item.render());
}
@ -613,10 +834,16 @@ class PresupuestoCliente {
});
});
$(document).on('click', '.papel-interior', (e) => {
$(document).on('change', 'input[name="papelInterior"]', (e) => {
if (!$(e.target).is(':checked'))
return;
const data = this.#getPresupuestoData();
this.interior_alert.addClass('d-none').find('#form-errors-alert-list').empty();
Summary.updatePapelInterior();
this.divGramajeInterior.removeClass('animate-fadeInUpBounce');
@ -641,9 +868,16 @@ class PresupuestoCliente {
const dataInterior = this.#getInteriorData();
this.#updateInteriorData(dataInterior);
this.#cacheFormData();
Summary.updatePapelInterior();
Summary.updateGramajeInterior();
}).fail((xhr, status, error) => {
this.interior_alert.removeClass('d-none');
const errors = xhr.responseJSON;
if (errors && typeof errors === 'object') {
this.interior_alert.find('#form-errors-alert-list').append(`<li>${errors.message}</li>`);
}
console.error("Error al obtener los gramajes de interior: ", xhr.responseText);
});
});
@ -679,14 +913,15 @@ class PresupuestoCliente {
$('.btn-change-tab-interior').on('click', (e) => {
let data = this.#getPresupuestoData();
const id = e.currentTarget.id;
const action = $(e.currentTarget).data('btn-action') || 'next';
this.interior_alert.addClass('d-none').find('#form-errors-alert-list').empty();
$.ajax({
url: '/presupuesto/public/validar/interior',
type: 'POST',
data: data,
success: (data) => {
if (id === 'btn-prev-interior') {
if (action === 'previous') {
this.summaryTableInterior.addClass('d-none');
this.#changeTab('pills-general-data');
} else {
@ -737,18 +972,16 @@ class PresupuestoCliente {
error: (xhr, status, error) => {
this.interior_alert.removeClass('d-none');
this.interior_alert.find('#inside-alert-list').empty();
this.interior_alert.find('#form-errors-alert-list').empty();
const errors = xhr.responseJSON;
if (errors && typeof errors === 'object') {
if (!this.DEBUG && xhr.responseJSON.error && xhr.responseJSON.error == 'Internal Server Error') {
console.error("Error al validar los datos generales. Internal Server Error");
return;
}
Object.values(errors).forEach(errorMsg => {
this.interior_alert.find('#inside-alert-list').append(`<li>${errorMsg}</li>`);
});
this.interior_alert.find('#form-errors-alert-list').append(`<li>${errors.message}</li>`);
} else {
this.interior_alert.find('#inside-alert-list').append('<li>Error desconocido. Por favor, inténtelo de nuevo más tarde.</li>');
this.interior_alert.find('#form-errors-alert-list').append('<li>Error desconocido. Por favor, inténtelo de nuevo más tarde.</li>');
}
$(window).scrollTop(0);
}
@ -769,6 +1002,7 @@ class PresupuestoCliente {
for (let i = 0; i < opciones_color.length; i++) {
const opcion = opciones_color[i];
const item = new imagen_presupuesto(opcion);
item.group = 'tipoImpresion';
item.extraClass = 'interior-data opcion-color';
if ((this.formData.interior.tipoImpresion === '' && i === 0) ||
this.formData.interior.tipoImpresion === opcion.id) {
@ -781,6 +1015,7 @@ class PresupuestoCliente {
for (let i = 0; i < opciones_papel_interior.length; i++) {
const opcion = opciones_papel_interior[i];
const item = new imagen_presupuesto(opcion);
item.group = 'papelInterior';
item.extraClass = 'interior-data papel-interior';
if (this.formData.interior.papelInteriorId == '' && i === 0 ||
this.formData.interior.papelInteriorId == opcion.extra_data["sk-id"]) {
@ -822,8 +1057,8 @@ class PresupuestoCliente {
#getInteriorData() {
const tipoImpresion = $('#div-opciones-color .image-container.selected').attr('id') || 'negro';
const papelInteriorId = $('#div-papel-interior .image-container.selected').data('sk-id') || 3;
const tipoImpresion = $('#div-opciones-color .image-container input:checked').parent().attr('id') || 'negro';
const papelInteriorId = $('#div-papel-interior .image-container input:checked').parent().data('sk-id') || 3;
const gramajeInterior = $('input[name="gramaje-interior"]:checked').data('gramaje') || 90;
return {
tipoImpresion: tipoImpresion,
@ -865,7 +1100,12 @@ class PresupuestoCliente {
`,
confirmButtonClass: 'btn btn-primary w-xs mt-2',
showConfirmButton: false,
showCloseButton: true
showCloseButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
});
@ -877,16 +1117,27 @@ class PresupuestoCliente {
html: window.languageBundle.get('presupuesto.impresion-cubierta-help'),
confirmButtonClass: 'btn btn-primary w-xs mt-2',
showConfirmButton: false,
showCloseButton: true
showCloseButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
});
$(document).on('click', '.tapa-cubierta', (e) => {
$(document).on('change', 'input[name="tipoCubierta"]', (e) => {
if (!$(e.target).is(':checked'))
return;
if (this._hydrating)
return;
$('.tapa-dura-options').eq(0).removeClass('animate-fadeInUpBounce');
$('.tapa-blanda-options').eq(0).removeClass('animate-fadeInUpBounce');
if (e.currentTarget.id === 'tapaBlanda') {
if (e.currentTarget.value === 'tapaBlanda') {
$('.tapa-blanda-options').eq(0).addClass('animate-fadeInUpBounce');
$('.tapa-dura-options').addClass('d-none');
$('.tapa-blanda-options').removeClass('d-none');
@ -897,13 +1148,16 @@ class PresupuestoCliente {
$('.tapa-dura-options').removeClass('d-none');
}
this.#getPapelesCubierta(e.currentTarget.id);
this.#getPapelesCubierta(e.currentTarget.value);
Summary.updateTapaCubierta();
});
$(document).on('click', '.solapas-cubierta', (e) => {
$(document).on('change', 'input[name="solapasCubierta"]', (e) => {
if (e.currentTarget.id === 'sin-solapas') {
if (!$(e.target).is(':checked'))
return;
if (e.currentTarget.closest('.image-container').id === 'sin-solapas') {
this.divSolapasCubierta.addClass('d-none');
}
else {
@ -917,7 +1171,8 @@ class PresupuestoCliente {
Summary.updateTapaCubierta();
});
$(document).on('click', '.papel-cubierta', (e) => {
$(document).on('change', 'input[name="papel-cubierta"]', (e) => {
const data = this.#getPresupuestoData();
@ -931,6 +1186,7 @@ class PresupuestoCliente {
}).done((data) => {
const gramajes = data.opciones_gramaje_cubierta;
this.divGramajeCubierta.empty();
this.#addGramajesCubierta(gramajes);
this.divGramajeCubierta.addClass('animate-fadeInUpBounce');
@ -939,6 +1195,7 @@ class PresupuestoCliente {
this.#cacheFormData();
Summary.updatePapelCubierta();
Summary.updateGramajeCubierta();
}).fail((xhr, status, error) => {
@ -952,11 +1209,14 @@ class PresupuestoCliente {
const gramaje = parseInt($('#' + inputId).data('gramaje'));
this.formData.cubierta.gramajeCubierta = gramaje;
this.#cacheFormData();
Summary.updateGramajeCubierta();
Summary.updateGramajeCubierta(gramaje);
});
$(document).on('change', '.datos-cubierta', (e) => {
if (this._hydrating)
return;
const dataToStore = this.#getCubiertaData();
this.#updateCubiertaData(dataToStore);
this.#cacheFormData();
@ -1017,9 +1277,9 @@ class PresupuestoCliente {
$('.btn-change-tab-cubierta').on('click', (e) => {
const data = this.#getPresupuestoData();
const id = e.currentTarget.id;
const action = $(e.currentTarget).data('btn-action') || 'next';
if (id === 'btn-prev-cubierta') {
if (action === 'previous') {
data.calcular = false;
}
else {
@ -1031,7 +1291,7 @@ class PresupuestoCliente {
type: 'POST',
data: data,
success: (data) => {
if (id === 'btn-prev-cubierta') {
if (action === 'previous') {
this.summaryTableCubierta.addClass('d-none');
this.#changeTab('pills-inside');
}
@ -1042,7 +1302,7 @@ class PresupuestoCliente {
}
},
error: (xhr, status, error) => {
if (id === 'btn-prev-cubierta') {
if (action === 'previous') {
this.#changeTab('pills-inside');
return;
}
@ -1096,13 +1356,13 @@ class PresupuestoCliente {
if (item.extraData["sk-id"] == this.formData.cubierta.papelCubiertaId) {
item.setSelected(true);
}
item.group = 'papel-cubierta';
this.divPapelCubierta.append(item.render());
}
if (this.divPapelCubierta.find('.image-container.selected').length === 0) {
this.divPapelCubierta.find('.image-container').first().addClass('selected');
this.formData.cubierta.papelCubiertaId =
this.divPapelCubierta.find('.image-container').first().data('sk-id') || 3;
if (this.divPapelCubierta.find('input[name="papel-cubierta"]:checked').length === 0) {
this.divPapelCubierta.find('input[name="papel-cubierta"]').first().prop('checked', true).trigger('change');
}
this.#addGramajesCubierta(data.opciones_gramaje_cubierta);
@ -1123,15 +1383,15 @@ class PresupuestoCliente {
#getCubiertaData() {
const tipoCubierta = $('.tapa-cubierta.selected').attr('id') || 'tapaBlanda';
const solapas = $('.solapas-cubierta.selected').id == 'sin-solapas' ? 0 : 1 || 0;
const tipoCubierta = $('.tapa-cubierta input:checked').val() || 'tapaBlanda';
const solapas = $('.solapas-cubierta input:checked').val() == 'sin-solapas' ? 0 : 1 || 0;
const tamanioSolapasCubierta = $('#tamanio-solapas-cubierta').val() || '80';
const cubiertaCaras = parseInt(this.carasImpresionCubierta.val()) || 2;
const papelGuardasId = parseInt($('#papel-guardas option:selected').data('papel-id')) || 3;
const gramajeGuardas = parseInt($('#papel-guardas option:selected').data('gramaje')) || 170;
const guardasImpresas = parseInt(this.guardasImpresas) || 0;
const guardasImpresas = parseInt(this.guardasImpresas.val()) || 0;
const cabezada = this.cabezada.val() || 'WHI';
const papelCubiertaId = $('#div-papel-cubierta .image-container.selected').data('sk-id') || this.formData.cubierta.papelCubiertaId || 3;
const papelCubiertaId = $('#div-papel-cubierta .image-container input:checked').parent().data('sk-id') || this.formData.cubierta.papelCubiertaId || 3;
const gramajeCubierta = $('input[name="gramaje-cubierta"]:checked').data('gramaje') || this.formData.cubierta.gramajeCubierta || 170;
const acabado = parseInt($(`input[name="acabado-cubierta"]:checked`).attr('sk-id')) || 1;
const sobrecubierta = this.sobrecubierta.hasClass('active');
@ -1210,7 +1470,7 @@ class PresupuestoCliente {
for (let i = 0; i < gramajes.length; i++) {
const gramaje = gramajes[i];
this.#addGramaje(this.divGramajeCubierta, 'gramaje-cubierta datos-cubierta', gramaje, 'gramaje-cubierta');
this.#addGramaje(this.divGramajeCubierta, 'gramaje-cubierta', gramaje, 'gramaje-cubierta');
// Seleccionar el gramaje por defecto
if (this.formData.cubierta.gramajeCubierta === '' && i === 0) {
@ -1229,8 +1489,10 @@ class PresupuestoCliente {
#loadCubiertaData() {
$('.tapa-cubierta').removeClass('selected');
$(`#${this.formData.cubierta.tipoCubierta}`).addClass('selected');
this._hydrating = true;
$('input[name="tipoCubierta"][value="' + this.formData.cubierta.tipoCubierta + '"]')
.prop('checked', true);
if (this.formData.cubierta.tipoCubierta === 'tapaBlanda') {
$('.tapa-blanda-options').removeClass('d-none');
@ -1246,15 +1508,16 @@ class PresupuestoCliente {
this.cabezada.val(this.formData.cubierta.cabezada);
}
$(`#${this.formData.cubierta.tipoCubierta}`).trigger('click');
this._hydrating = false;
$('input[name="tipoCubierta"][value="' + this.formData.cubierta.tipoCubierta + '"]').trigger('change');
if (this.formData.cubierta.solapasCubierta === 0) {
$('.solapas-cubierta#sin-solapas').addClass('selected');
$('.solapas-cubierta#sin-solapas input').prop('checked', true);
this.divSolapasCubierta.addClass('d-none');
}
else {
$('.solapas-cubierta').removeClass('selected');
$(`.solapas-cubierta#con-solapas`).addClass('selected');
$('.solapas-cubierta#con-solapas input').prop('checked', true);
this.divSolapasCubierta.removeClass('d-none');
this.carasImpresionCubierta.val(this.formData.cubierta.cubiertaCaras);
this.tamanioSolapasCubierta.val(this.formData.cubierta.tamanioSolapasCubierta);
@ -1307,9 +1570,9 @@ class PresupuestoCliente {
$(document).on('click', '.btn-change-tab-seleccion-tirada', (e) => {
const id = e.currentTarget.id;
const action = $(e.currentTarget).data('btn-action') || 'next';
if (id === 'btn-prev-seleccion-tirada') {
if (action === 'previous') {
this.#changeTab('pills-cover');
} else {
const data = this.#getPresupuestoData();
@ -1388,9 +1651,9 @@ class PresupuestoCliente {
$(document).on('click', '.btn-change-tab-extras', (e) => {
const id = e.currentTarget.id;
const action = $(e.currentTarget).data('btn-action') || 'next';
if (id === 'btn-prev-extras') {
if (action === 'previous') {
this.#changeTab('pills-seleccion-tirada');
this.summaryTableExtras.addClass('d-none');
} else {
@ -1398,17 +1661,32 @@ class PresupuestoCliente {
const servicios = [];
$('.service-checkbox:checked').each(function () {
const $servicio = $(this);
let price = 0;
if ($servicio.attr('id') === 'marcapaginas') {
price = self.formData.servicios.datosMarcapaginas.resultado.precio;
}
else if ($servicio.attr('id') === 'maquetacion') {
price = self.formData.servicios.datosMaquetacion.resultado.precio;
}
else {
price = $servicio.data('price') ?? $(`label[for="${$servicio.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), '');
}
servicios.push({
id: $servicio.attr('id') ?? $(`label[for="${$servicio.attr('id')}"] .service-title`).text().trim(),
label: $(`label[for="${$servicio.attr('id')}"] .service-title`).text().trim(),
units: $servicio.attr('id') === 'marcapaginas' ? self.formData.servicios.datosMarcapaginas.marcapaginas_tirada : 1,
price: $servicio.data('price') ?? $(`label[for="${$servicio.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), ''),
price: price,
});
});
const body = {
presupuesto: this.#getPresupuestoData(),
servicios: servicios
save: !this.opts.canSave,
mode: this.opts.mode,
servicios: servicios,
datosMaquetacion: this.formData.servicios.datosMaquetacion,
datosMarcapaginas: this.formData.servicios.datosMarcapaginas,
};
$.ajax({
@ -1418,6 +1696,12 @@ class PresupuestoCliente {
data: JSON.stringify(body)
}).then((data) => {
$('#resumen-titulo').text(data.titulo);
if (data.presupuesto_id) {
window.PRESUPUESTO_ID = data.presupuesto_id;
}
body.presupuesto.id = window.PRESUPUESTO_ID || body.presupuesto.id || null;
this.opts.presupuestoId = window.PRESUPUESTO_ID;
this.#updateResumenTable(data);
}).catch((error) => {
console.error("Error obtener resumen: ", error);
@ -1433,9 +1717,25 @@ class PresupuestoCliente {
const $target = $(e.currentTarget);
if ($target.prop('checked')) {
this.formData.servicios.servicios.push($target.val());
let price = 0;
if ($target.attr('id') === 'marcapaginas') {
price = self.formData.servicios.datosMarcapaginas.resultado.precio;
}
else if ($target.attr('id') === 'maquetacion') {
price = self.formData.servicios.datosMaquetacion.resultado.precio;
}
else {
price = $target.data('price') ?? $(`label[for="${$target.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), '');
}
this.formData.servicios.servicios.push(
{
id: $target.attr('id') ?? $(`label[for="${$target.attr('id')}"] .service-title`).text().trim(),
label: $(`label[for="${$target.attr('id')}"] .service-title`).text().trim(),
units: $target.attr('id') === 'marcapaginas' ? self.formData.servicios.datosMarcapaginas.marcapaginas_tirada : 1,
price: price,
});
} else {
const index = this.formData.servicios.servicios.indexOf($target.val());
const index = this.formData.servicios.servicios.findIndex(item => item.id == $target.attr('id'));
if (index > -1) {
this.formData.servicios.servicios.splice(index, 1);
}
@ -1456,10 +1756,10 @@ class PresupuestoCliente {
this.divExtras.addClass('animate-fadeInUpBounce');
for (const extra of servicios) {
if (this.formData.servicios.servicios.includes(extra.id) && !extra.checked) {
if (this.formData.servicios.servicios.some(s => s.id === extra.id) && !extra.checked) {
extra.checked = true;
if (extra.id === "marcapaginas" || extra.id === "maquetacion") {
extra.price = extra.id === "marcapaginas" ?
extra.price = (extra.id === "marcapaginas") ?
this.formData.servicios.datosMarcapaginas.resultado.precio :
this.formData.servicios.datosMaquetacion.resultado.precio;
extra.priceUnit = this.divExtras.data('currency');
@ -1467,13 +1767,16 @@ class PresupuestoCliente {
}
const item = new ServiceOptionCard(extra);
this.divExtras.append(item.render());
if (item.checked) {
if (!this.formData.servicios.servicios.includes(extra.id)) {
this.formData.servicios.servicios.push(extra.id);
}
}
}
if (!this.formData.servicios.servicios.some(s => s.id === "ferro-digital")) {
this.formData.servicios.servicios.push({
id: "ferro-digital",
label: "Ferro Digital",
units: 1,
price: 0
});
}
this.#cacheFormData();
Summary.updateExtras();
}
@ -1500,8 +1803,14 @@ class PresupuestoCliente {
...result,
};
const list = this.formData.servicios.servicios;
if (!list.includes('maquetacion')) list.push('maquetacion');
if (!this.formData.servicios.servicios.some(s => s.id === "maquetacion") && result.precio > 0) {
this.formData.servicios.servicios.push({
id: "maquetacion",
label: $(`label[for="maquetacion"] .service-title`).text().trim(),
units: 1,
price: result.precio,
});
}
this.#cacheFormData();
});
@ -1530,10 +1839,6 @@ class PresupuestoCliente {
...result,
};
// asegúrate de añadir el servicio seleccionado
const list = this.formData.servicios.servicios;
if (!list.includes('marcapaginas')) list.push('marcapaginas');
this.#cacheFormData();
});
@ -1562,7 +1867,8 @@ class PresupuestoCliente {
// 2) Botón "atrás" en Resumen
$(document).on('click', '.btn-change-tab-resumen', (e) => {
if (e.currentTarget.id === 'btn-prev-resumen') {
const action = $(e.currentTarget).data('btn-action') || 'next';
if (action === 'previous') {
this.#changeTab('pills-extras');
}
});
@ -1584,8 +1890,6 @@ class PresupuestoCliente {
});
const servicios = data.servicios || [];
let total = 0;
const locale = document.documentElement.lang || 'es-ES';
for (const l of lineas) {
@ -1598,7 +1902,6 @@ class PresupuestoCliente {
<td class="text-end">${formateaMoneda(data[l].precio_total, 2, locale)}</td>
</tr>
`;
total += data[l].precio_total;
this.tablaResumen.find('tbody').append(row);
}
for (const s of servicios) {
@ -1611,13 +1914,26 @@ class PresupuestoCliente {
<td class="text-end">${s.id === "marcapaginas" ? formateaMoneda(s.precio * s.unidades, 2, locale) : formateaMoneda(s.precio, 2, locale)}</td>
</tr>
`;
total += s.precio;
this.tablaResumen.find('tbody').append(row);
}
$('#resumen-base').text(formateaMoneda(total, 2, locale));
$('#resumen-iva').text(formateaMoneda(total * 0.04, 2, locale));
$('#resumen-total').text(formateaMoneda(total * 1.04, 2, locale));
$('#resumen-base').text(formateaMoneda(data.base_imponible, 2, locale));
if (data.iva_importe_4 > 0) {
$('#tr-resumen-iva4').removeClass('d-none');
$('#resumen-iva4').text(formateaMoneda(data.iva_importe_4, 2, locale));
}
else {
$('#tr-resumen-iva4').addClass('d-none');
$('#resumen-iva4').text(formateaMoneda(0, 2, locale));
}
if (data.iva_importe_21 > 0) {
$('#tr-resumen-iva21').removeClass('d-none');
$('#resumen-iva21').text(formateaMoneda(data.iva_importe_21, 2, locale));
} else {
$('#tr-resumen-iva21').addClass('d-none');
$('#resumen-iva21').text(formateaMoneda(0, 2, locale));
}
$('#resumen-total').text(formateaMoneda(data.total_con_iva, 2, locale));
}
/******************************
@ -1626,7 +1942,3 @@ class PresupuestoCliente {
}
document.addEventListener('DOMContentLoaded', function () {
const presupuestoCliente = new PresupuestoCliente();
presupuestoCliente.init();
});

View File

@ -0,0 +1,151 @@
(() => {
// si jQuery está cargado, añade CSRF a AJAX
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';
// Comprueba dependencias antes de iniciar
if (!window.DataTable) {
console.error('DataTables no está cargado aún');
return;
}
const table = new DataTable('#presupuestos-clientes-user-datatable', {
processing: true,
serverSide: true,
orderCellsTop: true,
pageLength: 50,
lengthMenu: [10, 25, 50, 100, 500],
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
dom: 'lBrtip',
buttons: {
dom: {
button: {
className: 'btn btn-sm btn-outline-primary me-1'
},
buttons: [
{ extend: 'copy' },
{ extend: 'csv' },
{ extend: 'excel' },
{ extend: 'pdf' },
{ extend: 'print' },
{ extend: 'colvis' }
],
}
},
ajax: {
url: '/presupuesto/datatable/clientes',
method: 'GET',
},
order: [[0, 'asc']],
columns: [
{ data: 'id', name: 'id', orderable: true },
{ data: 'titulo', name: 'titulo', orderable: true },
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', orderable: true },
{ data: 'tipoCubierta', name: 'tipoCubierta', orderable: true },
{ data: 'tipoImpresion', name: 'tipoImpresion', orderable: true },
{ data: 'selectedTirada', name: 'selectedTirada', orderable: true },
{ data: 'paginas', name: 'paginas', orderable: true },
{ data: 'estado', name: 'estado', orderable: true },
{ data: 'totalConIva', name: 'totalConIva', orderable: true },
{ data: 'updatedAt', name: 'updatedAt', orderable: true },
{ data: 'actions', orderable: false, searchable: false }
],
});
$('#presupuestos-clientes-user-datatable').on('click', '.btn-edit-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
if (id) {
window.location.href = '/presupuesto/edit/' + id;
}
});
$('#presupuestos-clientes-user-datatable').on('click', '.btn-delete-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
Swal.fire({
title: window.languageBundle.get(['presupuesto.delete.title']) || 'Eliminar presupuesto',
html: window.languageBundle.get(['presupuesto.delete.text']) || 'Esta acción no se puede deshacer.',
icon: 'warning',
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-danger w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: window.languageBundle.get(['presupuesto.delete.button']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: '/presupuesto/' + id,
type: 'DELETE',
success: function () {
Swal.fire({
icon: 'success', title: window.languageBundle.get(['presupuesto.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['presupuesto.delete.ok.text']) || 'El presupuesto ha sido eliminado con éxito.',
showConfirmButton: true,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
$('#presupuestos-anonimos-datatable').DataTable().ajax.reload(null, false);
},
error: function (xhr) {
// usa el mensaje del backend; fallback genérico por si no llega JSON
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar el presupuesto.';
Swal.fire({
icon: 'error',
title: 'No se pudo eliminar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
}
});
});
});
$('#presupuestos-clientes-user-datatable').on('keyup', '.presupuesto-filter', function (e) {
const colName = $(this).data('col');
const colIndex = table.column(colName + ':name').index();
if (table.column(colIndex).search() !== this.value) {
table.column(colIndex).search(this.value).draw();
}
});
$('#presupuestos-clientes-user-datatable').on('change', '.presupuesto-select-filter', function (e) {
const name = $(this).data('col');
const colIndex = table.column(name + ':name').index();
if (table.column(colIndex).search() !== this.value) {
table.column(colIndex).search(this.value).draw();
}
});
$('#addPresupuestoButton').on('click', async function (e) {
e.preventDefault();
window.location.href = '/presupuesto/add/private/' + $('#cliente_id').val();
});
})();

View File

@ -0,0 +1,289 @@
import { preguntarTipoPresupuesto } from './presupuesto-utils.js';
(() => {
// si jQuery está cargado, añade CSRF a AJAX
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';
// Comprueba dependencias antes de iniciar
if (!window.DataTable) {
console.error('DataTables no está cargado aún');
return;
}
const table_anonimos = new DataTable('#presupuestos-anonimos-datatable', {
processing: true,
serverSide: true,
orderCellsTop: true,
pageLength: 50,
lengthMenu: [10, 25, 50, 100, 500],
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
dom: 'lBrtip',
buttons: {
dom: {
button: {
className: 'btn btn-sm btn-outline-primary me-1'
},
buttons: [
{ extend: 'copy' },
{ extend: 'csv' },
{ extend: 'excel' },
{ extend: 'pdf' },
{ extend: 'print' },
{ extend: 'colvis' }
],
}
},
ajax: {
url: '/presupuesto/datatable/anonimos',
method: 'GET',
},
order: [[0, 'asc']],
columns: [
{ data: 'id', name: 'id', orderable: true },
{ data: 'titulo', name: 'titulo', orderable: true },
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', orderable: true },
{ data: 'tipoCubierta', name: 'tipoCubierta', orderable: true },
{ data: 'tipoImpresion', name: 'tipoImpresion', orderable: true },
{ data: 'selectedTirada', name: 'selectedTirada', orderable: true },
{ data: 'paginas', name: 'paginas', orderable: true },
{ data: 'estado', name: 'estado', orderable: true },
{ data: 'totalConIva', name: 'totalConIva', orderable: true },
{ data: 'pais', name: 'pais', orderable: true },
{ data: 'region', name: 'region', orderable: true },
{ data: 'ciudad', name: 'ciudad', orderable: true },
{ data: 'updatedAt', name: 'updatedAt', orderable: true },
{ data: 'actions', orderable: false, searchable: false }
],
});
$('#presupuestos-anonimos-datatable').on('click', '.btn-edit-anonimo', function (e) {
e.preventDefault();
const id = $(this).data('id');
if (id) {
window.location.href = '/presupuesto/view/' + id;
}
});
$('#presupuestos-anonimos-datatable').on('click', '.btn-delete-anonimo', function (e) {
e.preventDefault();
const id = $(this).data('id');
Swal.fire({
title: window.languageBundle.get(['presupuesto.delete.title']) || 'Eliminar presupuesto',
html: window.languageBundle.get(['presupuesto.delete.text']) || 'Esta acción no se puede deshacer.',
icon: 'warning',
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-danger w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: window.languageBundle.get(['presupuesto.delete.button']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: '/presupuesto/' + id,
type: 'DELETE',
success: function () {
Swal.fire({
icon: 'success', title: window.languageBundle.get(['presupuesto.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['presupuesto.delete.ok.text']) || 'El presupuesto ha sido eliminado con éxito.',
showConfirmButton: true,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
$('#presupuestos-anonimos-datatable').DataTable().ajax.reload(null, false);
},
error: function (xhr) {
// usa el mensaje del backend; fallback genérico por si no llega JSON
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar el presupuesto.';
Swal.fire({
icon: 'error',
title: 'No se pudo eliminar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
}
});
});
});
$('#presupuestos-anonimos-datatable').on('keyup', '.presupuesto-filter', function (e) {
const colName = $(this).data('col');
const colIndex = table_anonimos.column(colName + ':name').index();
if (table_anonimos.column(colIndex).search() !== this.value) {
table_anonimos.column(colIndex).search(this.value).draw();
}
});
$('#presupuestos-anonimos-datatable').on('change', '.presupuesto-select-filter', function (e) {
const colName = $(this).data('col');
const colIndex = table_anonimos.column(colName + ':name').index();
const value = $(this).val();
table_anonimos.column(colIndex).search(value).draw();
});
const table_clientes = new DataTable('#presupuestos-clientes-datatable', {
processing: true,
serverSide: true,
orderCellsTop: true,
pageLength: 50,
lengthMenu: [10, 25, 50, 100, 500],
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
dom: 'lBrtip',
buttons: {
dom: {
button: {
className: 'btn btn-sm btn-outline-primary me-1'
},
buttons: [
{ extend: 'copy' },
{ extend: 'csv' },
{ extend: 'excel' },
{ extend: 'pdf' },
{ extend: 'print' },
{ extend: 'colvis' }
],
}
},
ajax: {
url: '/presupuesto/datatable/clientes',
method: 'GET',
},
order: [[0, 'asc']],
columns: [
{ data: 'id', name: 'id', orderable: true },
{ data: 'user', name: 'user.fullName', orderable: true },
{ data: 'titulo', name: 'titulo', orderable: true },
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', orderable: true },
{ data: 'tipoCubierta', name: 'tipoCubierta', orderable: true },
{ data: 'tipoImpresion', name: 'tipoImpresion', orderable: true },
{ data: 'selectedTirada', name: 'selectedTirada', orderable: true },
{ data: 'paginas', name: 'paginas', orderable: true },
{ data: 'estado', name: 'estado', orderable: true },
{ data: 'totalConIva', name: 'totalConIva', orderable: true },
{ data: 'updatedAt', name: 'updatedAt', orderable: true },
{ data: 'actions', orderable: false, searchable: false }
],
});
$('#presupuestos-clientes-datatable').on('click', '.btn-edit-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
if (id) {
window.location.href = '/presupuesto/edit/' + id;
}
});
$('#presupuestos-clientes-datatable').on('click', '.btn-delete-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
Swal.fire({
title: window.languageBundle.get(['presupuesto.delete.title']) || 'Eliminar presupuesto',
html: window.languageBundle.get(['presupuesto.delete.text']) || 'Esta acción no se puede deshacer.',
icon: 'warning',
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-danger w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: window.languageBundle.get(['presupuesto.delete.button']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: '/presupuesto/' + id,
type: 'DELETE',
success: function () {
Swal.fire({
icon: 'success', title: window.languageBundle.get(['presupuesto.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['presupuesto.delete.ok.text']) || 'El presupuesto ha sido eliminado con éxito.',
showConfirmButton: true,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
$('#presupuestos-clientes-datatable').DataTable().ajax.reload(null, false);
},
error: function (xhr) {
// usa el mensaje del backend; fallback genérico por si no llega JSON
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar el presupuesto.';
Swal.fire({
icon: 'error',
title: 'No se pudo eliminar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
}
});
});
});
$('#presupuestos-clientes-datatable').on('keyup', '.presupuesto-filter', function (e) {
const colName = $(this).data('col');
const colIndex = table_clientes.column(colName + ':name').index();
if (table_clientes.column(colIndex).search() !== this.value) {
table_clientes.column(colIndex).search(this.value).draw();
}
});
$('#presupuestos-clientes-datatable').on('change', '.presupuesto-select-filter', function (e) {
const colName = $(this).data('col');
const colIndex = table_clientes.column(colName + ':name').index();
const value = $(this).val();
table_clientes.column(colIndex).search(value).draw();
});
$('#addPresupuestoButton').on('click', async function (e) {
e.preventDefault();
const res = await preguntarTipoPresupuesto();
if (!res) return;
if (res.tipo === 'anonimo') {
window.location.href = '/presupuesto/add/public';
} else {
window.location.href = '/presupuesto/add/private/' + res.clienteId;
}
});
})();

View File

@ -0,0 +1,77 @@
/**
* Pregunta el tipo de presupuesto y (si aplica) selecciona cliente.
* Devuelve una promesa con { tipo: 'anonimo'|'cliente', clienteId?: string }
*/
export async function preguntarTipoPresupuesto() {
const { value: tipo } = await Swal.fire({
title: window.languageBundle.get(['presupuesto.add.tipo']) || 'Selecciona tipo de presupuesto',
input: 'radio',
inputOptions: {
anonimo: window.languageBundle.get(['presupuesto.add.anonimo']) || 'Anónimo',
cliente: window.languageBundle.get(['presupuesto.add.cliente']) || 'De cliente'
},
inputValidator: (value) => {
if (!value) {
return window.languageBundle.get(['presupuesto.add.error.options']) || 'Debes seleccionar una opción.';
}
},
confirmButtonText: window.languageBundle.get(['presupuesto.add.next']) || 'Siguiente',
showCancelButton: true,
cancelButtonText: window.languageBundle.get(['presupuesto.add.cancel']) || 'Cancelar',
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
if (!tipo) return null; // Cancelado
if (tipo === 'anonimo') {
return { tipo };
}
// Si es de cliente, mostrar otro paso con Select2
return Swal.fire({
title: window.languageBundle.get(['presupuesto.add.select-client']) || 'Selecciona cliente',
html: `
<select id="selectCliente" class="form-select select2" style="width:100%"></select>
`,
focusConfirm: false,
buttonsStyling: false,
showCancelButton: true,
confirmButtonText: window.languageBundle.get(['presupuesto.add.next']) || 'Aceptar',
cancelButtonText: window.languageBundle.get(['presupuesto.add.cancel']) || 'Cancelar',
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
didOpen: () => {
const $select = $('#selectCliente');
// Configura Select2 (AJAX o lista estática)
$select.select2({
dropdownParent: $('.swal2-container'),
ajax: {
url: 'users/api/get-users', // ajusta a tu endpoint
dataType: 'json',
delay: 250,
data: (params) => ({ q: params.term }),
processResults: data => ({
results: data.results,
pagination: data.pagination
}),
cache: true
}
});
},
preConfirm: () => {
const clienteId = $('#selectCliente').val();
const clienteText = $('#selectCliente option:selected').text();
if (!clienteId) {
Swal.showValidationMessage(window.languageBundle.get(['presupuesto.add.error.select-client']) || 'Debes seleccionar un cliente.');
return false;
}
return { tipo, clienteId, clienteText };
}
}).then((r) => (r.isConfirmed ? r.value : null));
}

View File

@ -19,6 +19,22 @@ $(() => {
pageLength: 50,
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
dom: 'Bfrtip',
buttons: {
dom: {
button: {
className: 'btn btn-sm btn-outline-primary me-1'
},
buttons: [
{ extend: 'copy' },
{ extend: 'csv' },
{ extend: 'excel' },
{ extend: 'pdf' },
{ extend: 'print' },
{ extend: 'colvis' }
],
}
},
ajax: {
url: '/users/datatable',
method: 'GET',
@ -116,7 +132,16 @@ $(() => {
// usa el mensaje del backend; fallback genérico por si no llega JSON
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar el usuario.';
Swal.fire({ icon: 'error', title: 'No se pudo eliminar', text: msg });
Swal.fire({
icon: 'error',
title: 'No se pudo eliminar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
}
});
});

View File

@ -0,0 +1,5 @@
/*!
* Column visibility buttons for Buttons and DataTables.
* © SpryMedia Ltd - datatables.net/license
*/
!function(i){var o,e;"function"==typeof define&&define.amd?define(["jquery","datatables.net","datatables.net-buttons"],function(n){return i(n,window,document)}):"object"==typeof exports?(o=require("jquery"),e=function(n,t){t.fn.dataTable||require("datatables.net")(n,t),t.fn.dataTable.Buttons||require("datatables.net-buttons")(n,t)},"undefined"==typeof window?module.exports=function(n,t){return n=n||window,t=t||o(n),e(n,t),i(t,0,n.document)}:(e(window,o),module.exports=i(o,window,window.document))):i(jQuery,window,document)}(function(n,t,i){"use strict";var e=n.fn.dataTable;return n.extend(e.ext.buttons,{colvis:function(n,t){var i=null,o={extend:"collection",init:function(n,t){i=t},text:function(n){return n.i18n("buttons.colvis","Column visibility")},className:"buttons-colvis",closeButton:!1,buttons:[{extend:"columnsToggle",columns:t.columns,columnText:t.columnText}]};return n.on("column-reorder.dt"+t.namespace,function(){n.button(null,n.button(null,i).node()).collectionRebuild([{extend:"columnsToggle",columns:t.columns,columnText:t.columnText}])}),o},columnsToggle:function(n,t){return n.columns(t.columns).indexes().map(function(n){return{extend:"columnToggle",columns:n,columnText:t.columnText}}).toArray()},columnToggle:function(n,t){return{extend:"columnVisibility",columns:t.columns,columnText:t.columnText}},columnsVisibility:function(n,t){return n.columns(t.columns).indexes().map(function(n){return{extend:"columnVisibility",columns:n,visibility:t.visibility,columnText:t.columnText}}).toArray()},columnVisibility:{columns:void 0,text:function(n,t,i){return i._columnText(n,i)},className:"buttons-columnVisibility",action:function(n,t,i,o){var t=t.columns(o.columns),e=t.visible();t.visible(void 0!==o.visibility?o.visibility:!(e.length&&e[0]))},init:function(e,n,t){var u=this,l=e.column(t.columns);n.attr("data-cv-idx",t.columns),e.on("column-visibility.dt"+t.namespace,function(n,t,i,o){l.index()!==i||t.bDestroying||t.nTable!=e.settings()[0].nTable||u.active(o)}).on("column-reorder.dt"+t.namespace,function(){t.destroying||1===e.columns(t.columns).count()&&(l=e.column(t.columns),u.text(t._columnText(e,t)),u.active(l.visible()))}),this.active(l.visible())},destroy:function(n,t,i){n.off("column-visibility.dt"+i.namespace).off("column-reorder.dt"+i.namespace)},_columnText:function(n,t){var i,o;return"string"==typeof t.text?t.text:(o=n.column(t.columns).title(),i=n.column(t.columns).index(),o=o.replace(/\n/g," ").replace(/<br\s*\/?>/gi," ").replace(/<select(.*?)<\/select\s*>/gi,""),o=e.Buttons.stripHtmlComments(o),o=e.util.stripHtml(o).trim(),t.columnText?t.columnText(n,i,o):o)}},colvisRestore:{className:"buttons-colvisRestore",text:function(n){return n.i18n("buttons.colvisRestore","Restore visibility")},init:function(n,t,i){n.columns().every(function(){var n=this.init();void 0===n.__visOriginal&&(n.__visOriginal=this.visible())})},action:function(n,t,i,o){t.columns().every(function(n){var t=this.init();this.visible(t.__visOriginal)})}},colvisGroup:{className:"buttons-colvisGroup",action:function(n,t,i,o){t.columns(o.show).visible(!0,!1),t.columns(o.hide).visible(!1,!1),t.columns.adjust()},show:[],hide:[]}}),e});

Some files were not shown because too many files have changed in this diff Show More