diff --git a/pom.xml b/pom.xml index 578047b..b058b23 100644 --- a/pom.xml +++ b/pom.xml @@ -139,6 +139,18 @@ test + + + com.openhtmltopdf + openhtmltopdf-pdfbox + 1.0.10 + + + com.openhtmltopdf + openhtmltopdf-slf4j + 1.0.10 + + diff --git a/src/main/java/com/imprimelibros/erp/datatables/DataTablesSpecification.java b/src/main/java/com/imprimelibros/erp/datatables/DataTablesSpecification.java index 1b479d3..298b08d 100644 --- a/src/main/java/com/imprimelibros/erp/datatables/DataTablesSpecification.java +++ b/src/main/java/com/imprimelibros/erp/datatables/DataTablesSpecification.java @@ -31,7 +31,7 @@ public class DataTablesSpecification { 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); + //System.out.println("[DT] columna no mapeada o relación: " + col.name); } } } diff --git a/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java b/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java index 37f8089..1dc4c20 100644 --- a/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java +++ b/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java @@ -90,7 +90,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); } diff --git a/src/main/java/com/imprimelibros/erp/pdf/DocumentSpec.java b/src/main/java/com/imprimelibros/erp/pdf/DocumentSpec.java new file mode 100644 index 0000000..9638eea --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/DocumentSpec.java @@ -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 model // data del documento +) {} diff --git a/src/main/java/com/imprimelibros/erp/pdf/DocumentType.java b/src/main/java/com/imprimelibros/erp/pdf/DocumentType.java new file mode 100644 index 0000000..4f27aef --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/DocumentType.java @@ -0,0 +1,5 @@ +package com.imprimelibros.erp.pdf; + +public enum DocumentType { + PRESUPUESTO, PEDIDO, FACTURA +} diff --git a/src/main/java/com/imprimelibros/erp/pdf/PdfController.java b/src/main/java/com/imprimelibros/erp/pdf/PdfController.java new file mode 100644 index 0000000..6c4f5c6 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/PdfController.java @@ -0,0 +1,32 @@ +package com.imprimelibros.erp.pdf; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Locale; +import java.util.Map; + +@RestController +@RequestMapping("/api/pdf") +public class PdfController { + private final PdfService pdfService; + + public PdfController(PdfService pdfService) { this.pdfService = pdfService; } + + @PostMapping("/{type}/{templateId}") + public ResponseEntity generate( + @PathVariable("type") DocumentType type, + @PathVariable String templateId, + @RequestBody Map model, + Locale locale) { + + var spec = new DocumentSpec(type, templateId, locale, model); + var pdf = pdfService.generate(spec); + + var fileName = type.name().toLowerCase() + "-" + templateId + ".pdf"; + return ResponseEntity.ok() + .header("Content-Type", "application/pdf") + .header("Content-Disposition", "inline; filename=\"" + fileName + "\"") + .body(pdf); + } +} diff --git a/src/main/java/com/imprimelibros/erp/pdf/PdfModuleConfig.java b/src/main/java/com/imprimelibros/erp/pdf/PdfModuleConfig.java new file mode 100644 index 0000000..244f915 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/PdfModuleConfig.java @@ -0,0 +1,18 @@ +package com.imprimelibros.erp.pdf; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import java.util.Map; + +@Configuration +@ConfigurationProperties(prefix = "imprimelibros.pdf") +public class PdfModuleConfig { + /** + * Mapa: "TYPE:templateId" -> "ruta thymeleaf" (sin extensión). + * Ej: "PRESUPUESTO:presupuesto-a4" -> "pdf/presupuesto-a4" + */ + private Map templates; + + public Map getTemplates() { return templates; } + public void setTemplates(Map templates) { this.templates = templates; } +} diff --git a/src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java b/src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java new file mode 100644 index 0000000..3502808 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java @@ -0,0 +1,44 @@ +package com.imprimelibros.erp.pdf; + +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 Resource staticRoot; + + public byte[] renderHtmlToPdf(String html) { + try (var baos = new ByteArrayOutputStream()) { + var builder = new PdfRendererBuilder(); + + builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Regular.ttf"), "Open Sans", + 400, com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder.FontStyle.NORMAL, true); + builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-SemiBold.ttf"), "Open Sans", + 600, com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder.FontStyle.NORMAL, true); + builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Bold.ttf"), "Open Sans", 700, + com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder.FontStyle.NORMAL, true); + + builder.useFastMode(); + builder.withHtmlContent(html, baseUrl()); + builder.toStream(baos); + builder.run(); + return baos.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException("Error generando PDF", e); + } + } + + private String baseUrl() { + try { + return staticRoot.getURL().toString(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/imprimelibros/erp/pdf/PdfService.java b/src/main/java/com/imprimelibros/erp/pdf/PdfService.java new file mode 100644 index 0000000..246d0f5 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/PdfService.java @@ -0,0 +1,25 @@ +package com.imprimelibros.erp.pdf; + +import org.springframework.stereotype.Service; + +@Service +public class PdfService { + private final TemplateRegistry registry; + private final PdfTemplateEngine engine; + private final PdfRenderer renderer; + + public PdfService(TemplateRegistry registry, PdfTemplateEngine engine, PdfRenderer renderer) { + this.registry = registry; + this.engine = engine; + this.renderer = renderer; + } + + public 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); + } +} diff --git a/src/main/java/com/imprimelibros/erp/pdf/PdfTemplateEngine.java b/src/main/java/com/imprimelibros/erp/pdf/PdfTemplateEngine.java new file mode 100644 index 0000000..cc588b6 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/PdfTemplateEngine.java @@ -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 model) { + Context ctx = new Context(locale); + if (model != null) model.forEach(ctx::setVariable); + return thymeleaf.process(templateName, ctx); + } +} diff --git a/src/main/java/com/imprimelibros/erp/pdf/TemplateRegistry.java b/src/main/java/com/imprimelibros/erp/pdf/TemplateRegistry.java new file mode 100644 index 0000000..153d88f --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pdf/TemplateRegistry.java @@ -0,0 +1,18 @@ +// com.imprimelibros.erp.pdf.TemplateRegistry.java +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) { + if (config.getTemplates() == null) return null; + return config.getTemplates().get(type.name() + ":" + templateId); + } +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java index e44bf70..36e65fa 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java @@ -531,7 +531,9 @@ public class PresupuestoController { "presupuesto.plantilla-cubierta", "presupuesto.plantilla-cubierta-text", "presupuesto.impresion-cubierta", - "presupuesto.impresion-cubierta-help"); + "presupuesto.impresion-cubierta-help", + "presupuesto.exito.guardado", + "presupuesto.add.error.save.title"); Map translations = translationService.getTranslations(locale, keys); model.addAttribute("languageBundle", translations); @@ -558,12 +560,14 @@ public class PresupuestoController { 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()); @@ -594,7 +598,7 @@ public class PresupuestoController { model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max")); model.addAttribute("appMode", "add"); - + if (!mode.equals("public")) { model.addAttribute("cliente_id", clienteId); } @@ -603,24 +607,6 @@ public class PresupuestoController { return "imprimelibros/presupuestos/presupuesto-form"; } - @GetMapping(value = "/api/get", produces = "application/json") - public ResponseEntity getPresupuesto( - @RequestParam("id") Long id, Authentication authentication) { - - Optional 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(); - } - } - @GetMapping(value = "/datatable/{tipo}", produces = "application/json") @ResponseBody public DataTablesResponse> datatable( @@ -638,7 +624,6 @@ public class PresupuestoController { } } - @DeleteMapping("/{id}") @Transactional public ResponseEntity delete(@PathVariable Long id, Authentication auth, Locale locale) { @@ -697,7 +682,25 @@ public class PresupuestoController { } } - @PostMapping(path="/save") + @GetMapping(value = "/api/get", produces = "application/json") + public ResponseEntity getPresupuesto( + @RequestParam("id") Long id, Authentication authentication) { + + Optional 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 body, Locale locale, HttpServletRequest request) { @@ -707,6 +710,11 @@ public class PresupuestoController { String mode = objectMapper.convertValue(body.get("mode"), String.class); @SuppressWarnings("unchecked") List> serviciosList = (List>) body.getOrDefault("servicios", List.of()); + Long cliente_id = objectMapper.convertValue(body.get("cliente_id"), Long.class); + Map datosMaquetacion = (Map) objectMapper + .convertValue(body.get("datosMaquetacion"), Map.class); + Map datosMarcapaginas = (Map) objectMapper + .convertValue(body.get("datosMarcapaginas"), Map.class); Set> violations = validator.validate(presupuesto, PresupuestoValidationGroups.All.class); @@ -715,34 +723,26 @@ public class PresupuestoController { Map errores = new HashMap<>(); for (ConstraintViolation v : violations) { String campo = v.getPropertyPath().toString(); - String mensaje = messageSource.getMessage(v.getMessage().replace("{", "").replace("}", ""), null, locale); + String mensaje = messageSource.getMessage(v.getMessage().replace("{", "").replace("}", ""), null, + locale); errores.put(campo, mensaje); } return ResponseEntity.badRequest().body(errores); } try { - var resumen = presupuestoService.getTextosResumen(presupuesto, serviciosList, locale); - Long cliente_id = objectMapper.convertValue(body.get("cliente_id"), Long.class); - if(id == null && cliente_id != null && !mode.equals("public")) { + Map saveResult = presupuestoService.guardarPresupuesto( + presupuesto, + serviciosList, + datosMaquetacion, + datosMarcapaginas, + mode, + cliente_id, + id, + request, + locale); - presupuesto.setUser(userRepo.findById(cliente_id).orElse(null)); - presupuesto.setOrigen(Presupuesto.Origen.privado); - } - if (mode.equals("public")) { - presupuesto.setOrigen(Presupuesto.Origen.publico); - String sessionId = request.getSession(true).getId(); - String ip = request.getRemoteAddr(); - - presupuesto = presupuestoService.getDatosLocalizacion(presupuesto, sessionId, ip); - if (id != null) { - presupuesto.setId(id); // para que actualice, no cree uno nuevo - } - } - presupuesto = presupuestoService.generateTotalizadores(presupuesto, serviciosList, resumen, locale); - - 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) { diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoFormDataMapper.java b/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoFormDataMapper.java index 31234bd..7b37bd8 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoFormDataMapper.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoFormDataMapper.java @@ -3,6 +3,9 @@ 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 jakarta.persistence.criteria.CriteriaBuilder.In; + import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -84,10 +87,17 @@ public class PresupuestoFormDataMapper { // ===== Servicios / Extras ===== public static class Servicios { - public List servicios = List.of(); + public List servicios = new ArrayList(); 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_marcapaginas = "_50x140_"; @@ -184,8 +194,11 @@ public class PresupuestoFormDataMapper { vm.selectedTirada = p.getSelectedTirada(); // ===== Servicios desde JSONs - // servicios_json: acepta ["maquetacion","marcapaginas"] o [{id:...}, ...] - vm.servicios.servicios = parseServiciosIds(p.getServiciosJson()); + vm.servicios.servicios = parse(p.getServiciosJson(), + new TypeReference>() { + }); + if (vm.servicios.servicios == null) + vm.servicios.servicios = new ArrayList<>(); // datos_maquetacion_json PresupuestoFormDataDto.Servicios.DatosMaquetacion maq = parse(p.getDatosMaquetacionJson(), @@ -230,31 +243,13 @@ public class PresupuestoFormDataMapper { } } - private List parseServiciosIds(String json) { - if (json == null || json.isBlank()) - return new ArrayList<>(); + private T parse(String json, TypeReference typeRef) { try { - // 1) intentar como lista de strings - List ids = om.readValue(json, new TypeReference>() { - }); - return ids != null ? ids : new ArrayList<>(); - } catch (Exception ignore) { - } - try { - // 2) intentar como lista de objetos con 'id' - List> list = om.readValue(json, new TypeReference<>() { - }); - List ids = new ArrayList<>(); - for (Map it : list) { - Object id = it.get("id"); - if (id != null) - ids.add(String.valueOf(id)); - } - return ids; - } catch ( - - Exception e) { - return new ArrayList<>(); + if (json == null || json.isBlank()) + return null; + return om.readValue(json, typeRef); + } catch (Exception e) { + return null; } } 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 9ef7524..be70a78 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoService.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/service/PresupuestoService.java @@ -41,6 +41,10 @@ import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatricesReposito import com.imprimelibros.erp.presupuesto.marcapaginas.MarcapaginasRepository; import com.imprimelibros.erp.users.UserDao; import com.imprimelibros.erp.users.UserDetailsImpl; + +import jakarta.persistence.criteria.CriteriaBuilder.In; +import jakarta.servlet.http.HttpServletRequest; + import com.imprimelibros.erp.externalApi.skApiClient; @Service @@ -448,6 +452,16 @@ public class PresupuestoService { : "0.00"; } + private String obtenerPrecioRetractilado(Integer tirada) { + + Map 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 obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) { List opciones = new ArrayList<>(); @@ -747,13 +761,20 @@ public class PresupuestoService { 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 precios = this.calcularPresupuesto(pressupuestoTemp, locale); if (precios.containsKey("error")) { resumen.put("error", precios.get("error")); return resumen; } + resumen.put("precios", precios); HashMap linea = new HashMap<>(); Double precio_unitario = 0.0; @@ -824,7 +845,7 @@ public class PresupuestoService { if (mode.equals("public")) { presupuesto = getDatosLocalizacion(presupuesto, sessionId, ip); - + } else presupuesto.setOrigen(Presupuesto.Origen.privado); @@ -847,7 +868,7 @@ public class PresupuestoService { if (save != null && save) { // Si NO es para guardar (solo calcular resumen), devolver sin persistir - this.guardarPresupuesto(presupuesto); + presupuestoRepository.saveAndFlush(presupuesto); } // Opcional: devolver el id guardado al frontend para que lo envíe en llamadas @@ -865,24 +886,24 @@ public class PresupuestoService { } 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) { - } + 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; } @@ -892,84 +913,185 @@ public class PresupuestoService { 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 { - @SuppressWarnings("unchecked") - List precios = (List) ((Map) resumen.getOrDefault("precios", Map.of())) - .getOrDefault("precios", List.of()); - if (precios.isEmpty()) { - // si no venía en "resumen", recalcúlalo directamente - var preciosCalc = this.calcularPresupuesto(presupuesto, locale); - precios = (List) ((Map) preciosCalc.get("data")).get("precios"); - } - precioUnit = precios.get(0); - // guarda el snapshot completo de precios para auditoría - presupuesto.setPreciosPorTiradaJson(new ObjectMapper().writeValueAsString(precios)); - } catch (Exception ignore) { + Map> pricing_snapshot = new HashMap<>(); + + @SuppressWarnings("unchecked") + Map preciosNode = (Map) resumen.getOrDefault("precios", Map.of()); + @SuppressWarnings("unchecked") + Map data = (Map) preciosNode.getOrDefault("data", Map.of()); + @SuppressWarnings("unchecked") + List tiradas = (List) data.getOrDefault("tiradas", List.of()); + @SuppressWarnings("unchecked") + List precios = (List) data.getOrDefault("precios", List.of()); + @SuppressWarnings("unchecked") + List pesos = (List) data.getOrDefault("peso", List.of()); + if (precios.isEmpty()) { + var preciosCalc = this.calcularPresupuesto(presupuesto, locale); + precios = (List) ((Map) preciosCalc.get("data")).getOrDefault("precios", List.of()); } - BigDecimal precioTotalTirada = BigDecimal.valueOf(precioUnit) - .multiply(BigDecimal.valueOf(cantidad)) - .setScale(2, RoundingMode.HALF_UP); + // iterate getTiradas with a foreach with not null + for (Integer tirada : presupuesto.getTiradas()) { + if (tirada == null) { + continue; + } - // servicios_total - BigDecimal serviciosTotal = BigDecimal.ZERO; - if (servicios != null) { - for (Map s : servicios) { + // 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); + + // servicios_total + BigDecimal serviciosTotal = BigDecimal.ZERO; + if (servicios != null) { + for (Map s : servicios) { + try { + // retractilado o ejemplar-prueba: 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); + } + 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 { - 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))); + presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios)); } catch (Exception ignore) { } } + + // base imponible, IVA y total (si tienes IVA configurable, úsalo; si no, 0) + BigDecimal baseImponible = precioTotalTirada.add(serviciosTotal); + BigDecimal ivaTipo = BigDecimal.ZERO; try { - presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios)); + double iva = 4.0; // 0..100 + ivaTipo = BigDecimal.valueOf(iva); } catch (Exception ignore) { } + BigDecimal ivaImporte = baseImponible.multiply(ivaTipo).divide(BigDecimal.valueOf(100), 2, + RoundingMode.HALF_UP); + BigDecimal totalConIva = baseImponible.add(ivaImporte); + + // 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.setIvaTipo(ivaTipo); + presupuesto.setIvaImporte(ivaImporte); + presupuesto.setTotalConIva(totalConIva); + } + Map 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_tipo", ivaTipo); + snap.put("iva_importe", ivaImporte); + snap.put("total_con_iva", totalConIva); + snap.put("peso", (index >= 0 && index < pesos.size()) ? pesos.get(index) : 0.0); + + pricing_snapshot.put(tirada, snap); + } - // base imponible, IVA y total (si tienes IVA configurable, úsalo; si no, 0) - BigDecimal baseImponible = precioTotalTirada.add(serviciosTotal); - BigDecimal ivaTipo = BigDecimal.ZERO; try { - double iva = 4.0; // 0..100 - ivaTipo = BigDecimal.valueOf(iva); + String json = new ObjectMapper() + .writer() + .withDefaultPrettyPrinter() // opcional + .writeValueAsString(pricing_snapshot); + presupuesto.setPricingSnapshotJson(pricing_snapshot.isEmpty() ? null : json); } catch (Exception ignore) { } - BigDecimal ivaImporte = baseImponible.multiply(ivaTipo).divide(BigDecimal.valueOf(100), 2, - RoundingMode.HALF_UP); - BigDecimal totalConIva = baseImponible.add(ivaImporte); - - // precios y totales - presupuesto.setPrecioUnitario(BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP)); - presupuesto.setPrecioTotalTirada(precioTotalTirada); - presupuesto.setServiciosTotal(serviciosTotal); - presupuesto.setBaseImponible(baseImponible); - presupuesto.setIvaTipo(ivaTipo); - presupuesto.setIvaImporte(ivaImporte); - presupuesto.setTotalConIva(totalConIva); return presupuesto; } @Transactional - public HashMap guardarPresupuesto(Presupuesto presupuesto) { - + public HashMap guardarPresupuesto( + Presupuesto presupuesto, + List> serviciosList, + Map datosMaquetacion, + Map datosMarcapaginas, + String mode, + Long cliente_id, + Long id, + HttpServletRequest request, + Locale locale) { + HashMap result = new HashMap<>(); try { - Presupuesto p = presupuestoRepository.saveAndFlush(presupuesto); + presupuesto.setDatosMaquetacionJson( + datosMaquetacion != null ? new ObjectMapper().writeValueAsString(datosMaquetacion) : null); + presupuesto.setDatosMarcapaginasJson( + datosMarcapaginas != null ? new ObjectMapper().writeValueAsString(datosMarcapaginas) : null); + var resumen = this.getTextosResumen(presupuesto, serviciosList, 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 = request.getRemoteAddr(); + + 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", p.getId()); - + result.put("presupuesto_id", presupuesto.getId()); + return result; } catch (Exception e) { @@ -1007,7 +1129,7 @@ public class PresupuestoService { if (isUser) { // Si es usuario, solo puede ver sus propios presupuestos String username = authentication.getName(); - if (!presupuesto.getUser().getUserName().equals(username)) { + if (presupuesto.getUser() == null || !presupuesto.getUser().getUserName().equals(username)) { return false; } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4f79442..a020a4d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -85,3 +85,9 @@ geoip.http.enabled=true # Hibernate Timezone # spring.jpa.properties.hibernate.jdbc.time_zone=UTC + +# +# PDF Templates +# +imprimelibros.pdf.templates.PRESUPUESTO\:presupuesto-a4=imprimelibros/pdf/presupuesto-a4 +imprimelibros.pdf.templates.FACTURA\:factura-a4=imprimelibros/pdf/factura-a4 \ No newline at end of file diff --git a/src/main/resources/static/assets/css/presupuestopdf.css b/src/main/resources/static/assets/css/presupuestopdf.css new file mode 100644 index 0000000..6ad90ee --- /dev/null +++ b/src/main/resources/static/assets/css/presupuestopdf.css @@ -0,0 +1,225 @@ +/* === Fuentes === + * Para HTML→PDF con OpenHTMLtoPDF, es preferible registrar las TTF en el renderer. + * Aun así, incluimos @font-face por si renderizas en navegador. + * Coloca los TTF en /static/fonts/ y ajusta los nombres si procede. + */ +@font-face { + font-family: "Open Sans"; + src: url("/assets/fonts/OpenSans-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; +} +@font-face { + font-family: "Open Sans"; + src: url("/assets/fonts/OpenSans-SemiBold.ttf") format("truetype"); + font-weight: 600; + font-style: normal; +} +@font-face { + font-family: "Open Sans"; + src: url("/assets/fonts/OpenSans-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; +} + +:root { + --text: #1f2937; + --muted: #6b7280; + --border: #e5e7eb; + --brand: #0ea5e9; /* azul suave */ + --bg-light: #f8fafc; /* gris muy claro */ +} + +@page { + size: A4; + margin: 18mm 15mm 22mm 15mm; + @bottom-right { + content: "Página " counter(page) " / " counter(pages); + } +} +html, body { + font-family: "Open Sans", Arial, sans-serif; + color: var(--text); + font-size: 11pt; + line-height: 1.35; +} +* { box-sizing: border-box; } + +.doc-header { + display: table; + width: 100%; + margin-bottom: 10mm; + border-bottom: 2px solid var(--brand); + padding-bottom: 6mm; +} +.brand { + display: table-cell; + vertical-align: top; + width: 60%; +} +.logo { + height: 34px; + margin-bottom: 6px; +} +.brand-meta { margin-top: 4px; } +.company-name { + font-weight: 700; + font-size: 12pt; +} +.company-meta { + color: var(--muted); + font-size: 9.5pt; +} + +.doc-title { + display: table-cell; + vertical-align: top; + width: 40%; + text-align: right; +} +.title-main { + font-size: 18pt; + font-weight: 700; + letter-spacing: 0.5px; +} +.doc-info { + margin-top: 6px; + margin-left: auto; + font-size: 10pt; + border-collapse: collapse; +} +.doc-info td { + padding: 3px 0 3px 12px; + border-bottom: 1px solid var(--border); +} +.doc-info td:first-child { + color: var(--muted); + padding-left: 0; +} +.right { text-align: right; } + +.blocks { + display: table; + width: 100%; + table-layout: fixed; + margin-bottom: 8mm; +} +.block { + display: table-cell; + width: 50%; + padding-right: 6mm; + vertical-align: top; +} +.block:last-child { padding-right: 0; } +.block-title { + font-weight: 600; + text-transform: uppercase; + font-size: 10pt; + color: var(--brand); + margin-bottom: 2mm; +} +.block-body { + border: 1px solid var(--border); + padding: 4mm; + background: var(--bg-light); +} +.row { + margin-bottom: 2mm; + display: block; +} +.row .label { + display: inline-block; + width: 27%; + color: var(--muted); +} +.row .value { + display: inline-block; + width: 70%; + vertical-align: top; +} + +.table-section .section-title, +.notes .section-title { + font-weight: 600; + font-size: 10.5pt; + margin: 6mm 0 2mm 0; +} + +.items { + width: 100%; + border-collapse: collapse; + font-size: 10.5pt; +} +.items thead th { + text-align: left; + border-bottom: 2px solid var(--brand); + padding: 6px 6px; + background: #eef8fe; + font-weight: 600; +} +.items tbody td { + border-bottom: 1px solid var(--border); + padding: 6px 6px; + vertical-align: top; +} +.items .col-right { text-align: right; } +.items .col-center { text-align: center; } +.items .col-desc .desc { font-weight: 600; } +.items .col-desc .meta { color: var(--muted); font-size: 9.5pt; margin-top: 2px; } +.items .group-header td { + background: var(--bg-light); + color: var(--brand); + font-weight: 600; +} + +.totals { + margin-top: 6mm; + display: block; + width: 100%; +} +.totals-table { + margin-left: auto; + border-collapse: collapse; + min-width: 70mm; +} +.totals-table td { + padding: 4px 0 4px 14px; + border-bottom: 1px solid var(--border); +} +.totals-table td:first-child { + color: var(--muted); + padding-left: 0; +} +.totals-table .total-row td { + border-bottom: 2px solid var(--brand); + padding-top: 6px; + padding-bottom: 6px; +} +.totals-table .right { text-align: right; } + +.notes .note-text { + border: 1px solid var(--border); + padding: 4mm; + background: #fff; + font-size: 10pt; + white-space: pre-wrap; +} + +.doc-footer { + margin-top: 8mm; + padding-top: 4mm; + border-top: 1px solid var(--border); + display: table; + width: 100%; + color: var(--muted); + font-size: 9pt; +} +.footer-left { display: table-cell; } +.footer-right { + display: table-cell; + text-align: right; +} + +/* Compatibilidad PDF (OpenHTMLtoPDF) */ +.page-number::after { content: counter(page); } +.page-count::after { content: counter(pages); } diff --git a/src/main/resources/static/assets/fonts/OpenSans-Bold.ttf b/src/main/resources/static/assets/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000..fd79d43 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-Bold.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-BoldItalic.ttf b/src/main/resources/static/assets/fonts/OpenSans-BoldItalic.ttf new file mode 100644 index 0000000..9bc8009 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-BoldItalic.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-ExtraBold.ttf b/src/main/resources/static/assets/fonts/OpenSans-ExtraBold.ttf new file mode 100644 index 0000000..21f6f84 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-ExtraBold.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-ExtraBoldItalic.ttf b/src/main/resources/static/assets/fonts/OpenSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000..31cb688 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-ExtraBoldItalic.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-Italic.ttf b/src/main/resources/static/assets/fonts/OpenSans-Italic.ttf new file mode 100644 index 0000000..c90da48 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-Italic.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-Light.ttf b/src/main/resources/static/assets/fonts/OpenSans-Light.ttf new file mode 100644 index 0000000..0d38189 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-Light.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-LightItalic.ttf b/src/main/resources/static/assets/fonts/OpenSans-LightItalic.ttf new file mode 100644 index 0000000..68299c4 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-LightItalic.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-Regular.ttf b/src/main/resources/static/assets/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..db43334 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-Regular.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-Semibold.ttf b/src/main/resources/static/assets/fonts/OpenSans-Semibold.ttf new file mode 100644 index 0000000..1a7679e Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-Semibold.ttf differ diff --git a/src/main/resources/static/assets/fonts/OpenSans-SemiboldItalic.ttf b/src/main/resources/static/assets/fonts/OpenSans-SemiboldItalic.ttf new file mode 100644 index 0000000..59b6d16 Binary files /dev/null and b/src/main/resources/static/assets/fonts/OpenSans-SemiboldItalic.ttf differ diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js index a43824b..b06220c 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js @@ -1,9 +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: false, + useSessionCache: true, }); app.init(); \ No newline at end of file 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 index 9407e55..4abe859 100644 --- 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 @@ -1,5 +1,8 @@ import PresupuestoWizard from './wizard.js'; +// remove formData from sessionStorage to avoid conflicts +sessionStorage.removeItem('formData'); + const app = new PresupuestoWizard({ mode: 'public', readonly: false, 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 86ee6b5..d481a05 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 @@ -4,6 +4,6 @@ const app = new PresupuestoWizard({ mode: 'public', readonly: true, canSave: false, - useSessionCache: false, + useSessionCache: true, }); app.init(); \ No newline at end of file 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 4e2ec96..19968d6 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 @@ -180,6 +180,8 @@ export default class PresupuestoWizard { async init() { + const self = this; + $.ajaxSetup({ beforeSend: function (xhr) { const token = document.querySelector('meta[name="_csrf"]')?.content; @@ -190,7 +192,7 @@ export default class PresupuestoWizard { const root = document.getElementById('presupuesto-app'); const mode = root?.dataset.mode || 'public'; - const presupuestoId = root?.dataset.id || null; + const presupuestoId = this.opts.presupuestoId || null; let stored = null; if (this.opts.useSessionCache) { @@ -249,26 +251,19 @@ export default class PresupuestoWizard { 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, + 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(); $.ajax({ - url: '/presupuesto/save', + url: '/presupuesto/api/save', type: 'POST', contentType: 'application/json', data: JSON.stringify(payload) @@ -341,7 +336,7 @@ export default class PresupuestoWizard { ...this.#getInteriorData(), ...this.#getCubiertaData(), selectedTirada: this.formData.selectedTirada - + }; @@ -501,6 +496,18 @@ export default class PresupuestoWizard { $('.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(); @@ -1592,7 +1599,14 @@ export default class PresupuestoWizard { const $target = $(e.currentTarget); if ($target.prop('checked')) { - this.formData.servicios.servicios.push($target.val()); + this.formData.servicios.servicios.push( + $target.val(), + { + 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: $target.data('price') ?? $(`label[for="${$target.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), ''), + }); } else { const index = this.formData.servicios.servicios.indexOf($target.val()); if (index > -1) { @@ -1615,7 +1629,7 @@ export default class PresupuestoWizard { 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" ? @@ -1626,11 +1640,6 @@ export default class PresupuestoWizard { } 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); - } - } } this.#cacheFormData(); diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/list-user.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/list-user.js new file mode 100644 index 0000000..ade7b1c --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestos/list-user.js @@ -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(); + }); + +})(); 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 25bcccd..d7af401 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 @@ -24,12 +24,11 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js'; processing: true, serverSide: true, orderCellsTop: true, - stateSave: true, pageLength: 50, lengthMenu: [10, 25, 50, 100, 500], language: { url: '/assets/libs/datatables/i18n/' + language + '.json' }, responsive: true, - dom: 'Bflrtip', + dom: 'lBrtip', buttons: { dom: { button: { @@ -51,20 +50,20 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js'; }, order: [[0, 'asc']], columns: [ - { data: 'id', name: 'id', title: 'ID', orderable: true }, - { data: 'titulo', name: 'titulo', title: 'Título', orderable: true }, - { data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', title: 'Encuadernación', orderable: true }, - { data: 'tipoCubierta', name: 'tipoCubierta', title: 'Cubierta', orderable: true }, - { data: 'tipoImpresion', name: 'tipoImpresion', title: 'Tipo de impresión', orderable: true }, - { data: 'tirada', name: 'selectedTirada', title: 'Tirada', orderable: true }, - { data: 'paginas', name: 'paginas', title: 'Páginas', orderable: true }, - { data: 'estado', name: 'estado', title: 'Estado', orderable: true }, - { data: 'totalConIva', name: 'totalConIva', title: 'Total con IVA', orderable: true }, - { data: 'pais', name: 'pais', title: 'País', orderable: true }, - { data: 'region', name: 'region', title: 'Región', orderable: true }, - { data: 'ciudad', name: 'ciudad', title: 'Ciudad', orderable: true }, - { data: 'updatedAt', name: 'updatedAt', title: 'Actualizado el', orderable: true }, - { data: 'actions', title: 'Acciones', orderable: false, searchable: false } + { 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 } ], }); @@ -130,6 +129,148 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js'; }); }); + $('#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(); diff --git a/src/main/resources/templates/imprimelibros/partials/sidebar.html b/src/main/resources/templates/imprimelibros/partials/sidebar.html index 3dc8e52..508874d 100644 --- a/src/main/resources/templates/imprimelibros/partials/sidebar.html +++ b/src/main/resources/templates/imprimelibros/partials/sidebar.html @@ -43,7 +43,7 @@ Presupuestos -