mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-28 14:48:50 +00:00
preparando el imprimir
This commit is contained in:
12
pom.xml
12
pom.xml
@ -139,6 +139,18 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PDF generation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.openhtmltopdf</groupId>
|
||||||
|
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||||
|
<version>1.0.10</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.openhtmltopdf</groupId>
|
||||||
|
<artifactId>openhtmltopdf-slf4j</artifactId>
|
||||||
|
<version>1.0.10</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@ -31,7 +31,7 @@ public class DataTablesSpecification {
|
|||||||
ands.add(like(cb, path, col.search.value));
|
ands.add(like(cb, path, col.search.value));
|
||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
// columna no mapeada o relación: la ignoramos
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,7 +90,7 @@ public class skApiClient {
|
|||||||
margen.getMargenMax(),
|
margen.getMargenMax(),
|
||||||
margen.getMargenMin());
|
margen.getMargenMin());
|
||||||
double nuevoPrecio = precios.get(i) * (1 + margenValue / 100.0);
|
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 {
|
} else {
|
||||||
System.out.println("No se encontró margen para tirada " + tirada);
|
System.out.println("No se encontró margen para tirada " + tirada);
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/main/java/com/imprimelibros/erp/pdf/DocumentSpec.java
Normal file
11
src/main/java/com/imprimelibros/erp/pdf/DocumentSpec.java
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package com.imprimelibros.erp.pdf;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record DocumentSpec(
|
||||||
|
DocumentType type,
|
||||||
|
String templateId, // p.ej. "presupuesto-a4"
|
||||||
|
Locale locale,
|
||||||
|
Map<String, Object> model // data del documento
|
||||||
|
) {}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.imprimelibros.erp.pdf;
|
||||||
|
|
||||||
|
public enum DocumentType {
|
||||||
|
PRESUPUESTO, PEDIDO, FACTURA
|
||||||
|
}
|
||||||
32
src/main/java/com/imprimelibros/erp/pdf/PdfController.java
Normal file
32
src/main/java/com/imprimelibros/erp/pdf/PdfController.java
Normal file
@ -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<byte[]> generate(
|
||||||
|
@PathVariable("type") DocumentType type,
|
||||||
|
@PathVariable String templateId,
|
||||||
|
@RequestBody Map<String,Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/main/java/com/imprimelibros/erp/pdf/PdfModuleConfig.java
Normal file
18
src/main/java/com/imprimelibros/erp/pdf/PdfModuleConfig.java
Normal file
@ -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<String, String> templates;
|
||||||
|
|
||||||
|
public Map<String, String> getTemplates() { return templates; }
|
||||||
|
public void setTemplates(Map<String, String> templates) { this.templates = templates; }
|
||||||
|
}
|
||||||
44
src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java
Normal file
44
src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/main/java/com/imprimelibros/erp/pdf/PdfService.java
Normal file
25
src/main/java/com/imprimelibros/erp/pdf/PdfService.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.imprimelibros.erp.pdf;
|
||||||
|
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PdfTemplateEngine {
|
||||||
|
private final SpringTemplateEngine thymeleaf;
|
||||||
|
|
||||||
|
public PdfTemplateEngine(SpringTemplateEngine thymeleaf) {
|
||||||
|
this.thymeleaf = thymeleaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String render(String templateName, Locale locale, Map<String,Object> model) {
|
||||||
|
Context ctx = new Context(locale);
|
||||||
|
if (model != null) model.forEach(ctx::setVariable);
|
||||||
|
return thymeleaf.process(templateName, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -531,7 +531,9 @@ public class PresupuestoController {
|
|||||||
"presupuesto.plantilla-cubierta",
|
"presupuesto.plantilla-cubierta",
|
||||||
"presupuesto.plantilla-cubierta-text",
|
"presupuesto.plantilla-cubierta-text",
|
||||||
"presupuesto.impresion-cubierta",
|
"presupuesto.impresion-cubierta",
|
||||||
"presupuesto.impresion-cubierta-help");
|
"presupuesto.impresion-cubierta-help",
|
||||||
|
"presupuesto.exito.guardado",
|
||||||
|
"presupuesto.add.error.save.title");
|
||||||
|
|
||||||
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||||
model.addAttribute("languageBundle", translations);
|
model.addAttribute("languageBundle", translations);
|
||||||
@ -558,12 +560,14 @@ public class PresupuestoController {
|
|||||||
return "redirect:/presupuesto";
|
return "redirect:/presupuesto";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model.addAttribute("presupuesto_id", presupuestoOpt.get().getId());
|
||||||
String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
|
String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
|
||||||
.getRequest().getRequestURI();
|
.getRequest().getRequestURI();
|
||||||
String mode = path.contains("/view/") ? "view" : "edit";
|
String mode = path.contains("/view/") ? "view" : "edit";
|
||||||
if (mode.equals("view")) {
|
if (mode.equals("view")) {
|
||||||
model.addAttribute("appMode", "view");
|
model.addAttribute("appMode", "view");
|
||||||
} else {
|
} else {
|
||||||
|
model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId());
|
||||||
model.addAttribute("appMode", "edit");
|
model.addAttribute("appMode", "edit");
|
||||||
}
|
}
|
||||||
model.addAttribute("id", presupuestoOpt.get().getId());
|
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("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
|
||||||
|
|
||||||
model.addAttribute("appMode", "add");
|
model.addAttribute("appMode", "add");
|
||||||
|
|
||||||
if (!mode.equals("public")) {
|
if (!mode.equals("public")) {
|
||||||
model.addAttribute("cliente_id", clienteId);
|
model.addAttribute("cliente_id", clienteId);
|
||||||
}
|
}
|
||||||
@ -603,24 +607,6 @@ public class PresupuestoController {
|
|||||||
return "imprimelibros/presupuestos/presupuesto-form";
|
return "imprimelibros/presupuestos/presupuesto-form";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/api/get", produces = "application/json")
|
|
||||||
public ResponseEntity<PresupuestoFormDataDto> getPresupuesto(
|
|
||||||
@RequestParam("id") Long id, Authentication authentication) {
|
|
||||||
|
|
||||||
Optional<Presupuesto> presupuestoOpt = presupuestoRepository.findById(id);
|
|
||||||
|
|
||||||
if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) {
|
|
||||||
return ResponseEntity.status(403).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (presupuestoOpt.isPresent()) {
|
|
||||||
PresupuestoFormDataDto vm = formDataMapper.toFormData(presupuestoOpt.get());
|
|
||||||
return ResponseEntity.ok(vm);
|
|
||||||
} else {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(value = "/datatable/{tipo}", produces = "application/json")
|
@GetMapping(value = "/datatable/{tipo}", produces = "application/json")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public DataTablesResponse<Map<String, Object>> datatable(
|
public DataTablesResponse<Map<String, Object>> datatable(
|
||||||
@ -638,7 +624,6 @@ public class PresupuestoController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
|
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<PresupuestoFormDataDto> getPresupuesto(
|
||||||
|
@RequestParam("id") Long id, Authentication authentication) {
|
||||||
|
|
||||||
|
Optional<Presupuesto> presupuestoOpt = presupuestoRepository.findById(id);
|
||||||
|
|
||||||
|
if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (presupuestoOpt.isPresent()) {
|
||||||
|
PresupuestoFormDataDto vm = formDataMapper.toFormData(presupuestoOpt.get());
|
||||||
|
return ResponseEntity.ok(vm);
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "api/save")
|
||||||
public ResponseEntity<?> save(
|
public ResponseEntity<?> save(
|
||||||
@RequestBody Map<String, Object> body,
|
@RequestBody Map<String, Object> body,
|
||||||
Locale locale, HttpServletRequest request) {
|
Locale locale, HttpServletRequest request) {
|
||||||
@ -707,6 +710,11 @@ public class PresupuestoController {
|
|||||||
String mode = objectMapper.convertValue(body.get("mode"), String.class);
|
String mode = objectMapper.convertValue(body.get("mode"), String.class);
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
|
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
|
||||||
|
Long cliente_id = objectMapper.convertValue(body.get("cliente_id"), Long.class);
|
||||||
|
Map<String, Object> datosMaquetacion = (Map<String, Object>) objectMapper
|
||||||
|
.convertValue(body.get("datosMaquetacion"), Map.class);
|
||||||
|
Map<String, Object> datosMarcapaginas = (Map<String, Object>) objectMapper
|
||||||
|
.convertValue(body.get("datosMarcapaginas"), Map.class);
|
||||||
|
|
||||||
Set<ConstraintViolation<Presupuesto>> violations = validator.validate(presupuesto,
|
Set<ConstraintViolation<Presupuesto>> violations = validator.validate(presupuesto,
|
||||||
PresupuestoValidationGroups.All.class);
|
PresupuestoValidationGroups.All.class);
|
||||||
@ -715,34 +723,26 @@ public class PresupuestoController {
|
|||||||
Map<String, String> errores = new HashMap<>();
|
Map<String, String> errores = new HashMap<>();
|
||||||
for (ConstraintViolation<Presupuesto> v : violations) {
|
for (ConstraintViolation<Presupuesto> v : violations) {
|
||||||
String campo = v.getPropertyPath().toString();
|
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);
|
errores.put(campo, mensaje);
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(errores);
|
return ResponseEntity.badRequest().body(errores);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var resumen = presupuestoService.getTextosResumen(presupuesto, serviciosList, locale);
|
|
||||||
|
|
||||||
Long cliente_id = objectMapper.convertValue(body.get("cliente_id"), Long.class);
|
Map<String, Object> saveResult = presupuestoService.guardarPresupuesto(
|
||||||
if(id == null && cliente_id != null && !mode.equals("public")) {
|
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<String, Object> saveResult = presupuestoService.guardarPresupuesto(presupuesto);
|
|
||||||
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
|
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
|
||||||
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
|
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
|||||||
@ -3,6 +3,9 @@ package com.imprimelibros.erp.presupuesto.service;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
|
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
|
||||||
|
|
||||||
|
import jakarta.persistence.criteria.CriteriaBuilder.In;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -84,10 +87,17 @@ public class PresupuestoFormDataMapper {
|
|||||||
|
|
||||||
// ===== Servicios / Extras =====
|
// ===== Servicios / Extras =====
|
||||||
public static class Servicios {
|
public static class Servicios {
|
||||||
public List<String> servicios = List.of();
|
public List<DatosServicios> servicios = new ArrayList<DatosServicios>();
|
||||||
public DatosMarcapaginas datosMarcapaginas = new DatosMarcapaginas();
|
public DatosMarcapaginas datosMarcapaginas = new DatosMarcapaginas();
|
||||||
public DatosMaquetacion datosMaquetacion = new DatosMaquetacion();
|
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 static class DatosMarcapaginas {
|
||||||
public Integer marcapaginas_tirada = 100;
|
public Integer marcapaginas_tirada = 100;
|
||||||
public String tamanio_marcapaginas = "_50x140_";
|
public String tamanio_marcapaginas = "_50x140_";
|
||||||
@ -184,8 +194,11 @@ public class PresupuestoFormDataMapper {
|
|||||||
vm.selectedTirada = p.getSelectedTirada();
|
vm.selectedTirada = p.getSelectedTirada();
|
||||||
|
|
||||||
// ===== Servicios desde JSONs
|
// ===== Servicios desde JSONs
|
||||||
// servicios_json: acepta ["maquetacion","marcapaginas"] o [{id:...}, ...]
|
vm.servicios.servicios = parse(p.getServiciosJson(),
|
||||||
vm.servicios.servicios = parseServiciosIds(p.getServiciosJson());
|
new TypeReference<List<PresupuestoFormDataDto.Servicios.DatosServicios>>() {
|
||||||
|
});
|
||||||
|
if (vm.servicios.servicios == null)
|
||||||
|
vm.servicios.servicios = new ArrayList<>();
|
||||||
|
|
||||||
// datos_maquetacion_json
|
// datos_maquetacion_json
|
||||||
PresupuestoFormDataDto.Servicios.DatosMaquetacion maq = parse(p.getDatosMaquetacionJson(),
|
PresupuestoFormDataDto.Servicios.DatosMaquetacion maq = parse(p.getDatosMaquetacionJson(),
|
||||||
@ -230,31 +243,13 @@ public class PresupuestoFormDataMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> parseServiciosIds(String json) {
|
private <T> T parse(String json, TypeReference<T> typeRef) {
|
||||||
if (json == null || json.isBlank())
|
|
||||||
return new ArrayList<>();
|
|
||||||
try {
|
try {
|
||||||
// 1) intentar como lista de strings
|
if (json == null || json.isBlank())
|
||||||
List<String> ids = om.readValue(json, new TypeReference<List<String>>() {
|
return null;
|
||||||
});
|
return om.readValue(json, typeRef);
|
||||||
return ids != null ? ids : new ArrayList<>();
|
} catch (Exception e) {
|
||||||
} catch (Exception ignore) {
|
return null;
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 2) intentar como lista de objetos con 'id'
|
|
||||||
List<Map<String, Object>> list = om.readValue(json, new TypeReference<>() {
|
|
||||||
});
|
|
||||||
List<String> ids = new ArrayList<>();
|
|
||||||
for (Map<String, Object> it : list) {
|
|
||||||
Object id = it.get("id");
|
|
||||||
if (id != null)
|
|
||||||
ids.add(String.valueOf(id));
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
} catch (
|
|
||||||
|
|
||||||
Exception e) {
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,10 @@ import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatricesReposito
|
|||||||
import com.imprimelibros.erp.presupuesto.marcapaginas.MarcapaginasRepository;
|
import com.imprimelibros.erp.presupuesto.marcapaginas.MarcapaginasRepository;
|
||||||
import com.imprimelibros.erp.users.UserDao;
|
import com.imprimelibros.erp.users.UserDao;
|
||||||
import com.imprimelibros.erp.users.UserDetailsImpl;
|
import com.imprimelibros.erp.users.UserDetailsImpl;
|
||||||
|
|
||||||
|
import jakarta.persistence.criteria.CriteriaBuilder.In;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import com.imprimelibros.erp.externalApi.skApiClient;
|
import com.imprimelibros.erp.externalApi.skApiClient;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -448,6 +452,16 @@ public class PresupuestoService {
|
|||||||
: "0.00";
|
: "0.00";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String obtenerPrecioRetractilado(Integer tirada) {
|
||||||
|
|
||||||
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
|
requestBody.put("tirada",tirada != null ? tirada : 0);
|
||||||
|
Double precio_retractilado = apiClient.getRetractilado(requestBody);
|
||||||
|
return precio_retractilado != null
|
||||||
|
? String.valueOf(Math.round(precio_retractilado * 100.0) / 100.0)
|
||||||
|
: "0.00";
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Object> obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) {
|
public Map<String, Object> obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) {
|
||||||
List<Object> opciones = new ArrayList<>();
|
List<Object> opciones = new ArrayList<>();
|
||||||
|
|
||||||
@ -747,13 +761,20 @@ public class PresupuestoService {
|
|||||||
if (hayDepositoLegal) {
|
if (hayDepositoLegal) {
|
||||||
pressupuestoTemp.setSelectedTirada(
|
pressupuestoTemp.setSelectedTirada(
|
||||||
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() + 4 : 4);
|
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() + 4 : 4);
|
||||||
|
for (Integer i = 0; i < pressupuestoTemp.getTiradas().length; i++) {
|
||||||
|
Integer tirada = pressupuestoTemp.getTiradas()[i];
|
||||||
|
if (tirada != null && tirada >= 4) {
|
||||||
|
tirada = tirada + 4;
|
||||||
|
}
|
||||||
|
pressupuestoTemp.getTiradas()[i] = tirada;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HashMap<String, Object> precios = this.calcularPresupuesto(pressupuestoTemp, locale);
|
HashMap<String, Object> precios = this.calcularPresupuesto(pressupuestoTemp, locale);
|
||||||
if (precios.containsKey("error")) {
|
if (precios.containsKey("error")) {
|
||||||
resumen.put("error", precios.get("error"));
|
resumen.put("error", precios.get("error"));
|
||||||
return resumen;
|
return resumen;
|
||||||
}
|
}
|
||||||
|
resumen.put("precios", precios);
|
||||||
|
|
||||||
HashMap<String, Object> linea = new HashMap<>();
|
HashMap<String, Object> linea = new HashMap<>();
|
||||||
Double precio_unitario = 0.0;
|
Double precio_unitario = 0.0;
|
||||||
@ -824,7 +845,7 @@ public class PresupuestoService {
|
|||||||
if (mode.equals("public")) {
|
if (mode.equals("public")) {
|
||||||
|
|
||||||
presupuesto = getDatosLocalizacion(presupuesto, sessionId, ip);
|
presupuesto = getDatosLocalizacion(presupuesto, sessionId, ip);
|
||||||
|
|
||||||
} else
|
} else
|
||||||
presupuesto.setOrigen(Presupuesto.Origen.privado);
|
presupuesto.setOrigen(Presupuesto.Origen.privado);
|
||||||
|
|
||||||
@ -847,7 +868,7 @@ public class PresupuestoService {
|
|||||||
|
|
||||||
if (save != null && save) {
|
if (save != null && save) {
|
||||||
// Si NO es para guardar (solo calcular resumen), devolver sin persistir
|
// 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
|
// 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) {
|
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
|
presupuesto.setOrigen(Presupuesto.Origen.publico);
|
||||||
// líneas)
|
presupuesto.setSessionId(sessionId);
|
||||||
try {
|
// IP: guarda hash y trunc (si tienes campos). Si no, guarda tal cual en
|
||||||
GeoIpService.GeoData geo = geoIpService.lookup(ip).orElse(null);
|
// ip_trunc/ip_hash según tu modelo.
|
||||||
presupuesto.setPais(geo.getPais());
|
String ipTrunc = anonymizeIp(ip);
|
||||||
presupuesto.setRegion(geo.getRegion());
|
presupuesto.setIpTrunc(ipTrunc);
|
||||||
presupuesto.setCiudad(geo.getCiudad());
|
presupuesto.setIpHash(Integer.toHexString(ip.hashCode()));
|
||||||
} catch (Exception ignore) {
|
|
||||||
}
|
// 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;
|
return presupuesto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -892,84 +913,185 @@ public class PresupuestoService {
|
|||||||
Map<String, Object> resumen,
|
Map<String, Object> resumen,
|
||||||
Locale locale) {
|
Locale locale) {
|
||||||
|
|
||||||
// Genera los totalizadores (precio unitario, total tirada, etc.) sin guardar
|
Map<Integer, Map<String, Object>> pricing_snapshot = new HashMap<>();
|
||||||
double precioUnit = 0.0;
|
|
||||||
int cantidad = presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0;
|
@SuppressWarnings("unchecked")
|
||||||
try {
|
Map<String, Object> preciosNode = (Map<String, Object>) resumen.getOrDefault("precios", Map.of());
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<Double> precios = (List<Double>) ((Map<String, Object>) resumen.getOrDefault("precios", Map.of()))
|
Map<String, Object> data = (Map<String, Object>) preciosNode.getOrDefault("data", Map.of());
|
||||||
.getOrDefault("precios", List.of());
|
@SuppressWarnings("unchecked")
|
||||||
if (precios.isEmpty()) {
|
List<Integer> tiradas = (List<Integer>) data.getOrDefault("tiradas", List.of());
|
||||||
// si no venía en "resumen", recalcúlalo directamente
|
@SuppressWarnings("unchecked")
|
||||||
var preciosCalc = this.calcularPresupuesto(presupuesto, locale);
|
List<Double> precios = (List<Double>) data.getOrDefault("precios", List.of());
|
||||||
precios = (List<Double>) ((Map<String, Object>) preciosCalc.get("data")).get("precios");
|
@SuppressWarnings("unchecked")
|
||||||
}
|
List<Double> pesos = (List<Double>) data.getOrDefault("peso", List.of());
|
||||||
precioUnit = precios.get(0);
|
if (precios.isEmpty()) {
|
||||||
// guarda el snapshot completo de precios para auditoría
|
var preciosCalc = this.calcularPresupuesto(presupuesto, locale);
|
||||||
presupuesto.setPreciosPorTiradaJson(new ObjectMapper().writeValueAsString(precios));
|
precios = (List<Double>) ((Map<String, Object>) preciosCalc.get("data")).getOrDefault("precios", List.of());
|
||||||
} catch (Exception ignore) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BigDecimal precioTotalTirada = BigDecimal.valueOf(precioUnit)
|
// iterate getTiradas with a foreach with not null
|
||||||
.multiply(BigDecimal.valueOf(cantidad))
|
for (Integer tirada : presupuesto.getTiradas()) {
|
||||||
.setScale(2, RoundingMode.HALF_UP);
|
if (tirada == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// servicios_total
|
// Genera los totalizadores (precio unitario, total tirada, etc.) sin guardar
|
||||||
BigDecimal serviciosTotal = BigDecimal.ZERO;
|
double precioUnit = 0.0;
|
||||||
if (servicios != null) {
|
int cantidad = tirada != null ? tirada : 0;
|
||||||
for (Map<String, Object> s : servicios) {
|
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<String, Object> 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 {
|
try {
|
||||||
double unidades = Double.parseDouble(String.valueOf(s.getOrDefault("units", 0)));
|
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios));
|
||||||
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) {
|
} 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 {
|
try {
|
||||||
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios));
|
double iva = 4.0; // 0..100
|
||||||
|
ivaTipo = BigDecimal.valueOf(iva);
|
||||||
} catch (Exception ignore) {
|
} 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<String, Object> snap = new HashMap<>();
|
||||||
|
snap.put("precio_unitario", BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP));
|
||||||
|
snap.put("precio_total_tirada", precioTotalTirada);
|
||||||
|
snap.put("servicios_total", serviciosTotal);
|
||||||
|
snap.put("base_imponible", baseImponible);
|
||||||
|
snap.put("iva_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 {
|
try {
|
||||||
double iva = 4.0; // 0..100
|
String json = new ObjectMapper()
|
||||||
ivaTipo = BigDecimal.valueOf(iva);
|
.writer()
|
||||||
|
.withDefaultPrettyPrinter() // opcional
|
||||||
|
.writeValueAsString(pricing_snapshot);
|
||||||
|
presupuesto.setPricingSnapshotJson(pricing_snapshot.isEmpty() ? null : json);
|
||||||
} catch (Exception ignore) {
|
} 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;
|
return presupuesto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public HashMap<String, Object> guardarPresupuesto(Presupuesto presupuesto) {
|
public HashMap<String, Object> guardarPresupuesto(
|
||||||
|
Presupuesto presupuesto,
|
||||||
|
List<Map<String, Object>> serviciosList,
|
||||||
|
Map<String, Object> datosMaquetacion,
|
||||||
|
Map<String, Object> datosMarcapaginas,
|
||||||
|
String mode,
|
||||||
|
Long cliente_id,
|
||||||
|
Long id,
|
||||||
|
HttpServletRequest request,
|
||||||
|
Locale locale) {
|
||||||
|
|
||||||
HashMap<String, Object> result = new HashMap<>();
|
HashMap<String, Object> result = new HashMap<>();
|
||||||
try {
|
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("success", true);
|
||||||
result.put("presupuesto_id", p.getId());
|
result.put("presupuesto_id", presupuesto.getId());
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -1007,7 +1129,7 @@ public class PresupuestoService {
|
|||||||
if (isUser) {
|
if (isUser) {
|
||||||
// Si es usuario, solo puede ver sus propios presupuestos
|
// Si es usuario, solo puede ver sus propios presupuestos
|
||||||
String username = authentication.getName();
|
String username = authentication.getName();
|
||||||
if (!presupuesto.getUser().getUserName().equals(username)) {
|
if (presupuesto.getUser() == null || !presupuesto.getUser().getUserName().equals(username)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,3 +85,9 @@ geoip.http.enabled=true
|
|||||||
# Hibernate Timezone
|
# Hibernate Timezone
|
||||||
#
|
#
|
||||||
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
|
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
|
||||||
225
src/main/resources/static/assets/css/presupuestopdf.css
Normal file
225
src/main/resources/static/assets/css/presupuestopdf.css
Normal file
@ -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); }
|
||||||
BIN
src/main/resources/static/assets/fonts/OpenSans-Bold.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-Bold.ttf
Normal file
Binary file not shown.
BIN
src/main/resources/static/assets/fonts/OpenSans-BoldItalic.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/main/resources/static/assets/fonts/OpenSans-ExtraBold.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/main/resources/static/assets/fonts/OpenSans-Italic.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-Italic.ttf
Normal file
Binary file not shown.
BIN
src/main/resources/static/assets/fonts/OpenSans-Light.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-Light.ttf
Normal file
Binary file not shown.
BIN
src/main/resources/static/assets/fonts/OpenSans-LightItalic.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-LightItalic.ttf
Normal file
Binary file not shown.
BIN
src/main/resources/static/assets/fonts/OpenSans-Regular.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
src/main/resources/static/assets/fonts/OpenSans-Semibold.ttf
Normal file
BIN
src/main/resources/static/assets/fonts/OpenSans-Semibold.ttf
Normal file
Binary file not shown.
Binary file not shown.
@ -1,9 +1,14 @@
|
|||||||
import PresupuestoWizard from './wizard.js';
|
import PresupuestoWizard from './wizard.js';
|
||||||
|
|
||||||
|
if($('#presupuesto_id').val() == null || $('#presupuesto_id').val() === '') {
|
||||||
|
sessionStorage.removeItem('formData');
|
||||||
|
}
|
||||||
|
|
||||||
const app = new PresupuestoWizard({
|
const app = new PresupuestoWizard({
|
||||||
mode: 'private',
|
mode: 'private',
|
||||||
readonly: false,
|
readonly: false,
|
||||||
|
presupuestoId: $('#presupuesto_id').val(),
|
||||||
canSave: true,
|
canSave: true,
|
||||||
useSessionCache: false,
|
useSessionCache: true,
|
||||||
});
|
});
|
||||||
app.init();
|
app.init();
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import PresupuestoWizard from './wizard.js';
|
import PresupuestoWizard from './wizard.js';
|
||||||
|
|
||||||
|
// remove formData from sessionStorage to avoid conflicts
|
||||||
|
sessionStorage.removeItem('formData');
|
||||||
|
|
||||||
const app = new PresupuestoWizard({
|
const app = new PresupuestoWizard({
|
||||||
mode: 'public',
|
mode: 'public',
|
||||||
readonly: false,
|
readonly: false,
|
||||||
|
|||||||
@ -4,6 +4,6 @@ const app = new PresupuestoWizard({
|
|||||||
mode: 'public',
|
mode: 'public',
|
||||||
readonly: true,
|
readonly: true,
|
||||||
canSave: false,
|
canSave: false,
|
||||||
useSessionCache: false,
|
useSessionCache: true,
|
||||||
});
|
});
|
||||||
app.init();
|
app.init();
|
||||||
@ -180,6 +180,8 @@ export default class PresupuestoWizard {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
$.ajaxSetup({
|
$.ajaxSetup({
|
||||||
beforeSend: function (xhr) {
|
beforeSend: function (xhr) {
|
||||||
const token = document.querySelector('meta[name="_csrf"]')?.content;
|
const token = document.querySelector('meta[name="_csrf"]')?.content;
|
||||||
@ -190,7 +192,7 @@ export default class PresupuestoWizard {
|
|||||||
|
|
||||||
const root = document.getElementById('presupuesto-app');
|
const root = document.getElementById('presupuesto-app');
|
||||||
const mode = root?.dataset.mode || 'public';
|
const mode = root?.dataset.mode || 'public';
|
||||||
const presupuestoId = root?.dataset.id || null;
|
const presupuestoId = this.opts.presupuestoId || null;
|
||||||
|
|
||||||
let stored = null;
|
let stored = null;
|
||||||
if (this.opts.useSessionCache) {
|
if (this.opts.useSessionCache) {
|
||||||
@ -249,26 +251,19 @@ export default class PresupuestoWizard {
|
|||||||
|
|
||||||
const alert = $('#form-errors');
|
const alert = $('#form-errors');
|
||||||
const servicios = [];
|
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 = {
|
const payload = {
|
||||||
id: this.opts.presupuestoId,
|
id: this.opts.presupuestoId,
|
||||||
mode: this.opts.mode,
|
mode: this.opts.mode,
|
||||||
presupuesto: this.#getPresupuestoData(),
|
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,
|
cliente_id: $('#cliente_id').val() || null,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
alert.addClass('d-none').find('ul').empty();
|
alert.addClass('d-none').find('ul').empty();
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/presupuesto/save',
|
url: '/presupuesto/api/save',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
data: JSON.stringify(payload)
|
data: JSON.stringify(payload)
|
||||||
@ -341,7 +336,7 @@ export default class PresupuestoWizard {
|
|||||||
...this.#getInteriorData(),
|
...this.#getInteriorData(),
|
||||||
...this.#getCubiertaData(),
|
...this.#getCubiertaData(),
|
||||||
selectedTirada: this.formData.selectedTirada
|
selectedTirada: this.formData.selectedTirada
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -501,6 +496,18 @@ export default class PresupuestoWizard {
|
|||||||
|
|
||||||
$('.alto-faja-max').text(`max: ${this.formData.datosGenerales.alto} mm`);
|
$('.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);
|
this.#loadInteriorData(data);
|
||||||
|
|
||||||
const interiorData = this.#getInteriorData();
|
const interiorData = this.#getInteriorData();
|
||||||
@ -1592,7 +1599,14 @@ export default class PresupuestoWizard {
|
|||||||
const $target = $(e.currentTarget);
|
const $target = $(e.currentTarget);
|
||||||
|
|
||||||
if ($target.prop('checked')) {
|
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 {
|
} else {
|
||||||
const index = this.formData.servicios.servicios.indexOf($target.val());
|
const index = this.formData.servicios.servicios.indexOf($target.val());
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
@ -1615,7 +1629,7 @@ export default class PresupuestoWizard {
|
|||||||
|
|
||||||
this.divExtras.addClass('animate-fadeInUpBounce');
|
this.divExtras.addClass('animate-fadeInUpBounce');
|
||||||
for (const extra of servicios) {
|
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;
|
extra.checked = true;
|
||||||
if (extra.id === "marcapaginas" || extra.id === "maquetacion") {
|
if (extra.id === "marcapaginas" || extra.id === "maquetacion") {
|
||||||
extra.price = extra.id === "marcapaginas" ?
|
extra.price = extra.id === "marcapaginas" ?
|
||||||
@ -1626,11 +1640,6 @@ export default class PresupuestoWizard {
|
|||||||
}
|
}
|
||||||
const item = new ServiceOptionCard(extra);
|
const item = new ServiceOptionCard(extra);
|
||||||
this.divExtras.append(item.render());
|
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();
|
this.#cacheFormData();
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
@ -24,12 +24,11 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js';
|
|||||||
processing: true,
|
processing: true,
|
||||||
serverSide: true,
|
serverSide: true,
|
||||||
orderCellsTop: true,
|
orderCellsTop: true,
|
||||||
stateSave: true,
|
|
||||||
pageLength: 50,
|
pageLength: 50,
|
||||||
lengthMenu: [10, 25, 50, 100, 500],
|
lengthMenu: [10, 25, 50, 100, 500],
|
||||||
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
||||||
responsive: true,
|
responsive: true,
|
||||||
dom: 'Bflrtip',
|
dom: 'lBrtip',
|
||||||
buttons: {
|
buttons: {
|
||||||
dom: {
|
dom: {
|
||||||
button: {
|
button: {
|
||||||
@ -51,20 +50,20 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js';
|
|||||||
},
|
},
|
||||||
order: [[0, 'asc']],
|
order: [[0, 'asc']],
|
||||||
columns: [
|
columns: [
|
||||||
{ data: 'id', name: 'id', title: 'ID', orderable: true },
|
{ data: 'id', name: 'id', orderable: true },
|
||||||
{ data: 'titulo', name: 'titulo', title: 'Título', orderable: true },
|
{ data: 'titulo', name: 'titulo', orderable: true },
|
||||||
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', title: 'Encuadernación', orderable: true },
|
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', orderable: true },
|
||||||
{ data: 'tipoCubierta', name: 'tipoCubierta', title: 'Cubierta', orderable: true },
|
{ data: 'tipoCubierta', name: 'tipoCubierta', orderable: true },
|
||||||
{ data: 'tipoImpresion', name: 'tipoImpresion', title: 'Tipo de impresión', orderable: true },
|
{ data: 'tipoImpresion', name: 'tipoImpresion', orderable: true },
|
||||||
{ data: 'tirada', name: 'selectedTirada', title: 'Tirada', orderable: true },
|
{ data: 'selectedTirada', name: 'selectedTirada', orderable: true },
|
||||||
{ data: 'paginas', name: 'paginas', title: 'Páginas', orderable: true },
|
{ data: 'paginas', name: 'paginas', orderable: true },
|
||||||
{ data: 'estado', name: 'estado', title: 'Estado', orderable: true },
|
{ data: 'estado', name: 'estado', orderable: true },
|
||||||
{ data: 'totalConIva', name: 'totalConIva', title: 'Total con IVA', orderable: true },
|
{ data: 'totalConIva', name: 'totalConIva', orderable: true },
|
||||||
{ data: 'pais', name: 'pais', title: 'País', orderable: true },
|
{ data: 'pais', name: 'pais', orderable: true },
|
||||||
{ data: 'region', name: 'region', title: 'Región', orderable: true },
|
{ data: 'region', name: 'region', orderable: true },
|
||||||
{ data: 'ciudad', name: 'ciudad', title: 'Ciudad', orderable: true },
|
{ data: 'ciudad', name: 'ciudad', orderable: true },
|
||||||
{ data: 'updatedAt', name: 'updatedAt', title: 'Actualizado el', orderable: true },
|
{ data: 'updatedAt', name: 'updatedAt', orderable: true },
|
||||||
{ data: 'actions', title: 'Acciones', orderable: false, searchable: false }
|
{ 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) {
|
$('#addPresupuestoButton').on('click', async function (e) {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
<i class="ri-file-paper-2-line"></i> <span th:text="#{app.sidebar.presupuestos}">Presupuestos</span>
|
<i class="ri-file-paper-2-line"></i> <span th:text="#{app.sidebar.presupuestos}">Presupuestos</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li th:if="${#authentication.principal.role == 'SUPERADMIN' or #authentication.principal.role == 'ADMIN'}" class="nav-item">
|
||||||
<a class="nav-link menu-link collapsed" href="#sidebarConfig" data-bs-toggle="collapse"
|
<a class="nav-link menu-link collapsed" href="#sidebarConfig" data-bs-toggle="collapse"
|
||||||
role="button" aria-expanded="false" aria-controls="sidebarConfig">
|
role="button" aria-expanded="false" aria-controls="sidebarConfig">
|
||||||
<i class="ri-settings-2-line"></i> <span
|
<i class="ri-settings-2-line"></i> <span
|
||||||
|
|||||||
@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title th:text="'Presupuesto ' + ${numero}">Presupuesto</title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/presupuesto.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="doc-header">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="/img/logo-light.png" alt="ImprimeLibros" class="logo" />
|
||||||
|
<div class="brand-meta">
|
||||||
|
<div class="company-name" th:text="${empresa?.nombre} ?: 'ImprimeLibros ERP'">ImprimeLibros ERP</div>
|
||||||
|
<div class="company-meta">
|
||||||
|
<span th:text="${empresa?.direccion} ?: 'C/ Dirección 123, 28000 Madrid'">C/ Dirección 123, 28000 Madrid</span><br/>
|
||||||
|
<span th:text="${empresa?.telefono} ?: '+34 600 000 000'">+34 600 000 000</span> ·
|
||||||
|
<span th:text="${empresa?.email} ?: 'info@imprimelibros.com'">info@imprimelibros.com</span><br/>
|
||||||
|
<span th:text="${empresa?.cif} ?: 'B-12345678'">B-12345678</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-title">
|
||||||
|
<div class="title-main">PRESUPUESTO</div>
|
||||||
|
<table class="doc-info">
|
||||||
|
<tr>
|
||||||
|
<td>Nº</td>
|
||||||
|
<td class="right" th:text="${numero}">2025-00123</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Fecha</td>
|
||||||
|
<td class="right" th:text="${#temporals.format(fecha, 'dd/MM/yyyy')}">12/10/2025</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${validezDias != null}">
|
||||||
|
<td>Validez</td>
|
||||||
|
<td class="right" th:text="${validezDias} + ' días'">30 días</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Datos cliente / proyecto -->
|
||||||
|
<section class="blocks">
|
||||||
|
<div class="block">
|
||||||
|
<div class="block-title">Cliente</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Nombre:</span>
|
||||||
|
<span class="value" th:text="${cliente?.nombre} ?: '-'">Editorial Ejemplo S.L.</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">CIF/NIF:</span>
|
||||||
|
<span class="value" th:text="${cliente?.cif} ?: '-'">B-00000000</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Dirección:</span>
|
||||||
|
<span class="value" th:text="${cliente?.direccion} ?: '-'">Av. de los Libros, 45</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" th:if="${cliente?.cp != null or cliente?.poblacion != null or cliente?.provincia != null}">
|
||||||
|
<span class="label">Localidad:</span>
|
||||||
|
<span class="value">
|
||||||
|
<span th:text="${cliente?.cp} ?: ''"></span>
|
||||||
|
<span th:text="${cliente?.poblacion} ?: ''"></span>
|
||||||
|
<span th:text="${cliente?.provincia} ?: ''"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" th:if="${cliente?.email != null}">
|
||||||
|
<span class="label">Email:</span>
|
||||||
|
<span class="value" th:text="${cliente?.email}">comercial@editorial.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<div class="block-title">Proyecto</div>
|
||||||
|
<div class="block-body">
|
||||||
|
<div class="row" th:if="${titulo != null}">
|
||||||
|
<span class="label">Título:</span>
|
||||||
|
<span class="value" th:text="${titulo}">Libro de Ejemplo</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" th:if="${autor != null}">
|
||||||
|
<span class="label">Autor:</span>
|
||||||
|
<span class="value" th:text="${autor}">Autor/a</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" th:if="${isbn != null}">
|
||||||
|
<span class="label">ISBN:</span>
|
||||||
|
<span class="value" th:text="${isbn}">978-1-2345-6789-0</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" th:if="${ancho != null and alto != null}">
|
||||||
|
<span class="label">Formato:</span>
|
||||||
|
<span class="value">
|
||||||
|
<span th:text="${ancho}">150</span> × <span th:text="${alto}">210</span> mm
|
||||||
|
<span th:if="${formatoPersonalizado == true}">(personalizado)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" th:if="${paginasNegro != null or paginasColor != null}">
|
||||||
|
<span class="label">Páginas:</span>
|
||||||
|
<span class="value">
|
||||||
|
<span th:if="${paginasNegro != null}" th:text="'B/N ' + ${paginasNegro}"></span>
|
||||||
|
<span th:if="${paginasColor != null}" th:text="' · Color ' + ${paginasColor}"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" th:if="${tiradas != null}">
|
||||||
|
<span class="label">Tiradas:</span>
|
||||||
|
<span class="value" th:text="${#strings.arrayJoin(tiradas, ', ')}">300, 500, 1000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Conceptos principales -->
|
||||||
|
<section class="table-section">
|
||||||
|
<div class="section-title">Detalle del presupuesto</div>
|
||||||
|
<table class="items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-desc">Descripción</th>
|
||||||
|
<th class="col-center">Uds</th>
|
||||||
|
<th class="col-right">Precio unit.</th>
|
||||||
|
<th class="col-right">Dto.</th>
|
||||||
|
<th class="col-right">Importe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Línea tipo: interior, cubierta, manipulado, etc. -->
|
||||||
|
<tr th:each="l : ${lineas}">
|
||||||
|
<td class="col-desc">
|
||||||
|
<div class="desc" th:text="${l.descripcion}">Impresión interior B/N 80 g</div>
|
||||||
|
<div class="meta" th:if="${l.meta != null}" th:text="${l.meta}">
|
||||||
|
300 páginas · offset 80 g · tinta negra
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="col-center" th:text="${l.uds}">1000</td>
|
||||||
|
<td class="col-right" th:text="${#numbers.formatDecimal(l.precio, 1, 'POINT', 2, 'COMMA')}">2,1500</td>
|
||||||
|
<td class="col-right" th:text="${l.dto != null ? #numbers.formatDecimal(l.dto, 1, 'POINT', 2, 'COMMA') + '%':'-'}">-</td>
|
||||||
|
<td class="col-right" th:text="${#numbers.formatDecimal(l.importe, 1, 'POINT', 2, 'COMMA')}">2.150,00</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Servicios extra (si vienen) -->
|
||||||
|
<tr class="group-header" th:if="${servicios != null and !#lists.isEmpty(servicios)}">
|
||||||
|
<td colspan="5">Servicios adicionales</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:each="s : ${servicios}">
|
||||||
|
<td class="col-desc">
|
||||||
|
<div class="desc" th:text="${s.descripcion}">Transporte</div>
|
||||||
|
</td>
|
||||||
|
<td class="col-center" th:text="${s.unidades}">1</td>
|
||||||
|
<td class="col-right" th:text="${#numbers.formatDecimal(s.precio, 1, 'POINT', 2, 'COMMA')}">90,00</td>
|
||||||
|
<td class="col-right">-</td>
|
||||||
|
<td class="col-right" th:text="${#numbers.formatDecimal(s.unidades * s.precio, 1, 'POINT', 2, 'COMMA')}">90,00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Totales -->
|
||||||
|
<section class="totals">
|
||||||
|
<table class="totals-table">
|
||||||
|
<tr>
|
||||||
|
<td>Base imponible</td>
|
||||||
|
<td class="right" th:text="${#numbers.formatDecimal(baseImponible, 1, 'POINT', 2, 'COMMA')}">2.180,00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td th:text="'IVA (' + ${ivaTipo} + '%)'">IVA (21%)</td>
|
||||||
|
<td class="right" th:text="${#numbers.formatDecimal(ivaImporte, 1, 'POINT', 2, 'COMMA')}">457,80</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td><strong>Total</strong></td>
|
||||||
|
<td class="right"><strong th:text="${#numbers.formatDecimal(totalConIva, 1, 'POINT', 2, 'COMMA')}">2.637,80</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${peso != null}">
|
||||||
|
<td>Peso estimado</td>
|
||||||
|
<td class="right" th:text="${#numbers.formatDecimal(peso, 1, 'POINT', 2, 'COMMA')} + ' kg'">120,00 kg</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Observaciones / condiciones -->
|
||||||
|
<section class="notes" th:if="${observaciones != null or condiciones != null}">
|
||||||
|
<div class="section-title">Observaciones</div>
|
||||||
|
<div class="note-text" th:utext="${observaciones}">Presupuesto válido 30 días.</div>
|
||||||
|
|
||||||
|
<div class="section-title" th:if="${condiciones != null}">Condiciones</div>
|
||||||
|
<div class="note-text" th:utext="${condiciones}">
|
||||||
|
Entrega estimada 7-10 días laborables tras confirmación de artes finales.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="doc-footer">
|
||||||
|
<div class="footer-left" th:text="${empresa?.web} ?: 'www.imprimelibros.com'">www.imprimelibros.com</div>
|
||||||
|
<div class="footer-right">Página <span class="page-number"></span> / <span class="page-count"></span></div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,10 +1,15 @@
|
|||||||
<!-- templates/fragments/common.html -->
|
<!-- templates/fragments/common.html -->
|
||||||
<div th:fragment="buttons(appMode)"
|
<div th:fragment="buttons(appMode)"
|
||||||
th:if="${appMode == 'add' or appMode == 'edit'}"
|
|
||||||
class="order-3 order-md-2 mx-md-auto d-flex">
|
class="order-3 order-md-2 mx-md-auto d-flex">
|
||||||
<button id="btn-guardar" type="button"
|
<button th:if="${appMode == 'add' or appMode == 'edit'}" id="btn-guardar" type="button"
|
||||||
class="btn btn-success d-flex align-items-center guardar-presupuesto">
|
class="btn btn-success d-flex align-items-center guardar-presupuesto">
|
||||||
<i class="ri-save-3-line me-2"></i>
|
<i class="ri-save-3-line me-2"></i>
|
||||||
<span th:text="#{presupuesto.guardar}">Guardar</span>
|
<span th:text="#{presupuesto.guardar}">Guardar</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button id="btn-imprimir" type="button"
|
||||||
|
class="btn btn-primary d-flex align-items-center imprimir-presupuesto">
|
||||||
|
<i class="ri-printer-line me-2"></i>
|
||||||
|
<span th:text="#{app.imprimir}">Imprimir</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -55,19 +55,15 @@
|
|||||||
|
|
||||||
<div th:replace="~{imprimelibros/presupuestos/presupuestador-items/_buttons :: buttons(${appMode})}"></div>
|
<div th:replace="~{imprimelibros/presupuestos/presupuestador-items/_buttons :: buttons(${appMode})}"></div>
|
||||||
|
|
||||||
<div th:unless="${#authorization.expression('isAuthenticated()')}">
|
<button th:unless="${#authorization.expression('isAuthenticated()')}" id="btn-add-cart" type="button"
|
||||||
<button id="btn-add-cart" type="button"
|
class="btn btn-secondary d-flex align-items-center order-2 order-md-3">
|
||||||
class="btn btn-secondary d-flex align-items-center order-2 order-md-3">
|
<i class="mdi mdi-login label-icon align-middle fs-16 me-2"></i>
|
||||||
<i class="mdi mdi-login label-icon align-middle fs-16 me-2"></i>
|
<span th:text="#{presupuesto.resumen.inicie-sesion}">Inicie sesión para continuar</span>
|
||||||
<span th:text="#{presupuesto.resumen.inicie-sesion}">Inicie sesión para continuar</span>
|
</button>
|
||||||
</button>
|
<button th:if="${#authorization.expression('isAuthenticated()')}" id="btn-add-cart" type="button"
|
||||||
</div>
|
class="btn btn-secondary d-flex align-items-center order-2 order-md-3">
|
||||||
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
<span th:text="#{presupuesto.resumen.agregar-cesta}">Agregar a la cesta</span>
|
||||||
<button id="btn-add-cart" type="button"
|
<i class="ri-shopping-cart-2-line fs-16 ms-2"></i>
|
||||||
class="btn btn-secondary d-flex align-items-center order-2 order-md-3">
|
</button>
|
||||||
<span th:text="#{presupuesto.resumen.agregar-cesta}">Agregar a la cesta</span>
|
|
||||||
<i class="ri-shopping-cart-2-line fs-16 ms-2"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
<form action="#">
|
<form action="#">
|
||||||
<input type="hidden" id="cliente_id" th:value="${cliente_id} ?: null" />
|
<input type="hidden" id="cliente_id" th:value="${cliente_id} ?: null" />
|
||||||
|
<input type="hidden" id="presupuesto_id" th:value="${presupuesto_id} ?: null" />
|
||||||
|
|
||||||
<div id="form-errors" class="alert alert-danger d-none" role="alert">
|
<div id="form-errors" class="alert alert-danger d-none" role="alert">
|
||||||
<i class="ri-error-warning-line label-icon"></i>
|
<i class="ri-error-warning-line label-icon"></i>
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
|
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
|
||||||
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
|
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
|
||||||
th:unless="${#authorization.expression('isAuthenticated()') and (#authorization.expression('hasRole('SUPERADMIN', 'ADMIN')'))}" />
|
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
|
||||||
|
|
||||||
<th:block layout:fragment="content">
|
<th:block layout:fragment="content">
|
||||||
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
<div th:fragment="tabla-cliente-user">
|
||||||
|
<table id="presupuestos-clientes-user-datatable" class="table table-striped table-nowrap responsive w-100">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.id}">ID</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.titulo}">Título</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.encuadernacion}">Encuadernación</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.cubierta}">Cubierta</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.tipo-impresion}">Tipo de impresión</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.tirada}">Tirada</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.paginas}">Páginas</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.estado}">Estado</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.total-iva}">Total con IVA</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.updated-at}">Actualizado el</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.acciones}">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="id" /></th>
|
||||||
|
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="titulo" /></th>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<select class="form-select form-select-sm presupuesto-select-filter" data-col="tipoEncuadernacion">
|
||||||
|
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
||||||
|
<option value="fresado" th:text="#{presupuesto.fresado}">Fresado</option>
|
||||||
|
<option value="cosido" th:text="#{presupuesto.cosido}">Cosido</option>
|
||||||
|
<option value="espiral" th:text="#{presupuesto.espiral}">Espiral</option>
|
||||||
|
<option value="wireo" th:text="#{presupuesto.wireo}">Wireo</option>
|
||||||
|
<option value="grapado" th:text="#{presupuesto.grapado}">Grapado</option>
|
||||||
|
</select>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<select class="form-select form-select-sm presupuesto-select-filter" data-col="tipoCubierta">
|
||||||
|
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
||||||
|
<option value="tapaBlanda" th:text="#{presupuesto.tapa-blanda}">Tapa blanda</option>
|
||||||
|
<option value="tapaDura" th:text="#{presupuesto.tapa-dura}">Tapa dura</option>
|
||||||
|
<option value="tapaDuraLomoRedondo" th:text="#{presupuesto.tapa-dura-lomo-redondo}">Tapa dura
|
||||||
|
lomo redondo</option>
|
||||||
|
</select>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<select class="form-select form-select-sm presupuesto-select-filter" data-col="tipoImpresion">
|
||||||
|
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
||||||
|
<option value="negro" th:text="#{presupuesto.blanco-negro}">B/N</option>
|
||||||
|
<option value="negrohq" th:text="#{presupuesto.blanco-negro-premium}">B/N HQ</option>
|
||||||
|
<option value="color" th:text="#{presupuesto.color}">Color</option>
|
||||||
|
<option value="colorhq" th:text="#{presupuesto.color-premium}">Color HQ</option>
|
||||||
|
</select>
|
||||||
|
</th>
|
||||||
|
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="selectedTirada" /></th>
|
||||||
|
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="paginas" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<select class="form-select form-select-sm presupuesto-select-filter" data-col="estado">
|
||||||
|
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
||||||
|
<option value="borrador" th:text="#{presupuesto.estado.borrador}">Borrador</option>
|
||||||
|
<option value="aceptado" th:text="#{presupuesto.estado.aceptado}">Aceptado</option>
|
||||||
|
<option value="modificado" th:text="#{presupuesto.estado.modificado}">Modificado</option>
|
||||||
|
</select>
|
||||||
|
</th>
|
||||||
|
<th></th> <!-- Total con IVA (sin filtro) -->
|
||||||
|
<th></th> <!-- Actualizado el (sin filtro) -->
|
||||||
|
<th></th> <!-- Acciones (sin filtro) -->
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@ -3,8 +3,8 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" th:text="#{presupuesto.tabla.id}">ID</th>
|
<th scope="col" th:text="#{presupuesto.tabla.id}">ID</th>
|
||||||
<th scope="col" th:text="#{presupuesto.tabla.titulo}">Título</th>
|
|
||||||
<th scope="col" th:text="#{presupuesto.tabla.cliente}">Cliente</th>
|
<th scope="col" th:text="#{presupuesto.tabla.cliente}">Cliente</th>
|
||||||
|
<th scope="col" th:text="#{presupuesto.tabla.titulo}">Título</th>
|
||||||
<th scope="col" th:text="#{presupuesto.tabla.encuadernacion}">Encuadernación</th>
|
<th scope="col" th:text="#{presupuesto.tabla.encuadernacion}">Encuadernación</th>
|
||||||
<th scope="col" th:text="#{presupuesto.tabla.cubierta}">Cubierta</th>
|
<th scope="col" th:text="#{presupuesto.tabla.cubierta}">Cubierta</th>
|
||||||
<th scope="col" th:text="#{presupuesto.tabla.tipo-impresion}">Tipo de impresión</th>
|
<th scope="col" th:text="#{presupuesto.tabla.tipo-impresion}">Tipo de impresión</th>
|
||||||
@ -17,9 +17,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="id" /></th>
|
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="id" /></th>
|
||||||
|
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="user.fullName" /></th>
|
||||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="titulo" /></th>
|
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="titulo" /></th>
|
||||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="user.fullName" />
|
|
||||||
</th>
|
|
||||||
<th>
|
<th>
|
||||||
<select class="form-select form-select-sm presupuesto-select-filter" data-col="tipoEncuadernacion">
|
<select class="form-select form-select-sm presupuesto-select-filter" data-col="tipoEncuadernacion">
|
||||||
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
||||||
|
|||||||
@ -15,11 +15,13 @@
|
|||||||
|
|
||||||
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
|
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
|
||||||
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
|
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
|
||||||
th:unless="${#authorization.expression('isAuthenticated()') and (#authorization.expression('hasRole('SUPERADMIN', 'ADMIN')'))}" />
|
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
|
||||||
|
|
||||||
<th:block layout:fragment="content">
|
<th:block layout:fragment="content">
|
||||||
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
||||||
|
|
||||||
|
<input type="hidden" id="cliente_id" th:value="${#authentication.principal.id}" />
|
||||||
|
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
@ -45,7 +47,8 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
<ul class="nav nav-pills arrow-navtabs nav-secondary-outline bg-light mb-3" role="tablist">
|
<ul class="nav nav-pills arrow-navtabs nav-secondary-outline bg-light mb-3" role="tablist"
|
||||||
|
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<a class="nav-link active" data-bs-toggle="tab" href="#arrow-presupuestos-cliente" role="tab"
|
<a class="nav-link active" data-bs-toggle="tab" href="#arrow-presupuestos-cliente" role="tab"
|
||||||
aria-selected="true">
|
aria-selected="true">
|
||||||
@ -64,7 +67,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<!-- Tab panes -->
|
<!-- Tab panes -->
|
||||||
<div class="tab-content text-muted">
|
<div class="tab-content text-muted" sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
|
||||||
<div class="tab-pane active show" id="arrow-presupuestos-cliente" role="tabpanel">
|
<div class="tab-pane active show" id="arrow-presupuestos-cliente" role="tabpanel">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -79,6 +82,11 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div th:if="${#authorization.expression('isAuthenticated() and hasRole(''USER'')')}"
|
||||||
|
th:insert="~{imprimelibros/presupuestos/presupuesto-list-items/tabla-cliente-user :: tabla-cliente-user}">
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
@ -100,7 +108,8 @@
|
|||||||
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
|
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
|
||||||
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
|
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
|
||||||
|
|
||||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestos/list.js}"></script>
|
<script sec:authorize="isAuthenticated() and hasAnyRole('ADMIN', 'SUPERADMIN')" type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestos/list.js}"></script>
|
||||||
|
<script sec:authorize="isAuthenticated() and hasAnyRole('USER')" type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestos/list-user.js}"></script>
|
||||||
</th:block>
|
</th:block>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user