From a1359f37b0176477f424884c1e17d9ea90487f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Sat, 11 Oct 2025 14:14:47 +0200 Subject: [PATCH] guardando presupuestos anonimos --- .../erp/common/jpa/AbstractAuditedEntity.java | 1 - .../erp/config/JpaAuditConfig.java | 34 ++-- .../presupuesto/PresupuestoController.java | 151 ++++++++++++--- .../service/PresupuestoService.java | 179 ++++++++++-------- .../PresupuestoValidationGroups.java | 5 + .../com/imprimelibros/erp/users/User.java | 1 - .../erp/users/UserController.java | 39 ++++ .../com/imprimelibros/erp/users/UserDao.java | 73 ++++--- .../imprimelibros/erp/users/UserService.java | 14 +- .../erp/users/UserServiceImpl.java | 13 +- src/main/resources/application.properties | 8 +- .../resources/i18n/presupuesto_es.properties | 14 ++ .../presupuestador/wizard-publicos-add.js | 9 + .../presupuestador/wizard-publicos.js | 2 +- .../imprimelibros/presupuestador/wizard.js | 134 +++++++++---- .../pages/imprimelibros/presupuestos/list.js | 32 +++- .../presupuestos/presupuesto-utils.js | 77 ++++++++ .../js/pages/imprimelibros/users/list.js | 11 +- .../presupuestador-items/_buttons.html | 10 + .../presupuestador-items/_cubierta.html | 55 ++++-- .../_datos-generales.html | 5 +- .../presupuestador-items/_extras.html | 7 +- .../presupuestador-items/_interior.html | 15 +- .../presupuestador-items/_resumen_final.html | 9 +- .../_seleccion-tirada.html | 7 +- .../presupuestos/presupuestador.html | 19 +- .../presupuestos/presupuesto-form.html | 3 + .../presupuestos/presupuesto-list.html | 2 +- 28 files changed, 697 insertions(+), 232 deletions(-) create mode 100644 src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos-add.js create mode 100644 src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/presupuesto-utils.js create mode 100644 src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_buttons.html diff --git a/src/main/java/com/imprimelibros/erp/common/jpa/AbstractAuditedEntity.java b/src/main/java/com/imprimelibros/erp/common/jpa/AbstractAuditedEntity.java index a344f4e..6391e00 100644 --- a/src/main/java/com/imprimelibros/erp/common/jpa/AbstractAuditedEntity.java +++ b/src/main/java/com/imprimelibros/erp/common/jpa/AbstractAuditedEntity.java @@ -46,7 +46,6 @@ public abstract class AbstractAuditedEntity { @Column(name = "deleted_at") private Instant deletedAt; - @LastModifiedBy @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "deleted_by") private User deletedBy; diff --git a/src/main/java/com/imprimelibros/erp/config/JpaAuditConfig.java b/src/main/java/com/imprimelibros/erp/config/JpaAuditConfig.java index 0c2e545..40444ab 100644 --- a/src/main/java/com/imprimelibros/erp/config/JpaAuditConfig.java +++ b/src/main/java/com/imprimelibros/erp/config/JpaAuditConfig.java @@ -3,41 +3,45 @@ 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 org.springframework.security.core.userdetails.UserDetails; import com.imprimelibros.erp.users.User; -import com.imprimelibros.erp.users.UserDao; +import com.imprimelibros.erp.users.UserDetailsImpl; // tu implementación @Configuration @EnableJpaAuditing(auditorAwareRef = "auditorAware") public class JpaAuditConfig { @Bean - public AuditorAware auditorAware(UserDao userDao) { + public AuditorAware auditorAware(EntityManager em) { return () -> { - var auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) - return Optional.empty(); + 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; - if (principal instanceof User u) - return Optional.of(u); - - if (principal instanceof UserDetails ud) { - return userDao.findByUserNameIgnoreCase(ud.getUsername()); + // Tu UserDetailsImpl ya tiene el id + if (principal instanceof UserDetailsImpl udi) { + userId = udi.getId(); } - - if (principal instanceof String username && !"anonymousUser".equals(username)) { - return userDao.findByUserNameIgnoreCase(username); + // 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(); - return Optional.empty(); + // Devuelve una referencia gestionada (NO hace SELECT ni fuerza flush) + return Optional.of(em.getReference(User.class, userId)); }; } } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java index 281ba69..4c0c8ed 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java @@ -5,13 +5,13 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; import java.time.Instant; -import java.time.LocalDateTime; 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; @@ -33,7 +33,7 @@ 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 org.springframework.web.server.ResponseStatusException; +import jakarta.validation.Validator; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.core.type.TypeReference; @@ -48,12 +48,12 @@ 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.User; import com.imprimelibros.erp.users.UserDetailsImpl; import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper; import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; @Controller @@ -71,6 +71,9 @@ public class PresupuestoController { @Autowired protected MessageSource messageSource; + @Autowired + private Validator validator; + private final ObjectMapper objectMapper; private final TranslationService translationService; private final PresupuestoDatatableService dtService; @@ -472,6 +475,8 @@ public class PresupuestoController { 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> serviciosList = (List>) body.getOrDefault("servicios", List.of()); @@ -479,7 +484,7 @@ public class PresupuestoController { String sessionId = request.getSession(true).getId(); String ip = request.getRemoteAddr(); - var resumen = presupuestoService.getResumenPublico(p, serviciosList, locale, sessionId, ip); + var resumen = presupuestoService.getResumen(p, serviciosList, save, mode, locale, sessionId, ip); return ResponseEntity.ok(resumen); } @@ -498,7 +503,15 @@ public class PresupuestoController { "app.yes", "app.cancelar", "presupuesto.delete.ok.title", - "presupuesto.delete.ok.text"); + "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 translations = translationService.getTranslations(locale, keys); model.addAttribute("languageBundle", translations); @@ -557,6 +570,35 @@ public class PresupuestoController { 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 keys = List.of( + "presupuesto.plantilla-cubierta", + "presupuesto.plantilla-cubierta-text", + "presupuesto.impresion-cubierta", + "presupuesto.impresion-cubierta-help"); + + Map 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); + } + + return "imprimelibros/presupuestos/presupuesto-form"; + } + @GetMapping(value = "/api/get", produces = "application/json") public ResponseEntity getPresupuesto( @RequestParam("id") Long id, Authentication authentication) { @@ -588,51 +630,102 @@ public class PresupuestoController { @Transactional public ResponseEntity delete(@PathVariable Long id, Authentication auth, Locale locale) { - Presupuesto p = presupuestoRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, - messageSource.getMessage("presupuesto.error.not-found", null, 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")); - // compara por IDs (no uses equals entre tipos distintos) Long ownerId = p.getUser() != null ? p.getUser().getId() : null; - - User currentUser = null; Long currentUserId = null; if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) { currentUserId = udi.getId(); - currentUser = userRepo.findById(currentUserId).orElse(null); } else if (auth != null) { - currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); // fallback - currentUser = userRepo.findById(currentUserId).orElse(null); + currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); } - boolean isOwner = ownerId != null && ownerId.equals(currentUserId); if (isUser && !isOwner) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - messageSource.getMessage("presupuesto.error.delete-permission-denied", null, locale)); + 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)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - messageSource.getMessage("presupuesto.error.delete-not-draft", null, locale)); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("message", + messageSource.getMessage("presupuesto.error.delete-not-draft", null, locale))); } - // SOFT DELETE (no uses deleteById) - p.setDeleted(true); - p.setDeletedAt(Instant.now()); - p.setDeletedBy(currentUser); + try { + p.setDeleted(true); + p.setDeletedAt(Instant.now()); - presupuestoRepository.save(p); - return ResponseEntity.ok(Map.of("message", - messageSource.getMessage("presupuesto.exito.eliminado", null, locale))); + 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() : ""))); + } + } + + @PostMapping(path="/save") + public ResponseEntity save( + @RequestBody Map 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> serviciosList = (List>) body.getOrDefault("servicios", List.of()); + + Set> violations = validator.validate(presupuesto, + PresupuestoValidationGroups.All.class); + + if (!violations.isEmpty()) { + Map errores = new HashMap<>(); + for (ConstraintViolation 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 { + if (mode.equals("public")) { + var resumen = presupuestoService.getTextosResumen(presupuesto, serviciosList, locale); + presupuesto = presupuestoService.generateTotalizadores(presupuesto, serviciosList, resumen, locale); + if (id != null) { + presupuesto.setId(id); // para que actualice, no cree uno nuevo + } + } + Map saveResult = presupuestoService.guardarPresupuesto(presupuesto); + 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() : ""))); + } } } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoService.java b/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoService.java index 2a0b6fa..1b9df66 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoService.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoService.java @@ -8,10 +8,11 @@ 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.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,6 +39,8 @@ import com.imprimelibros.erp.presupuesto.dto.Presupuesto; import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta; import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatricesRepository; import com.imprimelibros.erp.presupuesto.marcapaginas.MarcapaginasRepository; +import com.imprimelibros.erp.users.UserDao; +import com.imprimelibros.erp.users.UserDetailsImpl; import com.imprimelibros.erp.externalApi.skApiClient; @Service @@ -65,13 +68,15 @@ public class PresupuestoService { private final PresupuestoFormatter presupuestoFormatter; private final skApiClient apiClient; private final GeoIpService geoIpService; + private final UserDao userRepo; public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter, - skApiClient apiClient, GeoIpService geoIpService) { + 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) { @@ -439,9 +444,8 @@ 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"; } public Map obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) { @@ -517,7 +521,7 @@ public class PresupuestoService { put("priceUnit", ""); } else { put("price", - NumberFormat.getNumberInstance(locale).format(Math.round(price_prototipo * 100.0) / 100.0)); + String.valueOf(Math.round(price_prototipo * 100.0) / 100.0)); put("priceUnit", messageSource.getMessage("app.currency-symbol", null, locale)); } } @@ -723,7 +727,8 @@ public class PresupuestoService { * Calcula el resumen (SIN persistir cambios de estado). * Mantiene firma para no romper llamadas existentes. */ - public Map getResumen(Presupuesto presupuesto, List> servicios, Locale locale) { + public Map getTextosResumen(Presupuesto presupuesto, List> servicios, + Locale locale) { Map resumen = new HashMap<>(); resumen.put("titulo", presupuesto.getTitulo()); @@ -798,25 +803,88 @@ public class PresupuestoService { * Se invoca al entrar en la pestaña "Resumen" del presupuestador público. */ // PresupuestoService.java - - @Transactional - public Map getResumenPublico( + public Map getResumen( Presupuesto presupuesto, List> servicios, + Boolean save, + String mode, Locale locale, String sessionId, String ip) { // 1) Calcula el resumen (como ya haces) - Map resumen = getResumen(presupuesto, servicios, locale); + Map resumen = getTextosResumen(presupuesto, servicios, locale); if (resumen.containsKey("error")) return resumen; - // 2) Totales a partir del resumen - // - precio_unitario: primer precio devuelto por la API - // - cantidad: selected_tirada - // - precio_total_tirada - // - servicios_total (si hay) + presupuesto = generateTotalizadores(presupuesto, servicios, resumen, locale); + + // 3) Enriquecer el Presupuesto a persistir + presupuesto.setEstado(Presupuesto.Estado.borrador); + if (mode.equals("public")) { + 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) { + } + } 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 + this.guardarPresupuesto(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_tipo", presupuesto.getIvaTipo()); + resumen.put("iva_importe", presupuesto.getIvaImporte()); + resumen.put("total_con_iva", presupuesto.getTotalConIva()); + + return resumen; + } + + public Presupuesto generateTotalizadores( + Presupuesto presupuesto, + List> servicios, + Map resumen, + Locale locale) { + + // Genera los totalizadores (precio unitario, total tirada, etc.) sin guardar double precioUnit = 0.0; int cantidad = presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0; try { @@ -871,27 +939,6 @@ public class PresupuestoService { RoundingMode.HALF_UP); BigDecimal totalConIva = baseImponible.add(ivaImporte); - // 3) Enriquecer el Presupuesto a persistir - presupuesto.setEstado(Presupuesto.Estado.borrador); - 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) { - } - // precios y totales presupuesto.setPrecioUnitario(BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP)); presupuesto.setPrecioTotalTirada(precioTotalTirada); @@ -901,46 +948,33 @@ public class PresupuestoService { presupuesto.setIvaImporte(ivaImporte); presupuesto.setTotalConIva(totalConIva); - // 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()); + return presupuesto; + } + + @Transactional + public HashMap guardarPresupuesto(Presupuesto presupuesto) { + + HashMap result = new HashMap<>(); + try { + + Presupuesto p = presupuestoRepository.saveAndFlush(presupuesto); + + result.put("success", true); + result.put("presupuesto_id", p.getId()); + + return result; + + } catch (Exception e) { + System.out.println("Error guardando presupuesto: " + e.getMessage()); + result.put("success", false); + return result; } - // 5) Guardar/actualizar - entidad = mergePresupuesto(entidad, presupuesto); - presupuestoRepository.saveAndFlush(entidad); - - // 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_tipo", presupuesto.getIvaTipo()); - resumen.put("iva_importe", presupuesto.getIvaImporte()); - resumen.put("total_con_iva", presupuesto.getTotalConIva()); - - return resumen; } /** * PRIVADO (futuro botón "Guardar"): persiste el presupuesto como borrador. */ - @Transactional - public Presupuesto guardarPrivado(Presupuesto presupuesto) { - presupuesto.setEstado(Presupuesto.Estado.borrador); - return presupuestoRepository.saveAndFlush(presupuesto); - } public HashMap calcularPresupuesto(Presupuesto presupuesto, Locale locale) { HashMap price = new HashMap<>(); @@ -957,9 +991,8 @@ public class PresupuestoService { return price; } - public Boolean canAccessPresupuesto(Presupuesto presupuesto, Authentication authentication) { - + boolean isUser = authentication.getAuthorities().stream() .anyMatch(a -> a.getAuthority().equals("ROLE_USER")); @@ -972,7 +1005,6 @@ public class PresupuestoService { } return true; } - // ======================================================================= // Métodos privados @@ -1095,5 +1127,4 @@ public class PresupuestoService { return ip; } - } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/validation/PresupuestoValidationGroups.java b/src/main/java/com/imprimelibros/erp/presupuesto/validation/PresupuestoValidationGroups.java index 2d51d74..3718ad1 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/validation/PresupuestoValidationGroups.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/validation/PresupuestoValidationGroups.java @@ -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 {} } diff --git a/src/main/java/com/imprimelibros/erp/users/User.java b/src/main/java/com/imprimelibros/erp/users/User.java index b60953f..4c7e43e 100644 --- a/src/main/java/com/imprimelibros/erp/users/User.java +++ b/src/main/java/com/imprimelibros/erp/users/User.java @@ -255,7 +255,6 @@ public class User { ", fullName='" + fullName + '\'' + ", userName='" + userName + '\'' + ", enabled=" + enabled + - ", roles=" + getRoles() + '}'; } diff --git a/src/main/java/com/imprimelibros/erp/users/UserController.java b/src/main/java/com/imprimelibros/erp/users/UserController.java index 0b56747..8c5d9c6 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserController.java +++ b/src/main/java/com/imprimelibros/erp/users/UserController.java @@ -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 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 users = userService.findByRoleAndSearch(role, q, pageable); + + boolean more = users.hasNext(); + + List> results = users.getContent().stream() + .map(u -> { + Map 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)); + + } } diff --git a/src/main/java/com/imprimelibros/erp/users/UserDao.java b/src/main/java/com/imprimelibros/erp/users/UserDao.java index cf62722..de2645e 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserDao.java +++ b/src/main/java/com/imprimelibros/erp/users/UserDao.java @@ -19,39 +19,60 @@ import org.springframework.lang.Nullable; @Repository public interface UserDao extends JpaRepository, JpaSpecificationExecutor { - // Aplicamos EntityGraph a la versión con Specification+Pageable - @Override - @EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" }) - @NonNull - Page findAll(@Nullable Specification spec, @NonNull Pageable pageable); + // Aplicamos EntityGraph a la versión con Specification+Pageable + @Override + @EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" }) + @NonNull + Page findAll(@Nullable Specification spec, @NonNull Pageable pageable); - Optional findByUserNameIgnoreCase(String userName); + Optional 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 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 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 findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName); + // Nuevo: para login/negocio "activo" + @EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" }) + Optional findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName); - // Para poder restaurar, necesitas leer ignorando @Where (native): - @Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true) - Optional findByIdIncludingDeleted(@Param("id") Long id); + // Para poder restaurar, necesitas leer ignorando @Where (native): + @Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true) + Optional findByIdIncludingDeleted(@Param("id") Long id); - @Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true) - List findAllDeleted(); + @Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true) + List findAllDeleted(); - @Query("select u.id from User u where lower(u.userName) = lower(:userName)") - Optional findIdByUserNameIgnoreCase(@Param("userName") String userName); + @Query("select u.id from User u where lower(u.userName) = lower(:userName)") + Optional 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 searchUsers(@Param("role") String role, + @Param("q") String q, + Pageable pageable); } diff --git a/src/main/java/com/imprimelibros/erp/users/UserService.java b/src/main/java/com/imprimelibros/erp/users/UserService.java index 1160d12..6856390 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserService.java +++ b/src/main/java/com/imprimelibros/erp/users/UserService.java @@ -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 findByRoleAndSearch(String role, String query, Pageable pageable); } diff --git a/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java b/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java index 569deea..107c88b 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java +++ b/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java @@ -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 findByRoleAndSearch(String role, String query, Pageable pageable) { + + if (query == null || query.isBlank()) query = null; + return userDao.searchUsers(role, query, pageable); + } + } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2721042..18a2d3e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -33,11 +33,9 @@ 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 - +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.orm.jdbc.bind=TRACE +spring.jpa.properties.hibernate.format_sql=true # # Resource chain diff --git a/src/main/resources/i18n/presupuesto_es.properties b/src/main/resources/i18n/presupuesto_es.properties index 3c3bd35..acb1968 100644 --- a/src/main/resources/i18n/presupuesto_es.properties +++ b/src/main/resources/i18n/presupuesto_es.properties @@ -10,6 +10,7 @@ presupuesto.resumen=Resumen presupuesto.add-to-presupuesto=Añadir al presupuesto presupuesto.calcular=Calcular presupuesto.add=Añadir presupuesto +presupuesto.guardar=Guardar presupuesto.nav.presupuestos-cliente=Presupuestos cliente presupuesto.nav.presupuestos-anonimos=Presupuestos anónimos @@ -278,6 +279,19 @@ presupuesto.error.delete-permission-denied=No se puede eliminar: permiso denegad 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.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 diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos-add.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos-add.js new file mode 100644 index 0000000..9407e55 --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos-add.js @@ -0,0 +1,9 @@ +import PresupuestoWizard from './wizard.js'; + +const app = new PresupuestoWizard({ + mode: 'public', + readonly: false, + canSave: true, + useSessionCache: false, +}); +app.init(); \ No newline at end of file diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js index 25400b4..86ee6b5 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js @@ -1,7 +1,7 @@ import PresupuestoWizard from './wizard.js'; const app = new PresupuestoWizard({ - mode: 'view', + mode: 'public', readonly: true, canSave: false, useSessionCache: false, diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard.js index 9655687..4c6af44 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard.js @@ -22,15 +22,15 @@ export default class PresupuestoWizard { titulo: '', autor: '', isbn: '', - tirada1: '', + tirada1: 10, tirada2: '', tirada3: '', tirada4: '', - ancho: '', - alto: '', + ancho: 148, + alto: 218, formatoPersonalizado: false, - paginasNegro: '', - paginasColor: '', + paginasNegro: 0, + paginasColor: 32, posicionPaginasColor: '', tipoEncuadernacion: 'fresado', }, @@ -99,7 +99,7 @@ export default class PresupuestoWizard { } } }, - selectedTirada: null, + selectedTirada: 10, } // pestaña datos generales @@ -180,12 +180,20 @@ export default class PresupuestoWizard { async init() { + $.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 = root?.dataset.id || null; let stored = null; - if(this.opts.useSessionCache) { + if (this.opts.useSessionCache) { stored = sessionStorage.getItem("formData"); } @@ -238,29 +246,75 @@ export default class PresupuestoWizard { if (this.opts.canSave) { $('#btn-guardar').on('click', async () => { - // compón el payload con lo que ya tienes: + + const alert = $('#form-errors'); + const servicios = []; + $('.service-checkbox:checked').each(function () { + const $servicio = $(this); + 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'), ''), + }); + }); const payload = { id: this.opts.presupuestoId, + mode: this.opts.mode, presupuesto: this.#getPresupuestoData(), + servicios: servicios, }; try { - const res = await fetch(this.opts.endpoints.save, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }).then(r => r.json()); + alert.addClass('d-none').find('ul').empty(); + $.ajax({ + url: '/presupuesto/save', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(payload) + }).then((data) => { + Swal.fire({ + icon: 'success', + title: window.languageBundle?.get('common.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 + }); + // opcional: actualizar window.PRESUPUESTO_ID/resumen con el id devuelto + if (data.id) this.opts.presupuestoId = data.id; + }).catch((xhr, status, error) => { - // feedback - Swal.fire({ - icon: 'success', - title: window.languageBundle?.get('common.guardado') || 'Guardado', - timer: 1800, - showConfirmButton: false + 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(`
  • ${errorMsg}
  • `); + }); + alert.removeClass('d-none'); + } else { + alert.find('ul').append('
  • Error desconocido. Por favor, inténtelo de nuevo más tarde.
  • '); + $(window).scrollTop(0); + alert.removeClass('d-none'); + } + window.scrollTo({ top: 0, behavior: 'smooth' }); }); - // opcional: actualizar window.PRESUPUESTO_ID/resumen con el id devuelto - if (res.id) window.PRESUPUESTO_ID = res.id; } catch (e) { - Swal.fire({ icon: 'error', title: 'Error al guardar', text: e?.message || '' }); + Swal.fire({ + icon: 'error', + title: 'Error al guardar', + 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 + }, + }); } }); } @@ -381,7 +435,7 @@ export default class PresupuestoWizard { $(document).on('change', 'input[name="tipoEncuadernacion"]', (e) => { if ($(e.target).is(':checked')) { - + Summary.updateEncuadernacion(); } }); @@ -507,7 +561,7 @@ export default class PresupuestoWizard { 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); @@ -589,7 +643,7 @@ export default class PresupuestoWizard { } if (!(selectedTipo && $('.tipo-libro#' + selectedTipo).length > 0 && !$('.tipo-libro#' + selectedTipo).hasClass('d-none'))) { - + let firstVisible = $('.tipo-libro').not('.d-none').first(); if (firstVisible.length) { @@ -626,7 +680,7 @@ export default class PresupuestoWizard { $(document).on('change', 'input[name="tipoImpresion"]', (e) => { if (!$(e.target).is(':checked')) return; - + const data = this.#getPresupuestoData(); Summary.updateTipoImpresion(); @@ -849,7 +903,7 @@ export default class PresupuestoWizard { 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.group = 'papelInterior'; item.extraClass = 'interior-data papel-interior'; if (this.formData.interior.papelInteriorId == '' && i === 0 || this.formData.interior.papelInteriorId == opcion.extra_data["sk-id"]) { @@ -934,7 +988,12 @@ export default class PresupuestoWizard { `, 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 + }, }); }); @@ -946,7 +1005,12 @@ export default class PresupuestoWizard { 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 + }, }); }); @@ -955,7 +1019,7 @@ export default class PresupuestoWizard { if (!$(e.target).is(':checked')) return; - if(this._hydrating) + if (this._hydrating) return; $('.tapa-dura-options').eq(0).removeClass('animate-fadeInUpBounce'); @@ -995,7 +1059,7 @@ export default class PresupuestoWizard { Summary.updateTapaCubierta(); }); - + $(document).on('change', 'input[name="papel-cubierta"]', (e) => { const data = this.#getPresupuestoData(); @@ -1036,7 +1100,7 @@ export default class PresupuestoWizard { $(document).on('change', '.datos-cubierta', (e) => { - if(this._hydrating) + if (this._hydrating) return; const dataToStore = this.#getCubiertaData(); @@ -1178,12 +1242,12 @@ export default class PresupuestoWizard { if (item.extraData["sk-id"] == this.formData.cubierta.papelCubiertaId) { item.setSelected(true); } - item.group='papel-cubierta'; + item.group = 'papel-cubierta'; this.divPapelCubierta.append(item.render()); } if (this.divPapelCubierta.find('input[name="papel-cubierta"]:checked').length === 0) { - + this.divPapelCubierta.find('input[name="papel-cubierta"]').first().prop('checked', true).trigger('change'); } @@ -1493,6 +1557,8 @@ export default class PresupuestoWizard { const body = { presupuesto: this.#getPresupuestoData(), + save: !this.opts.canSave, + mode: this.opts.mode, servicios: servicios }; diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/list.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/list.js index 5ca5ffe..cdde956 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/list.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/list.js @@ -1,3 +1,5 @@ +import { preguntarTipoPresupuesto } from './presupuesto-utils.js'; + (() => { // si jQuery está cargado, añade CSRF a AJAX const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); @@ -67,7 +69,7 @@ }); $('#presupuestos-anonimos-datatable').on('click', '.btn-edit-anonimo', function (e) { - + e.preventDefault(); const id = $(this).data('id'); if (id) { @@ -76,7 +78,7 @@ }); $('#presupuestos-anonimos-datatable').on('click', '.btn-delete-anonimo', function (e) { - + e.preventDefault(); const id = $(this).data('id'); @@ -113,10 +115,34 @@ // 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 }); + 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 + }, + }); } }); }); }); + $('#addPresupuestoButton').on('click', async function (e) { + + e.preventDefault(); + + const res = await preguntarTipoPresupuesto(); + if (!res) return; + + if (res.tipo === 'anonimo') { + console.log('Crear presupuesto ANÓNIMO'); + } else { + console.log('Crear presupuesto de CLIENTE:', res.clienteId, res.clienteText); + } + + }); + })(); diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/presupuesto-utils.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/presupuesto-utils.js new file mode 100644 index 0000000..5b42345 --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/presupuesto-utils.js @@ -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: ` + + `, + 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)); +} diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js b/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js index 1e3bdfb..26ef934 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js @@ -132,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 + }, + }); } }); }); diff --git a/src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_buttons.html b/src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_buttons.html new file mode 100644 index 0000000..0121c8a --- /dev/null +++ b/src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_buttons.html @@ -0,0 +1,10 @@ + +
    + +
    diff --git a/src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_cubierta.html b/src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_cubierta.html index 452dba4..b31d15b 100644 --- a/src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_cubierta.html +++ b/src/main/resources/templates/imprimelibros/presupuestos/presupuestador-items/_cubierta.html @@ -21,7 +21,8 @@
    -
    -
    -