mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-23 09:10:22 +00:00
Compare commits
10 Commits
62dcff8869
...
9b0a79e2cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0a79e2cd | |||
| 47344c94a9 | |||
| 543ff9a079 | |||
| d99ef65268 | |||
| 9d88392a2b | |||
| 9ebe2a3419 | |||
| c15fff73ee | |||
| 99d27cd3ed | |||
| 26c2ca543a | |||
| 6641c1f077 |
12
pom.xml
12
pom.xml
@ -139,6 +139,18 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- PDF generation -->
|
||||
<dependency>
|
||||
<groupId>com.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||
<version>1.0.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-slf4j</artifactId>
|
||||
<version>1.0.10</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@ -2,8 +2,10 @@ package com.imprimelibros.erp;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
|
||||
public class ErpApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@ -7,12 +7,14 @@ import org.springframework.data.domain.*;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import jakarta.persistence.criteria.*;
|
||||
import java.util.*;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class DataTable<T> {
|
||||
|
||||
/* ===== Tipos funcionales ===== */
|
||||
public interface FilterHook<T> extends BiConsumer<SpecBuilder<T>, DataTablesRequest> {
|
||||
}
|
||||
|
||||
@ -20,31 +22,55 @@ public class DataTable<T> {
|
||||
void add(Specification<T> extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtro custom por campo virtual: te doy (root, query, cb, value) y me
|
||||
* devuelves un Predicate
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FieldFilter<T> {
|
||||
Predicate apply(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb, String value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orden custom por campo virtual: te doy (root, query, cb) y me devuelves la
|
||||
* Expression<?> para orderBy
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FieldOrder<T> {
|
||||
Expression<?> apply(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
|
||||
}
|
||||
|
||||
/* ===== Estado ===== */
|
||||
private final JpaSpecificationExecutor<T> repo;
|
||||
private final Class<T> entityClass;
|
||||
private final DataTablesRequest dt;
|
||||
private final List<String> searchable;
|
||||
|
||||
private final List<Function<T, Map<String, Object>>> adders = new ArrayList<>();
|
||||
private final List<Function<Map<String, Object>, Map<String, Object>>> editors = new ArrayList<>();
|
||||
private final List<FilterHook<T>> filters = new ArrayList<>();
|
||||
private Specification<T> baseSpec = (root, q, cb) -> cb.conjunction();
|
||||
|
||||
private final ObjectMapper om = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
/** whitelist de campos ordenables “simples” (por nombre) */
|
||||
private List<String> orderable = null;
|
||||
|
||||
private boolean onlyAdded = false;
|
||||
public DataTable<T> onlyAddedColumns(){
|
||||
this.onlyAdded = true;
|
||||
return this;
|
||||
}
|
||||
/** mapas de comportamiento custom por campo */
|
||||
private final Map<String, FieldOrder<T>> orderCustom = new HashMap<>();
|
||||
private final Map<String, FieldFilter<T>> filterCustom = new HashMap<>();
|
||||
|
||||
private boolean onlyAdded = false;
|
||||
|
||||
/* ===== Ctor / factory ===== */
|
||||
private DataTable(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
|
||||
List<String> searchable) {
|
||||
this.repo = repo;
|
||||
this.entityClass = entityClass;
|
||||
this.dt = dt;
|
||||
this.searchable = searchable;
|
||||
this.searchable = searchable != null ? searchable : List.of();
|
||||
}
|
||||
|
||||
public static <T> DataTable<T> of(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
|
||||
@ -52,13 +78,19 @@ public class DataTable<T> {
|
||||
return new DataTable<>(repo, entityClass, dt, searchable);
|
||||
}
|
||||
|
||||
/** Equivalente a tu $q->where(...): establece condición base */
|
||||
/* ===== Fluent API ===== */
|
||||
public DataTable<T> onlyAddedColumns() {
|
||||
this.onlyAdded = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** WHERE base reusable */
|
||||
public DataTable<T> where(Specification<T> spec) {
|
||||
this.baseSpec = this.baseSpec.and(spec);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** add("campo", fn(entity)->valor|Map) */
|
||||
/** Campos renderizados */
|
||||
public DataTable<T> add(String field, Function<T, Object> fn) {
|
||||
adders.add(entity -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
@ -68,19 +100,19 @@ public class DataTable<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* add(fn(entity)->Map<String,Object>) para devolver objetos anidados como tu
|
||||
* "logo"
|
||||
*/
|
||||
public DataTable<T> addIf(boolean condition, String field, Function<T, Object> fn) {
|
||||
if (condition)
|
||||
return add(field, fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DataTable<T> add(Function<T, Map<String, Object>> fn) {
|
||||
adders.add(fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* edit("campo", fn(entity)->valor) sobreescribe un campo existente o lo crea si
|
||||
* no existe
|
||||
*/
|
||||
/** Edita/inyecta valor usando la entidad original (guardada como __entity) */
|
||||
@SuppressWarnings("unchecked")
|
||||
public DataTable<T> edit(String field, Function<T, Object> fn) {
|
||||
editors.add(row -> {
|
||||
row.put(field, fn.apply((T) row.get("__entity")));
|
||||
@ -89,73 +121,132 @@ public class DataTable<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Whitelist de campos simples ordenables (por nombre) */
|
||||
public DataTable<T> orderable(List<String> fields) {
|
||||
this.orderable = fields;
|
||||
return this;
|
||||
}
|
||||
|
||||
private List<String> getOrderable() {
|
||||
return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable;
|
||||
/** Orden custom por campo virtual (expresiones) */
|
||||
public DataTable<T> orderable(String field, FieldOrder<T> orderFn) {
|
||||
this.orderCustom.put(field, orderFn);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** filter((builder, req) -> builder.add(miExtraSpec(req))) */
|
||||
/** Filtro custom por campo virtual (LIKE, rangos, etc.) */
|
||||
public DataTable<T> filter(String field, FieldFilter<T> filterFn) {
|
||||
this.filterCustom.put(field, filterFn);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Hook para añadir Specifications extra programáticamente */
|
||||
public DataTable<T> filter(FilterHook<T> hook) {
|
||||
filters.add(hook);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
private List<String> getOrderable() {
|
||||
return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable;
|
||||
}
|
||||
|
||||
/* ===== Core ===== */
|
||||
public DataTablesResponse<Map<String, Object>> toJson(long totalCount) {
|
||||
// Construye spec con búsqueda global + base + filtros custom
|
||||
// 1) Spec base + búsqueda (global/columnas) + hooks programáticos
|
||||
Specification<T> spec = baseSpec.and(DataTablesSpecification.build(dt, searchable));
|
||||
final Specification<T>[] holder = new Specification[] { spec };
|
||||
filters.forEach(h -> h.accept(extra -> holder[0] = holder[0].and(extra), dt));
|
||||
spec = holder[0];
|
||||
|
||||
// Sort
|
||||
// Sort
|
||||
// Hooks externos
|
||||
filters.forEach(h -> h.accept(extra -> holder[0] = holder[0].and(extra), dt));
|
||||
|
||||
// 2) Filtros por columna “custom” (virtuales)
|
||||
for (var col : dt.columns) {
|
||||
if (col == null || !col.searchable)
|
||||
continue;
|
||||
if (col.name == null || col.name.isBlank())
|
||||
continue;
|
||||
if (!filterCustom.containsKey(col.name))
|
||||
continue;
|
||||
if (col.search == null || col.search.value == null || col.search.value.isBlank())
|
||||
continue;
|
||||
|
||||
var value = col.search.value;
|
||||
var filterFn = filterCustom.get(col.name);
|
||||
holder[0] = holder[0].and((root, query, cb) -> {
|
||||
Predicate p = filterFn.apply(root, query, cb, value);
|
||||
return p != null ? p : cb.conjunction();
|
||||
});
|
||||
}
|
||||
|
||||
// 3) Orden:
|
||||
// - Para campos “simples” (no custom): con Sort (Spring)
|
||||
// - Para campos “custom” (virtuales/expresiones): query.orderBy(...) dentro de
|
||||
// una spec
|
||||
Sort sort = Sort.unsorted();
|
||||
List<Sort.Order> simpleOrders = new ArrayList<>();
|
||||
boolean customApplied = false;
|
||||
|
||||
if (!dt.order.isEmpty() && !dt.columns.isEmpty()) {
|
||||
List<Sort.Order> orders = new ArrayList<>();
|
||||
for (var o : dt.order) {
|
||||
var col = dt.columns.get(o.column);
|
||||
String field = col != null ? col.name : null;
|
||||
if (col == null)
|
||||
continue;
|
||||
|
||||
String field = col.name;
|
||||
if (field == null || field.isBlank())
|
||||
continue;
|
||||
if (!col.orderable)
|
||||
continue;
|
||||
if (!getOrderable().contains(field))
|
||||
continue; // << usa tu whitelist
|
||||
continue;
|
||||
|
||||
orders.add(new Sort.Order(
|
||||
"desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC,
|
||||
field));
|
||||
}
|
||||
if (!orders.isEmpty()) {
|
||||
sort = Sort.by(orders);
|
||||
} else {
|
||||
for (var c : dt.columns) {
|
||||
if (c != null && c.orderable && c.name != null && !c.name.isBlank()
|
||||
&& getOrderable().contains(c.name)) {
|
||||
sort = Sort.by(c.name);
|
||||
break;
|
||||
}
|
||||
if (orderCustom.containsKey(field)) {
|
||||
final boolean asc = !"desc".equalsIgnoreCase(o.dir);
|
||||
final FieldOrder<T> orderFn = orderCustom.get(field);
|
||||
|
||||
// aplica el ORDER BY custom dentro de la Specification (con Criteria)
|
||||
holder[0] = holder[0].and((root, query, cb) -> {
|
||||
Expression<?> expr = orderFn.apply(root, query, cb);
|
||||
if (expr != null) {
|
||||
query.orderBy(asc ? cb.asc(expr) : cb.desc(expr));
|
||||
}
|
||||
return cb.conjunction();
|
||||
});
|
||||
customApplied = true;
|
||||
} else {
|
||||
// orden simple por nombre de propiedad real
|
||||
simpleOrders.add(new Sort.Order(
|
||||
"desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC,
|
||||
field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Page
|
||||
if (!simpleOrders.isEmpty()) {
|
||||
sort = Sort.by(simpleOrders);
|
||||
}
|
||||
|
||||
// 4) Paginación (Sort para simples; custom order ya va dentro de la spec)
|
||||
int page = dt.length > 0 ? dt.start / dt.length : 0;
|
||||
Pageable pageable = dt.length > 0 ? PageRequest.of(page, dt.length, sort) : Pageable.unpaged();
|
||||
|
||||
var p = repo.findAll(holder[0], pageable);
|
||||
long filtered = p.getTotalElements();
|
||||
|
||||
// Mapear entidad -> Map base (via Jackson) + add/edit
|
||||
// 5) Mapeo a Map + add/edit
|
||||
List<Map<String, Object>> data = new ArrayList<>();
|
||||
for (T e : p.getContent()) {
|
||||
Map<String, Object> row = onlyAdded ? new HashMap<>() : om.convertValue(e, Map.class);
|
||||
row.put("__entity", e); // para editores que necesiten la entidad
|
||||
Map<String, Object> row;
|
||||
if (onlyAdded) {
|
||||
row = new HashMap<>();
|
||||
} else {
|
||||
try {
|
||||
row = om.convertValue(e, Map.class);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
row = new HashMap<>();
|
||||
}
|
||||
}
|
||||
row.put("__entity", e);
|
||||
for (var ad : adders)
|
||||
row.putAll(ad.apply(e));
|
||||
for (var ed : editors)
|
||||
@ -163,6 +254,12 @@ public class DataTable<T> {
|
||||
row.remove("__entity");
|
||||
data.add(row);
|
||||
}
|
||||
|
||||
return new DataTablesResponse<>(dt.draw, totalCount, filtered, data);
|
||||
}
|
||||
}
|
||||
|
||||
private Predicate nullSafePredicate(CriteriaBuilder cb) {
|
||||
// Devuelve conjunción para no interferir con los demás predicados
|
||||
return cb.conjunction();
|
||||
}
|
||||
}
|
||||
@ -23,9 +23,15 @@ public class DataTablesSpecification {
|
||||
DataTablesRequest.Column col = dt.columns.get(i);
|
||||
if (col.searchable && col.search != null && col.search.value != null && !col.search.value.isEmpty()) {
|
||||
try {
|
||||
ands.add(like(cb, root.get(col.name), col.search.value));
|
||||
Path<?> path = root;
|
||||
String[] parts = col.name.split("\\.");
|
||||
for (String part : parts) {
|
||||
path = path.get(part);
|
||||
}
|
||||
ands.add(like(cb, path, col.search.value));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// columna no mapeada o relación: la ignoramos
|
||||
//System.out.println("[DT] columna no mapeada o relación: " + col.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,7 +90,7 @@ public class skApiClient {
|
||||
margen.getMargenMax(),
|
||||
margen.getMargenMin());
|
||||
double nuevoPrecio = precios.get(i) * (1 + margenValue / 100.0);
|
||||
precios.set(i, nuevoPrecio);
|
||||
precios.set(i, Math.round(nuevoPrecio * 10000.0) / 10000.0); // redondear a 2 decimales
|
||||
} else {
|
||||
System.out.println("No se encontró margen para tirada " + tirada);
|
||||
}
|
||||
|
||||
@ -35,7 +35,9 @@ public class HomeController {
|
||||
"presupuesto.plantilla-cubierta",
|
||||
"presupuesto.plantilla-cubierta-text",
|
||||
"presupuesto.impresion-cubierta",
|
||||
"presupuesto.impresion-cubierta-help");
|
||||
"presupuesto.impresion-cubierta-help",
|
||||
"presupuesto.iva-reducido",
|
||||
"presupuesto.iva-reducido-descripcion");
|
||||
|
||||
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||
model.addAttribute("languageBundle", translations);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
15
src/main/java/com/imprimelibros/erp/pdf/PdfModuleConfig.java
Normal file
15
src/main/java/com/imprimelibros/erp/pdf/PdfModuleConfig.java
Normal file
@ -0,0 +1,15 @@
|
||||
// com.imprimelibros.erp.pdf.PdfModuleConfig.java
|
||||
package com.imprimelibros.erp.pdf;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@ConfigurationProperties(prefix = "imprimelibros.pdf")
|
||||
public class PdfModuleConfig {
|
||||
private Map<String, String> templates = new HashMap<>();
|
||||
|
||||
public Map<String, String> getTemplates() { return templates; }
|
||||
public void setTemplates(Map<String, String> templates) { this.templates = templates; }
|
||||
}
|
||||
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.outputdevice.helper.BaseRendererBuilder;
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
@Service
|
||||
public class PdfRenderer {
|
||||
|
||||
@Value("classpath:/static/")
|
||||
private org.springframework.core.io.Resource staticRoot;
|
||||
|
||||
public byte[] renderHtmlToPdf(String html) {
|
||||
try (var baos = new ByteArrayOutputStream()) {
|
||||
var builder = new com.openhtmltopdf.pdfboxout.PdfRendererBuilder();
|
||||
builder.useFastMode();
|
||||
|
||||
// 👇 Base URL para que pueda resolver /assets/css/ y /img/
|
||||
builder.withHtmlContent(html, staticRoot.getURL().toString()); // .../target/classes/static/
|
||||
|
||||
|
||||
// (Opcional) Registrar fuentes TTF
|
||||
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Regular.ttf"),
|
||||
"Open Sans", 400, BaseRendererBuilder.FontStyle.NORMAL, true);
|
||||
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-SemiBold.ttf"),
|
||||
"Open Sans", 600, BaseRendererBuilder.FontStyle.NORMAL, true);
|
||||
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Bold.ttf"),
|
||||
"Open Sans", 700, BaseRendererBuilder.FontStyle.NORMAL, true);
|
||||
|
||||
builder.toStream(baos);
|
||||
builder.run();
|
||||
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Error generando PDF", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,21 @@
|
||||
package com.imprimelibros.erp.pdf;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class TemplateRegistry {
|
||||
private final PdfModuleConfig config;
|
||||
|
||||
public TemplateRegistry(PdfModuleConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public String resolve(DocumentType type, String templateId) {
|
||||
String key = type.name() + ":" + templateId;
|
||||
String keyAlt = type.name() + "_" + templateId; // compatibilidad con properties
|
||||
if (config.getTemplates() == null) return null;
|
||||
String value = config.getTemplates().get(key);
|
||||
if (value == null) value = config.getTemplates().get(keyAlt);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -531,7 +531,11 @@ public class PresupuestoController {
|
||||
"presupuesto.plantilla-cubierta",
|
||||
"presupuesto.plantilla-cubierta-text",
|
||||
"presupuesto.impresion-cubierta",
|
||||
"presupuesto.impresion-cubierta-help");
|
||||
"presupuesto.impresion-cubierta-help",
|
||||
"presupuesto.exito.guardado",
|
||||
"presupuesto.add.error.save.title",
|
||||
"presupuesto.iva-reducido",
|
||||
"presupuesto.iva-reducido-descripcion");
|
||||
|
||||
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||
model.addAttribute("languageBundle", translations);
|
||||
@ -558,12 +562,14 @@ public class PresupuestoController {
|
||||
return "redirect:/presupuesto";
|
||||
}
|
||||
|
||||
model.addAttribute("presupuesto_id", presupuestoOpt.get().getId());
|
||||
String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
|
||||
.getRequest().getRequestURI();
|
||||
String mode = path.contains("/view/") ? "view" : "edit";
|
||||
if (mode.equals("view")) {
|
||||
model.addAttribute("appMode", "view");
|
||||
} else {
|
||||
model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId());
|
||||
model.addAttribute("appMode", "edit");
|
||||
}
|
||||
model.addAttribute("id", presupuestoOpt.get().getId());
|
||||
@ -594,7 +600,7 @@ public class PresupuestoController {
|
||||
model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
|
||||
|
||||
model.addAttribute("appMode", "add");
|
||||
|
||||
|
||||
if (!mode.equals("public")) {
|
||||
model.addAttribute("cliente_id", clienteId);
|
||||
}
|
||||
@ -603,24 +609,6 @@ public class PresupuestoController {
|
||||
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")
|
||||
@ResponseBody
|
||||
public DataTablesResponse<Map<String, Object>> datatable(
|
||||
@ -638,7 +626,6 @@ public class PresupuestoController {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
|
||||
@ -697,7 +684,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(
|
||||
@RequestBody Map<String, Object> body,
|
||||
Locale locale, HttpServletRequest request) {
|
||||
@ -707,6 +712,11 @@ public class PresupuestoController {
|
||||
String mode = objectMapper.convertValue(body.get("mode"), String.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
|
||||
Long cliente_id = objectMapper.convertValue(body.get("cliente_id"), Long.class);
|
||||
Map<String, Object> datosMaquetacion = (Map<String, Object>) objectMapper
|
||||
.convertValue(body.get("datosMaquetacion"), Map.class);
|
||||
Map<String, Object> datosMarcapaginas = (Map<String, Object>) objectMapper
|
||||
.convertValue(body.get("datosMarcapaginas"), Map.class);
|
||||
|
||||
Set<ConstraintViolation<Presupuesto>> violations = validator.validate(presupuesto,
|
||||
PresupuestoValidationGroups.All.class);
|
||||
@ -715,34 +725,26 @@ public class PresupuestoController {
|
||||
Map<String, String> errores = new HashMap<>();
|
||||
for (ConstraintViolation<Presupuesto> v : violations) {
|
||||
String campo = v.getPropertyPath().toString();
|
||||
String mensaje = messageSource.getMessage(v.getMessage().replace("{", "").replace("}", ""), null, locale);
|
||||
String mensaje = messageSource.getMessage(v.getMessage().replace("{", "").replace("}", ""), null,
|
||||
locale);
|
||||
errores.put(campo, mensaje);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(errores);
|
||||
}
|
||||
|
||||
try {
|
||||
var resumen = presupuestoService.getTextosResumen(presupuesto, serviciosList, locale);
|
||||
|
||||
Long cliente_id = objectMapper.convertValue(body.get("cliente_id"), Long.class);
|
||||
if(id == null && cliente_id != null && !mode.equals("public")) {
|
||||
Map<String, Object> saveResult = presupuestoService.guardarPresupuesto(
|
||||
presupuesto,
|
||||
serviciosList,
|
||||
datosMaquetacion,
|
||||
datosMarcapaginas,
|
||||
mode,
|
||||
cliente_id,
|
||||
id,
|
||||
request,
|
||||
locale);
|
||||
|
||||
presupuesto.setUser(userRepo.findById(cliente_id).orElse(null));
|
||||
presupuesto.setOrigen(Presupuesto.Origen.privado);
|
||||
}
|
||||
if (mode.equals("public")) {
|
||||
presupuesto.setOrigen(Presupuesto.Origen.publico);
|
||||
String sessionId = request.getSession(true).getId();
|
||||
String ip = request.getRemoteAddr();
|
||||
|
||||
presupuesto = presupuestoService.getDatosLocalizacion(presupuesto, sessionId, ip);
|
||||
if (id != null) {
|
||||
presupuesto.setId(id); // para que actualice, no cree uno nuevo
|
||||
}
|
||||
}
|
||||
presupuesto = presupuestoService.generateTotalizadores(presupuesto, serviciosList, resumen, locale);
|
||||
|
||||
Map<String, Object> saveResult = presupuestoService.guardarPresupuesto(presupuesto);
|
||||
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
|
||||
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
|
||||
} catch (Exception ex) {
|
||||
|
||||
@ -1,21 +1,22 @@
|
||||
package com.imprimelibros.erp.presupuesto;
|
||||
|
||||
import com.imprimelibros.erp.datatables.*;
|
||||
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
|
||||
|
||||
import jakarta.persistence.criteria.Expression;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.data.domain.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.NumberFormat;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.imprimelibros.erp.datatables.*;
|
||||
import java.text.NumberFormat;
|
||||
|
||||
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
|
||||
|
||||
@Service
|
||||
public class PresupuestoDatatableService {
|
||||
@ -28,301 +29,59 @@ public class PresupuestoDatatableService {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
/* ---------- API pública ---------- */
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DataTablesResponse<Map<String, Object>> datatablePublicos(DataTablesRequest dt, Locale locale) {
|
||||
String term = extractSearch(dt);
|
||||
Pageable pageable = pageableFrom(dt);
|
||||
|
||||
EnumMatches matches = buildEnumMatches(term, locale);
|
||||
|
||||
Specification<Presupuesto> spec = baseSpec(term, matches, dt);
|
||||
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("origen"), "publico"));
|
||||
|
||||
Page<Presupuesto> page = repo.findAll(spec, pageable);
|
||||
|
||||
var rows = page.getContent().stream()
|
||||
.map(p -> mapPresupuestoPublico(p, locale))
|
||||
.toList();
|
||||
|
||||
return new DataTablesResponse<>(dt.draw, repo.count(), page.getTotalElements(), rows);
|
||||
return commonDataTable(dt, locale, "publico", true);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DataTablesResponse<Map<String, Object>> datatablePrivados(DataTablesRequest dt, Locale locale) {
|
||||
String term = extractSearch(dt);
|
||||
Pageable pageable = pageableFrom(dt);
|
||||
|
||||
EnumMatches matches = buildEnumMatches(term, locale);
|
||||
|
||||
Specification<Presupuesto> spec = baseSpec(term, matches, dt);
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("origen"), "privado"));
|
||||
|
||||
Page<Presupuesto> page = repo.findAll(spec, pageable);
|
||||
|
||||
var rows = page.getContent().stream()
|
||||
.map(p -> mapPresupuestoPrivado(p, locale)) // 👈 otro mapper con más/otros campos
|
||||
.toList();
|
||||
|
||||
return new DataTablesResponse<>(dt.draw, repo.count(), page.getTotalElements(), rows);
|
||||
return commonDataTable(dt, locale, "privado", false);
|
||||
}
|
||||
|
||||
/* ---------- Helpers reutilizables ---------- */
|
||||
private DataTablesResponse<Map<String, Object>> commonDataTable(DataTablesRequest dt, Locale locale, String origen,
|
||||
boolean publico) {
|
||||
Long count = repo.findAllByOrigen(Presupuesto.Origen.valueOf(origen)).stream().count();
|
||||
|
||||
private String extractSearch(DataTablesRequest dt) {
|
||||
return (dt.search != null && dt.search.value != null) ? dt.search.value.trim().toLowerCase() : "";
|
||||
List<String> orderable = List.of(
|
||||
"id", "titulo", "user.fullName", "tipoEncuadernacion", "tipoCubierta", "tipoImpresion",
|
||||
"selectedTirada", "estado", "totalConIva", "paginas", "pais", "region", "ciudad", "updatedAt");
|
||||
|
||||
return DataTable.of(repo, Presupuesto.class, dt,
|
||||
List.of("")) // búsqueda global solo por campos simples
|
||||
.orderable(orderable)
|
||||
.where((root, query, cb) -> cb.equal(root.get("origen"), Presupuesto.Origen.valueOf(origen)))
|
||||
.onlyAddedColumns()
|
||||
.add("id", Presupuesto::getId)
|
||||
.add("titulo", Presupuesto::getTitulo)
|
||||
.add("tipoEncuadernacion", p -> msg(p.getTipoEncuadernacion().getMessageKey(), locale))
|
||||
.add("tipoCubierta", p -> msg(p.getTipoCubierta().getMessageKey(), locale))
|
||||
.add("tipoImpresion", p -> msg(p.getTipoImpresion().getMessageKey(), locale))
|
||||
.add("selectedTirada", Presupuesto::getSelectedTirada)
|
||||
.add("paginas", p -> n(p.getPaginasColor()) + n(p.getPaginasNegro()))
|
||||
.filter("paginas", (root, q, cb, value) -> {
|
||||
Expression<Integer> sum = cb.sum(
|
||||
cb.coalesce(root.get("paginasColor"), cb.literal(0)),
|
||||
cb.coalesce(root.get("paginasNegro"), cb.literal(0)));
|
||||
Expression<String> asStr = cb.function("CONCAT", String.class, cb.literal(""), sum);
|
||||
return cb.like(asStr, "%" + value.trim() + "%");
|
||||
})
|
||||
.orderable("paginas", (root, q, cb) -> cb.sum(
|
||||
cb.coalesce(root.get("paginasColor"), cb.literal(0)),
|
||||
cb.coalesce(root.get("paginasNegro"), cb.literal(0))))
|
||||
|
||||
.add("estado", p -> msg(p.getEstado().getMessageKey(), locale))
|
||||
.add("totalConIva", p -> formatCurrency(p.getTotalConIva(), locale))
|
||||
.addIf(publico, "pais", Presupuesto::getPais)
|
||||
.addIf(publico, "region", Presupuesto::getRegion)
|
||||
.addIf(publico, "ciudad", Presupuesto::getCiudad)
|
||||
.add("updatedAt", p -> formatDate(p.getUpdatedAt(), locale))
|
||||
.addIf(!publico, "user", p -> p.getUser() != null ? p.getUser().getFullName() : "")
|
||||
.add("actions", this::generarBotones)
|
||||
.toJson(count);
|
||||
}
|
||||
|
||||
private Map<String, String> extractColumnSearches(DataTablesRequest dt) {
|
||||
Map<String, String> byColumn = new HashMap<>();
|
||||
if (dt.columns == null)
|
||||
return byColumn;
|
||||
|
||||
for (var col : dt.columns) {
|
||||
// Importante: en el front usa columns[i][name] con el nombre del campo JPA
|
||||
String field = col.name;
|
||||
String value = (col.search != null && col.search.value != null)
|
||||
? col.search.value.trim()
|
||||
: "";
|
||||
if (field != null && !field.isBlank() && value != null && !value.isBlank()) {
|
||||
byColumn.put(field, value.toLowerCase());
|
||||
}
|
||||
}
|
||||
return byColumn;
|
||||
}
|
||||
|
||||
private Pageable pageableFrom(DataTablesRequest dt) {
|
||||
int page = dt.length > 0 ? dt.start / dt.length : 0;
|
||||
List<Sort.Order> orders = new ArrayList<>();
|
||||
for (var o : dt.order) {
|
||||
String field = dt.columns.get(o.column).name; // usa columns[i][name] en el front
|
||||
if (field == null || field.isBlank())
|
||||
continue;
|
||||
orders.add(
|
||||
new Sort.Order("desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC, field));
|
||||
}
|
||||
Sort sort = orders.isEmpty() ? Sort.by(Sort.Order.desc("updatedAt")) : Sort.by(orders);
|
||||
return dt.length > 0 ? PageRequest.of(page, dt.length, sort) : Pageable.unpaged();
|
||||
}
|
||||
|
||||
private EnumMatches buildEnumMatches(String term, Locale locale) {
|
||||
Function<String, String> tr = key -> {
|
||||
try {
|
||||
return messageSource.getMessage(key, null, locale).toLowerCase();
|
||||
} catch (Exception e) {
|
||||
return key.toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
var enc = Arrays.stream(Presupuesto.TipoEncuadernacion.values())
|
||||
.filter(e -> tr.apply(e.getMessageKey()).contains(term))
|
||||
.collect(() -> EnumSet.noneOf(Presupuesto.TipoEncuadernacion.class), EnumSet::add, EnumSet::addAll);
|
||||
|
||||
var cub = Arrays.stream(Presupuesto.TipoCubierta.values())
|
||||
.filter(e -> tr.apply(e.getMessageKey()).contains(term))
|
||||
.collect(() -> EnumSet.noneOf(Presupuesto.TipoCubierta.class), EnumSet::add, EnumSet::addAll);
|
||||
|
||||
var imp = Arrays.stream(Presupuesto.TipoImpresion.values())
|
||||
.filter(e -> tr.apply(e.getMessageKey()).contains(term))
|
||||
.collect(() -> EnumSet.noneOf(Presupuesto.TipoImpresion.class), EnumSet::add, EnumSet::addAll);
|
||||
|
||||
var est = Arrays.stream(Presupuesto.Estado.values())
|
||||
.filter(e -> tr.apply(e.getMessageKey()).contains(term))
|
||||
.collect(() -> EnumSet.noneOf(Presupuesto.Estado.class), EnumSet::add, EnumSet::addAll);
|
||||
|
||||
return new EnumMatches(enc, cub, imp, est);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE + ORDER dinámico (paginas/estado) reutilizable para ambos datatables
|
||||
*/
|
||||
private Specification<Presupuesto> baseSpec(String term, EnumMatches m, DataTablesRequest dt) {
|
||||
return (root, query, cb) -> {
|
||||
List<Predicate> ors = new ArrayList<>();
|
||||
List<Predicate> ands = new ArrayList<>(); // filtros por columna (AND)
|
||||
|
||||
Expression<Integer> totalPag = cb.sum(
|
||||
cb.coalesce(root.get("paginasColor"), cb.literal(0)),
|
||||
cb.coalesce(root.get("paginasNegro"), cb.literal(0)));
|
||||
|
||||
if (!term.isBlank()) {
|
||||
String like = "%" + term + "%";
|
||||
ors.add(cb.like(cb.lower(root.get("titulo")), like));
|
||||
ors.add(cb.like(cb.lower(root.join("user").get("fullName")), like));
|
||||
ors.add(cb.like(cb.lower(root.get("ciudad")), like));
|
||||
ors.add(cb.like(cb.lower(root.get("region")), like));
|
||||
ors.add(cb.like(cb.lower(root.get("pais")), like));
|
||||
}
|
||||
if (!m.enc.isEmpty())
|
||||
ors.add(root.get("tipoEncuadernacion").in(m.enc));
|
||||
if (!m.cub.isEmpty())
|
||||
ors.add(root.get("tipoCubierta").in(m.cub));
|
||||
if (!m.imp.isEmpty())
|
||||
ors.add(root.get("tipoImpresion").in(m.imp));
|
||||
if (!m.est.isEmpty())
|
||||
ors.add(root.get("estado").in(m.est));
|
||||
|
||||
Map<String, String> byCol = extractColumnSearches(dt);
|
||||
for (var entry : byCol.entrySet()) {
|
||||
String field = entry.getKey();
|
||||
String value = entry.getValue();
|
||||
if (value.isBlank() || field.isBlank()
|
||||
|| field.contains("tipoEncuadernacion")
|
||||
|| field.contains("tipoCubierta")
|
||||
|| field.contains("tipoImpresion")
|
||||
|| field.contains("estado")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- CASO ESPECIAL: filtro por nombre del usuario ---
|
||||
if ("user".equals(field)) {
|
||||
var userJoin = root.join("user");
|
||||
var expr = cb.lower(userJoin.get("fullName"));
|
||||
ands.add(cb.like(expr, "%" + value.toLowerCase() + "%"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- CASO ESPECIAL: filtro por total de páginas ---
|
||||
if ("paginas".equals(field)) {
|
||||
try {
|
||||
int paginas = Integer.parseInt(value);
|
||||
ands.add(cb.equal(totalPag, paginas));
|
||||
} catch (NumberFormatException nfe) {
|
||||
var asString = cb.function("CONCAT", String.class, cb.literal(""), totalPag);
|
||||
var safe = cb.function("COALESCE", String.class, asString, cb.literal(""));
|
||||
var strExpr = cb.lower(safe);
|
||||
ands.add(cb.like(strExpr, "%" + value.toLowerCase() + "%"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- RESTO DE CAMPOS: acceso genérico ---
|
||||
var path = root.get(field);
|
||||
Class<?> type = path.getJavaType();
|
||||
|
||||
Expression<String> strExpr;
|
||||
if (String.class.isAssignableFrom(type)) {
|
||||
strExpr = cb.lower(path.as(String.class));
|
||||
} else {
|
||||
var asString = cb.function("CONCAT", String.class, cb.literal(""), path);
|
||||
var safe = cb.function("COALESCE", String.class, asString, cb.literal(""));
|
||||
strExpr = cb.lower(safe);
|
||||
}
|
||||
|
||||
ands.add(cb.like(strExpr, "%" + value.toLowerCase() + "%"));
|
||||
}
|
||||
|
||||
// ORDER BY especial si en columns[i][name] viene 'paginas' o 'estado'
|
||||
if (query != null && !query.getOrderList().isEmpty()) {
|
||||
var jpaOrders = new ArrayList<jakarta.persistence.criteria.Order>();
|
||||
for (var ob : query.getOrderList()) {
|
||||
String prop = ob.getExpression().toString();
|
||||
boolean asc = ob.isAscending();
|
||||
if ("paginas".equals(prop)) {
|
||||
var totalPagOrder = cb.sum(
|
||||
cb.coalesce(root.get("paginasColor"), cb.literal(0)),
|
||||
cb.coalesce(root.get("paginasNegro"), cb.literal(0)));
|
||||
jpaOrders.add(asc ? cb.asc(totalPagOrder) : cb.desc(totalPagOrder));
|
||||
} else if ("estado".equals(prop)) {
|
||||
var estadoStr = cb.function("str", String.class, root.get("estado"));
|
||||
jpaOrders.add(asc ? cb.asc(estadoStr) : cb.desc(estadoStr));
|
||||
} else {
|
||||
try {
|
||||
jpaOrders.add(asc ? cb.asc(root.get(prop)) : cb.desc(root.get(prop)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// El campo no existe (como 'paginas'), lo ignoramos
|
||||
// Opcional: puedes loggear si quieres
|
||||
// log.warn("Campo no encontrado para ORDER BY: {}", prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
query.orderBy(jpaOrders);
|
||||
}
|
||||
|
||||
// === Compose final WHERE ===
|
||||
Predicate where = ors.isEmpty()
|
||||
? cb.conjunction()
|
||||
: cb.or(ors.toArray(new Predicate[0]));
|
||||
|
||||
if (!ands.isEmpty()) {
|
||||
where = cb.and(where, cb.and(ands.toArray(new Predicate[0])));
|
||||
}
|
||||
|
||||
return where;
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Mappers de filas (puedes tener tantos como vistas) ---------- */
|
||||
|
||||
private Map<String, Object> mapPresupuestoPublico(Presupuesto p, Locale locale) {
|
||||
int paginas = n(p.getPaginasColor()) + n(p.getPaginasNegro());
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", p.getId());
|
||||
m.put("titulo", p.getTitulo());
|
||||
m.put("tipoEncuadernacion", msg(p.getTipoEncuadernacion().getMessageKey(), locale));
|
||||
m.put("tipoCubierta", msg(p.getTipoCubierta().getMessageKey(), locale));
|
||||
m.put("tipoImpresion", msg(p.getTipoImpresion().getMessageKey(), locale));
|
||||
m.put("tirada", p.getSelectedTirada());
|
||||
m.put("paginas", paginas);
|
||||
m.put("estado", msg(p.getEstado().getMessageKey(), locale));
|
||||
m.put("totalConIva", formatCurrency(p.getTotalConIva(), locale));
|
||||
m.put("pais", p.getPais());
|
||||
m.put("region", p.getRegion());
|
||||
m.put("ciudad", p.getCiudad());
|
||||
m.put("updatedAt", formatDate(p.getUpdatedAt(), locale));
|
||||
if (p.getEstado().equals(Presupuesto.Estado.borrador)) {
|
||||
m.put("actions",
|
||||
"<div class=\"hstack gap-3 flex-wrap\">" +
|
||||
"<a href=\"javascript:void(0);\" data-id=\"" + p.getId()
|
||||
+ "\" class=\"link-success btn-edit-anonimo fs-15\"><i class=\"ri-eye-line\"></i></a>" +
|
||||
"<a href=\"javascript:void(0);\" data-id=\"" + p.getId()
|
||||
+ "\" class=\"link-danger btn-delete-anonimo fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>"
|
||||
+
|
||||
"</div>");
|
||||
} else {
|
||||
m.put("actions",
|
||||
"<div class=\"hstack gap-3 flex-wrap\">" +
|
||||
"<a href=\"javascript:void(0);\" data-id=\"" + p.getId()
|
||||
+ "\" class=\"link-success btn-edit-anonimo fs-15\"><i class=\"ri-eye-line\"></i></a>" +
|
||||
"</div>");
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> mapPresupuestoPrivado(Presupuesto p, Locale locale) {
|
||||
int paginas = n(p.getPaginasColor()) + n(p.getPaginasNegro());
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", p.getId());
|
||||
m.put("titulo", p.getTitulo());
|
||||
m.put("user", p.getUser().getFullName());
|
||||
m.put("tipoEncuadernacion", msg(p.getTipoEncuadernacion().getMessageKey(), locale));
|
||||
m.put("tipoCubierta", msg(p.getTipoCubierta().getMessageKey(), locale));
|
||||
m.put("tipoImpresion", msg(p.getTipoImpresion().getMessageKey(), locale));
|
||||
m.put("tirada", p.getSelectedTirada());
|
||||
m.put("paginas", paginas);
|
||||
m.put("estado", msg(p.getEstado().getMessageKey(), locale));
|
||||
m.put("totalConIva", formatCurrency(p.getTotalConIva(), locale));
|
||||
m.put("updatedAt", formatDate(p.getUpdatedAt(), locale));
|
||||
if (p.getEstado().equals(Presupuesto.Estado.borrador)) {
|
||||
m.put("actions",
|
||||
"<div class=\"hstack gap-3 flex-wrap\">" +
|
||||
"<a href=\"javascript:void(0);\" data-id=\"" + p.getId()
|
||||
+ "\" class=\"link-success btn-edit-privado fs-15\"><i class=\"ri-pencil-line\"></i></a>" +
|
||||
"<a href=\"javascript:void(0);\" data-id=\"" + p.getId()
|
||||
+ "\" class=\"link-danger btn-delete-privado fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>"
|
||||
+
|
||||
"</div>");
|
||||
} else {
|
||||
m.put("actions",
|
||||
"<div class=\"hstack gap-3 flex-wrap\">" +
|
||||
"<a href=\"javascript:void(0);\" data-id=\"" + p.getId()
|
||||
+ "\" class=\"link-success btn-edit-privado fs-15\"><i class=\"ri-pencil-line\"></i></a>" +
|
||||
"</div>");
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/* ---------- utilidades ---------- */
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private String msg(String key, Locale locale) {
|
||||
try {
|
||||
@ -349,15 +108,21 @@ public class PresupuestoDatatableService {
|
||||
private String formatCurrency(BigDecimal value, Locale locale) {
|
||||
if (value == null)
|
||||
return "";
|
||||
NumberFormat nf = NumberFormat.getCurrencyInstance(locale);
|
||||
return nf.format(value);
|
||||
return NumberFormat.getCurrencyInstance(locale).format(value);
|
||||
}
|
||||
|
||||
/* record para agrupar matches */
|
||||
private record EnumMatches(
|
||||
EnumSet<Presupuesto.TipoEncuadernacion> enc,
|
||||
EnumSet<Presupuesto.TipoCubierta> cub,
|
||||
EnumSet<Presupuesto.TipoImpresion> imp,
|
||||
EnumSet<Presupuesto.Estado> est) {
|
||||
private String generarBotones(Presupuesto p) {
|
||||
boolean borrador = p.getEstado() == Presupuesto.Estado.borrador;
|
||||
String id = String.valueOf(p.getId());
|
||||
String editBtn = "<a href=\"javascript:void(0);\" data-id=\"" + id + "\" class=\"link-success btn-edit-" +
|
||||
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado") + " fs-15\"><i class=\"ri-" +
|
||||
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "eye" : "pencil") + "-line\"></i></a>";
|
||||
|
||||
String deleteBtn = borrador ? "<a href=\"javascript:void(0);\" data-id=\"" + id
|
||||
+ "\" class=\"link-danger btn-delete-"
|
||||
+ (p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado")
|
||||
+ " fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>" : "";
|
||||
|
||||
return "<div class=\"hstack gap-3 flex-wrap\">" + editBtn + deleteBtn + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,4 +25,6 @@ public interface PresupuestoRepository extends JpaRepository<Presupuesto, Long>,
|
||||
Optional<Presupuesto> findAnyById(@Param("id") Long id);
|
||||
|
||||
Optional<Presupuesto> findTopBySessionIdAndEstadoOrderByCreatedAtDesc(String sessionId, Presupuesto.Estado estado);
|
||||
|
||||
List<Presupuesto> findAllByOrigen(Presupuesto.Origen origen);
|
||||
}
|
||||
|
||||
@ -105,6 +105,20 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum Entrega{
|
||||
peninsula("presupuesto.entrega.peninsula"),
|
||||
canarias("presupuesto.entrega.canarias"),
|
||||
paises_ue("presupuesto.entrega.paises-ue");
|
||||
|
||||
private final String messageKey;
|
||||
Entrega(String messageKey) {
|
||||
this.messageKey = messageKey;
|
||||
}
|
||||
public String getMessageKey() {
|
||||
return messageKey;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Presupuesto clone() {
|
||||
try {
|
||||
@ -165,11 +179,18 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
|
||||
@Column(name = "base_imponible", precision = 12, scale = 2)
|
||||
private BigDecimal baseImponible;
|
||||
|
||||
@Column(name = "iva_tipo", precision = 5, scale = 2)
|
||||
private BigDecimal ivaTipo;
|
||||
@Column(name = "iva_reducido")
|
||||
private Boolean ivaReducido;
|
||||
|
||||
@Column(name = "iva_importe", precision = 12, scale = 2)
|
||||
private BigDecimal ivaImporte;
|
||||
@Column(name = "entrega_tipo")
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Entrega entregaTipo;
|
||||
|
||||
@Column(name = "iva_importe_4", precision = 12, scale = 2)
|
||||
private BigDecimal ivaImporte4;
|
||||
|
||||
@Column(name = "iva_importe_21", precision = 12, scale = 2)
|
||||
private BigDecimal ivaImporte21;
|
||||
|
||||
@Column(name = "total_con_iva", precision = 12, scale = 2)
|
||||
private BigDecimal totalConIva;
|
||||
@ -481,20 +502,36 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
|
||||
this.baseImponible = baseImponible;
|
||||
}
|
||||
|
||||
public BigDecimal getIvaTipo() {
|
||||
return ivaTipo;
|
||||
public Boolean getIvaReducido() {
|
||||
return ivaReducido;
|
||||
}
|
||||
|
||||
public void setIvaTipo(BigDecimal ivaTipo) {
|
||||
this.ivaTipo = ivaTipo;
|
||||
public void setIvaReducido(Boolean ivaReducido) {
|
||||
this.ivaReducido = ivaReducido;
|
||||
}
|
||||
|
||||
public BigDecimal getIvaImporte() {
|
||||
return ivaImporte;
|
||||
public Entrega getEntregaTipo() {
|
||||
return entregaTipo;
|
||||
}
|
||||
|
||||
public void setIvaImporte(BigDecimal ivaImporte) {
|
||||
this.ivaImporte = ivaImporte;
|
||||
public void setEntregaTipo(Entrega entregaTipo) {
|
||||
this.entregaTipo = entregaTipo;
|
||||
}
|
||||
|
||||
public BigDecimal getIvaImporte4() {
|
||||
return ivaImporte4;
|
||||
}
|
||||
|
||||
public void setIvaImporte4(BigDecimal ivaImporte4) {
|
||||
this.ivaImporte4 = ivaImporte4;
|
||||
}
|
||||
|
||||
public BigDecimal getIvaImporte21() {
|
||||
return ivaImporte21;
|
||||
}
|
||||
|
||||
public void setIvaImporte21(BigDecimal ivaImporte21) {
|
||||
this.ivaImporte21 = ivaImporte21;
|
||||
}
|
||||
|
||||
public BigDecimal getTotalConIva() {
|
||||
@ -881,7 +918,4 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
|
||||
public void setId(Long id){
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -3,11 +3,12 @@ package com.imprimelibros.erp.presupuesto.service;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
|
||||
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class PresupuestoFormDataMapper {
|
||||
@ -36,6 +37,8 @@ public class PresupuestoFormDataMapper {
|
||||
public String paginasColor = "";
|
||||
public String posicionPaginasColor = "";
|
||||
public String tipoEncuadernacion = "fresado"; // enum name
|
||||
public String entregaTipo = "peninsula"; // enum name
|
||||
public boolean ivaReducido = true;
|
||||
}
|
||||
|
||||
// ===== Interior =====
|
||||
@ -84,10 +87,17 @@ public class PresupuestoFormDataMapper {
|
||||
|
||||
// ===== Servicios / Extras =====
|
||||
public static class Servicios {
|
||||
public List<String> servicios = List.of();
|
||||
public List<DatosServicios> servicios = new ArrayList<DatosServicios>();
|
||||
public DatosMarcapaginas datosMarcapaginas = new DatosMarcapaginas();
|
||||
public DatosMaquetacion datosMaquetacion = new DatosMaquetacion();
|
||||
|
||||
public static class DatosServicios {
|
||||
public String id;
|
||||
public String label;
|
||||
public Integer units;
|
||||
public Double price;
|
||||
}
|
||||
|
||||
public static class DatosMarcapaginas {
|
||||
public Integer marcapaginas_tirada = 100;
|
||||
public String tamanio_marcapaginas = "_50x140_";
|
||||
@ -147,6 +157,9 @@ public class PresupuestoFormDataMapper {
|
||||
|
||||
vm.datosGenerales.tipoEncuadernacion = enumName(p.getTipoEncuadernacion(), "fresado");
|
||||
|
||||
vm.datosGenerales.entregaTipo = enumName(p.getEntregaTipo(), "peninsula");
|
||||
vm.datosGenerales.ivaReducido = Boolean.TRUE.equals(p.getIvaReducido());
|
||||
|
||||
// ===== Interior
|
||||
vm.interior.tipoImpresion = enumName(p.getTipoImpresion(), "negro");
|
||||
vm.interior.papelInteriorId = nz(p.getPapelInteriorId(), 3);
|
||||
@ -184,8 +197,11 @@ public class PresupuestoFormDataMapper {
|
||||
vm.selectedTirada = p.getSelectedTirada();
|
||||
|
||||
// ===== Servicios desde JSONs
|
||||
// servicios_json: acepta ["maquetacion","marcapaginas"] o [{id:...}, ...]
|
||||
vm.servicios.servicios = parseServiciosIds(p.getServiciosJson());
|
||||
vm.servicios.servicios = parse(p.getServiciosJson(),
|
||||
new TypeReference<List<PresupuestoFormDataDto.Servicios.DatosServicios>>() {
|
||||
});
|
||||
if (vm.servicios.servicios == null)
|
||||
vm.servicios.servicios = new ArrayList<>();
|
||||
|
||||
// datos_maquetacion_json
|
||||
PresupuestoFormDataDto.Servicios.DatosMaquetacion maq = parse(p.getDatosMaquetacionJson(),
|
||||
@ -230,31 +246,13 @@ public class PresupuestoFormDataMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseServiciosIds(String json) {
|
||||
if (json == null || json.isBlank())
|
||||
return new ArrayList<>();
|
||||
private <T> T parse(String json, TypeReference<T> typeRef) {
|
||||
try {
|
||||
// 1) intentar como lista de strings
|
||||
List<String> ids = om.readValue(json, new TypeReference<List<String>>() {
|
||||
});
|
||||
return ids != null ? ids : new ArrayList<>();
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
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<>();
|
||||
if (json == null || json.isBlank())
|
||||
return null;
|
||||
return om.readValue(json, typeRef);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -41,6 +41,10 @@ import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatricesReposito
|
||||
import com.imprimelibros.erp.presupuesto.marcapaginas.MarcapaginasRepository;
|
||||
import com.imprimelibros.erp.users.UserDao;
|
||||
import com.imprimelibros.erp.users.UserDetailsImpl;
|
||||
|
||||
import jakarta.persistence.criteria.CriteriaBuilder.In;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.imprimelibros.erp.externalApi.skApiClient;
|
||||
|
||||
@Service
|
||||
@ -448,6 +452,16 @@ public class PresupuestoService {
|
||||
: "0.00";
|
||||
}
|
||||
|
||||
private String obtenerPrecioRetractilado(Integer tirada) {
|
||||
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("tirada", tirada != null ? tirada : 0);
|
||||
Double precio_retractilado = apiClient.getRetractilado(requestBody);
|
||||
return precio_retractilado != null
|
||||
? String.valueOf(Math.round(precio_retractilado * 100.0) / 100.0)
|
||||
: "0.00";
|
||||
}
|
||||
|
||||
public Map<String, Object> obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) {
|
||||
List<Object> opciones = new ArrayList<>();
|
||||
|
||||
@ -747,13 +761,20 @@ public class PresupuestoService {
|
||||
if (hayDepositoLegal) {
|
||||
pressupuestoTemp.setSelectedTirada(
|
||||
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() + 4 : 4);
|
||||
for (Integer i = 0; i < pressupuestoTemp.getTiradas().length; i++) {
|
||||
Integer tirada = pressupuestoTemp.getTiradas()[i];
|
||||
if (tirada != null && tirada >= 4) {
|
||||
tirada = tirada + 4;
|
||||
}
|
||||
pressupuestoTemp.getTiradas()[i] = tirada;
|
||||
}
|
||||
}
|
||||
|
||||
HashMap<String, Object> precios = this.calcularPresupuesto(pressupuestoTemp, locale);
|
||||
if (precios.containsKey("error")) {
|
||||
resumen.put("error", precios.get("error"));
|
||||
return resumen;
|
||||
}
|
||||
resumen.put("precios", precios);
|
||||
|
||||
HashMap<String, Object> linea = new HashMap<>();
|
||||
Double precio_unitario = 0.0;
|
||||
@ -824,7 +845,7 @@ public class PresupuestoService {
|
||||
if (mode.equals("public")) {
|
||||
|
||||
presupuesto = getDatosLocalizacion(presupuesto, sessionId, ip);
|
||||
|
||||
|
||||
} else
|
||||
presupuesto.setOrigen(Presupuesto.Origen.privado);
|
||||
|
||||
@ -847,7 +868,7 @@ public class PresupuestoService {
|
||||
|
||||
if (save != null && save) {
|
||||
// Si NO es para guardar (solo calcular resumen), devolver sin persistir
|
||||
this.guardarPresupuesto(presupuesto);
|
||||
presupuestoRepository.saveAndFlush(presupuesto);
|
||||
}
|
||||
|
||||
// Opcional: devolver el id guardado al frontend para que lo envíe en llamadas
|
||||
@ -857,32 +878,32 @@ public class PresupuestoService {
|
||||
resumen.put("precio_total_tirada", presupuesto.getPrecioTotalTirada());
|
||||
resumen.put("servicios_total", presupuesto.getServiciosTotal());
|
||||
resumen.put("base_imponible", presupuesto.getBaseImponible());
|
||||
resumen.put("iva_tipo", presupuesto.getIvaTipo());
|
||||
resumen.put("iva_importe", presupuesto.getIvaImporte());
|
||||
resumen.put("iva_importe_4", presupuesto.getIvaImporte4());
|
||||
resumen.put("iva_importe_21", presupuesto.getIvaImporte21());
|
||||
resumen.put("total_con_iva", presupuesto.getTotalConIva());
|
||||
|
||||
return resumen;
|
||||
}
|
||||
|
||||
public Presupuesto getDatosLocalizacion(Presupuesto presupuesto, String sessionId, String ip) {
|
||||
|
||||
presupuesto.setOrigen(Presupuesto.Origen.publico);
|
||||
presupuesto.setSessionId(sessionId);
|
||||
// IP: guarda hash y trunc (si tienes campos). Si no, guarda tal cual en
|
||||
// ip_trunc/ip_hash según tu modelo.
|
||||
String ipTrunc = anonymizeIp(ip);
|
||||
presupuesto.setIpTrunc(ipTrunc);
|
||||
presupuesto.setIpHash(Integer.toHexString(ip.hashCode()));
|
||||
|
||||
// ubicación (si tienes un servicio GeoIP disponible; si no, omite estas tres
|
||||
// líneas)
|
||||
try {
|
||||
GeoIpService.GeoData geo = geoIpService.lookup(ip).orElse(null);
|
||||
presupuesto.setPais(geo.getPais());
|
||||
presupuesto.setRegion(geo.getRegion());
|
||||
presupuesto.setCiudad(geo.getCiudad());
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
presupuesto.setOrigen(Presupuesto.Origen.publico);
|
||||
presupuesto.setSessionId(sessionId);
|
||||
// IP: guarda hash y trunc (si tienes campos). Si no, guarda tal cual en
|
||||
// ip_trunc/ip_hash según tu modelo.
|
||||
String ipTrunc = anonymizeIp(ip);
|
||||
presupuesto.setIpTrunc(ipTrunc);
|
||||
presupuesto.setIpHash(Integer.toHexString(ip.hashCode()));
|
||||
|
||||
// ubicación (si tienes un servicio GeoIP disponible; si no, omite estas tres
|
||||
// líneas)
|
||||
try {
|
||||
GeoIpService.GeoData geo = geoIpService.lookup(ip).orElse(null);
|
||||
presupuesto.setPais(geo.getPais());
|
||||
presupuesto.setRegion(geo.getRegion());
|
||||
presupuesto.setCiudad(geo.getCiudad());
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
return presupuesto;
|
||||
}
|
||||
|
||||
@ -892,84 +913,227 @@ public class PresupuestoService {
|
||||
Map<String, Object> resumen,
|
||||
Locale locale) {
|
||||
|
||||
// Genera los totalizadores (precio unitario, total tirada, etc.) sin guardar
|
||||
double precioUnit = 0.0;
|
||||
int cantidad = presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Double> precios = (List<Double>) ((Map<String, Object>) resumen.getOrDefault("precios", Map.of()))
|
||||
.getOrDefault("precios", List.of());
|
||||
if (precios.isEmpty()) {
|
||||
// si no venía en "resumen", recalcúlalo directamente
|
||||
var preciosCalc = this.calcularPresupuesto(presupuesto, locale);
|
||||
precios = (List<Double>) ((Map<String, Object>) preciosCalc.get("data")).get("precios");
|
||||
}
|
||||
precioUnit = precios.get(0);
|
||||
// guarda el snapshot completo de precios para auditoría
|
||||
presupuesto.setPreciosPorTiradaJson(new ObjectMapper().writeValueAsString(precios));
|
||||
} catch (Exception ignore) {
|
||||
Map<Integer, Map<String, Object>> pricing_snapshot = new HashMap<>();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> preciosNode = (Map<String, Object>) resumen.getOrDefault("precios", Map.of());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) preciosNode.getOrDefault("data", Map.of());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Integer> tiradas = (List<Integer>) data.getOrDefault("tiradas", List.of());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Double> precios = (List<Double>) data.getOrDefault("precios", List.of());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Double> pesos = (List<Double>) data.getOrDefault("peso", List.of());
|
||||
|
||||
boolean hayDepositoLegal = servicios != null && servicios.stream()
|
||||
.map(m -> java.util.Objects.toString(m.get("id"), ""))
|
||||
.map(String::trim)
|
||||
.anyMatch("deposito-legal"::equals);
|
||||
|
||||
if (precios.isEmpty()) {
|
||||
var preciosCalc = this.calcularPresupuesto(presupuesto, locale);
|
||||
precios = (List<Double>) ((Map<String, Object>) preciosCalc.get("data")).getOrDefault("precios", List.of());
|
||||
}
|
||||
|
||||
BigDecimal precioTotalTirada = BigDecimal.valueOf(precioUnit)
|
||||
.multiply(BigDecimal.valueOf(cantidad))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
// iterate getTiradas with a foreach with not null
|
||||
for (Integer tirada : presupuesto.getTiradas()) {
|
||||
if (tirada == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// servicios_total
|
||||
BigDecimal serviciosTotal = BigDecimal.ZERO;
|
||||
if (servicios != null) {
|
||||
for (Map<String, Object> s : servicios) {
|
||||
// Genera los totalizadores (precio unitario, total tirada, etc.) sin guardar
|
||||
double precioUnit = 0.0;
|
||||
int cantidad = tirada != null ? tirada : 0;
|
||||
int index = tiradas.indexOf(tirada);
|
||||
try {
|
||||
|
||||
if (index >= 0 && index < precios.size()) {
|
||||
precioUnit = precios.get(index);
|
||||
} else if (!precios.isEmpty()) {
|
||||
precioUnit = precios.get(0); // fallback al primero
|
||||
}
|
||||
// guarda el snapshot completo de precios para auditoría
|
||||
presupuesto.setPreciosPorTiradaJson(new ObjectMapper().writeValueAsString(precios));
|
||||
} catch (Exception ignore) {
|
||||
precioUnit = 0.0;
|
||||
}
|
||||
|
||||
BigDecimal precioTotalTirada = BigDecimal.valueOf(precioUnit)
|
||||
.multiply(BigDecimal.valueOf(cantidad))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
if( hayDepositoLegal ){
|
||||
precioTotalTirada = precioTotalTirada.add(BigDecimal.valueOf(precioUnit).multiply(BigDecimal.valueOf(4))).setScale(6, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
// servicios_total
|
||||
BigDecimal serviciosIva4 = BigDecimal.ZERO;
|
||||
BigDecimal serviciosTotal = BigDecimal.ZERO;
|
||||
if (servicios != null) {
|
||||
for (Map<String, Object> s : servicios) {
|
||||
try {
|
||||
// retractilado: recalcular precio
|
||||
if (s.get("id").equals("retractilado")) {
|
||||
double precio_retractilado = obtenerPrecioRetractilado(cantidad) != null
|
||||
? Double.parseDouble(obtenerPrecioRetractilado(cantidad))
|
||||
: 0.0;
|
||||
s.put("price", precio_retractilado);
|
||||
}
|
||||
// si tiene protitipo, guardamos el valor para el IVA al 4%
|
||||
else if (s.get("id").equals("ejemplar-prueba")) {
|
||||
serviciosIva4 = BigDecimal.valueOf(
|
||||
s.get("price") != null ? Double.parseDouble(String.valueOf(s.get("price"))) : 0.0);
|
||||
} else if (s.get("id").equals("marcapaginas")) {
|
||||
PresupuestoMarcapaginas pm = presupuesto.getDatosMarcapaginasJson() != null
|
||||
? new ObjectMapper().readValue(presupuesto.getDatosMarcapaginasJson(),
|
||||
PresupuestoMarcapaginas.class)
|
||||
: null;
|
||||
Map<String, Object> precio_marcapaginas = this.getPrecioMarcapaginas(pm, locale);
|
||||
s.put("price", precio_marcapaginas.getOrDefault("precio_total", 0.0));
|
||||
} else if (s.get("id").equals("maquetacion")) {
|
||||
PresupuestoMaquetacion pm = presupuesto.getDatosMaquetacionJson() != null
|
||||
? new ObjectMapper().readValue(presupuesto.getDatosMaquetacionJson(),
|
||||
PresupuestoMaquetacion.class)
|
||||
: null;
|
||||
Map<String, Object> precio_maquetacion = this.getPrecioMaquetacion(pm, locale);
|
||||
s.put("price", precio_maquetacion.getOrDefault("precio", 0.0));
|
||||
}
|
||||
double unidades = Double.parseDouble(String.valueOf(s.getOrDefault("units", 0)));
|
||||
double precio = Double.parseDouble(String.valueOf(
|
||||
s.get("id").equals("marcapaginas")
|
||||
? (Double.parseDouble(String.valueOf(s.get("price"))) / unidades) // unidad
|
||||
: s.getOrDefault("price", 0)));
|
||||
serviciosTotal = serviciosTotal.add(
|
||||
BigDecimal.valueOf(precio).multiply(BigDecimal.valueOf(unidades)));
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
double unidades = Double.parseDouble(String.valueOf(s.getOrDefault("units", 0)));
|
||||
double precio = Double.parseDouble(String.valueOf(
|
||||
s.get("id").equals("marcapaginas")
|
||||
? (Double.parseDouble(String.valueOf(s.get("price"))) / unidades) // unidad
|
||||
: s.getOrDefault("price", 0)));
|
||||
serviciosTotal = serviciosTotal.add(
|
||||
BigDecimal.valueOf(precio).multiply(BigDecimal.valueOf(unidades)));
|
||||
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios));
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios));
|
||||
} catch (Exception ignore) {
|
||||
|
||||
BigDecimal baseImponible = precioTotalTirada;
|
||||
BigDecimal ivaImporte4 = BigDecimal.ZERO;
|
||||
BigDecimal ivaImporte21 = BigDecimal.ZERO;
|
||||
|
||||
// Si la entrega es en peninsula, se mira el valor del iva
|
||||
// Canarias y paises UE no llevan IVA
|
||||
if (presupuesto.getEntregaTipo() == Presupuesto.Entrega.peninsula){
|
||||
// Si el iva es reducido, el precio de la tirada y el del prototipo llevan IVA
|
||||
// 4%
|
||||
if (presupuesto.getIvaReducido()) {
|
||||
ivaImporte4 = baseImponible.add(serviciosIva4).multiply(BigDecimal.valueOf(4)).divide(
|
||||
BigDecimal.valueOf(100), 2,
|
||||
RoundingMode.HALF_UP);
|
||||
ivaImporte21 = serviciosTotal.subtract(serviciosIva4).multiply(BigDecimal.valueOf(21)).divide(
|
||||
BigDecimal.valueOf(100), 2,
|
||||
RoundingMode.HALF_UP);
|
||||
} else {
|
||||
ivaImporte21 = baseImponible.add(serviciosTotal).multiply(BigDecimal.valueOf(21)).divide(
|
||||
BigDecimal.valueOf(100), 2,
|
||||
RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
baseImponible = baseImponible.add(serviciosTotal);
|
||||
BigDecimal totalConIva = baseImponible.add(ivaImporte21).add(ivaImporte4);
|
||||
|
||||
// precios y totales
|
||||
if (tirada == (presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0)) {
|
||||
presupuesto.setPrecioUnitario(BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP));
|
||||
presupuesto.setPrecioTotalTirada(precioTotalTirada);
|
||||
presupuesto.setServiciosTotal(serviciosTotal);
|
||||
presupuesto.setBaseImponible(baseImponible);
|
||||
presupuesto.setIvaImporte4(ivaImporte4);
|
||||
presupuesto.setIvaImporte21(ivaImporte21);
|
||||
presupuesto.setTotalConIva(totalConIva);
|
||||
}
|
||||
Map<String, Object> snap = new HashMap<>();
|
||||
snap.put("precio_unitario", BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP));
|
||||
snap.put("precio_total_tirada", precioTotalTirada);
|
||||
snap.put("servicios_total", serviciosTotal);
|
||||
snap.put("base_imponible", baseImponible);
|
||||
snap.put("iva_importe_4", ivaImporte4);
|
||||
snap.put("iva_importe_21", ivaImporte21);
|
||||
snap.put("total_con_iva", totalConIva);
|
||||
snap.put("peso", (index >= 0 && index < pesos.size()) ? pesos.get(index) : 0.0);
|
||||
|
||||
pricing_snapshot.put(tirada, snap);
|
||||
|
||||
}
|
||||
|
||||
// base imponible, IVA y total (si tienes IVA configurable, úsalo; si no, 0)
|
||||
BigDecimal baseImponible = precioTotalTirada.add(serviciosTotal);
|
||||
BigDecimal ivaTipo = BigDecimal.ZERO;
|
||||
try {
|
||||
double iva = 4.0; // 0..100
|
||||
ivaTipo = BigDecimal.valueOf(iva);
|
||||
String json = new ObjectMapper()
|
||||
.writer()
|
||||
.withDefaultPrettyPrinter() // opcional
|
||||
.writeValueAsString(pricing_snapshot);
|
||||
presupuesto.setPricingSnapshotJson(pricing_snapshot.isEmpty() ? null : json);
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
BigDecimal ivaImporte = baseImponible.multiply(ivaTipo).divide(BigDecimal.valueOf(100), 2,
|
||||
RoundingMode.HALF_UP);
|
||||
BigDecimal totalConIva = baseImponible.add(ivaImporte);
|
||||
|
||||
// precios y totales
|
||||
presupuesto.setPrecioUnitario(BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP));
|
||||
presupuesto.setPrecioTotalTirada(precioTotalTirada);
|
||||
presupuesto.setServiciosTotal(serviciosTotal);
|
||||
presupuesto.setBaseImponible(baseImponible);
|
||||
presupuesto.setIvaTipo(ivaTipo);
|
||||
presupuesto.setIvaImporte(ivaImporte);
|
||||
presupuesto.setTotalConIva(totalConIva);
|
||||
|
||||
return presupuesto;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HashMap<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<>();
|
||||
try {
|
||||
|
||||
Presupuesto p = presupuestoRepository.saveAndFlush(presupuesto);
|
||||
presupuesto.setDatosMaquetacionJson(
|
||||
datosMaquetacion != null ? new ObjectMapper().writeValueAsString(datosMaquetacion) : null);
|
||||
presupuesto.setDatosMarcapaginasJson(
|
||||
datosMarcapaginas != null ? new ObjectMapper().writeValueAsString(datosMarcapaginas) : null);
|
||||
var resumen = this.getTextosResumen(presupuesto, serviciosList, locale);
|
||||
|
||||
Object serviciosObj = resumen.get("servicios");
|
||||
|
||||
if (serviciosObj instanceof List<?> servicios && !servicios.isEmpty()) {
|
||||
// serializa a JSON válido
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
String json = objectMapper.writeValueAsString(servicios);
|
||||
presupuesto.setServiciosJson(json);
|
||||
} else {
|
||||
// decide tu política: null o "[]"
|
||||
presupuesto.setServiciosJson(null); // o presupuesto.setServiciosJson("[]");
|
||||
}
|
||||
|
||||
if (cliente_id != null && !mode.equals("public")) {
|
||||
|
||||
presupuesto.setUser(userRepo.findById(cliente_id).orElse(null));
|
||||
presupuesto.setOrigen(Presupuesto.Origen.privado);
|
||||
if (id != null) {
|
||||
presupuesto.setId(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode.equals("public")) {
|
||||
presupuesto.setOrigen(Presupuesto.Origen.publico);
|
||||
String sessionId = request.getSession(true).getId();
|
||||
String ip = request.getRemoteAddr();
|
||||
|
||||
presupuesto = this.getDatosLocalizacion(presupuesto, sessionId, ip);
|
||||
if (id != null) {
|
||||
presupuesto.setId(id); // para que actualice, no cree uno nuevo
|
||||
}
|
||||
}
|
||||
presupuesto = this.generateTotalizadores(presupuesto, serviciosList, resumen, locale);
|
||||
|
||||
presupuestoRepository.saveAndFlush(presupuesto);
|
||||
|
||||
result.put("success", true);
|
||||
result.put("presupuesto_id", p.getId());
|
||||
|
||||
result.put("presupuesto_id", presupuesto.getId());
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
@ -1007,7 +1171,7 @@ public class PresupuestoService {
|
||||
if (isUser) {
|
||||
// Si es usuario, solo puede ver sus propios presupuestos
|
||||
String username = authentication.getName();
|
||||
if (!presupuesto.getUser().getUserName().equals(username)) {
|
||||
if (presupuesto.getUser() == null || !presupuesto.getUser().getUserName().equals(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1067,8 +1231,10 @@ public class PresupuestoService {
|
||||
target.setPrecioTotalTirada(src.getPrecioTotalTirada());
|
||||
target.setServiciosTotal(src.getServiciosTotal());
|
||||
target.setBaseImponible(src.getBaseImponible());
|
||||
target.setIvaTipo(src.getIvaTipo());
|
||||
target.setIvaImporte(src.getIvaImporte());
|
||||
target.setIvaReducido(src.getIvaReducido());
|
||||
target.setEntregaTipo(src.getEntregaTipo());
|
||||
target.setIvaImporte4(src.getIvaImporte4());
|
||||
target.setIvaImporte21(src.getIvaImporte21());
|
||||
target.setTotalConIva(src.getTotalConIva());
|
||||
target.setCreatedBy(target.getCreatedBy() == null ? src.getCreatedBy() : target.getCreatedBy()); // no pisar si
|
||||
// ya existe
|
||||
|
||||
@ -3,8 +3,8 @@ spring.application.name=erp
|
||||
#
|
||||
# Logging
|
||||
#
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
logging.level.root=WARN
|
||||
logging.level.org.springframework.security=ERROR
|
||||
logging.level.root=ERROR
|
||||
logging.level.org.springframework=ERROR
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ spring.datasource.password=om91irrDctd
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
|
||||
#spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.show-sql=false
|
||||
|
||||
|
||||
#
|
||||
@ -33,9 +33,9 @@ safekat.api.password=Safekat2024
|
||||
#
|
||||
# Debug JPA / Hibernate
|
||||
#
|
||||
logging.level.org.hibernate.SQL=DEBUG
|
||||
logging.level.org.hibernate.orm.jdbc.bind=TRACE
|
||||
spring.jpa.properties.hibernate.format_sql=true
|
||||
#logging.level.org.hibernate.SQL=DEBUG
|
||||
#logging.level.org.hibernate.orm.jdbc.bind=TRACE
|
||||
#spring.jpa.properties.hibernate.format_sql=true
|
||||
|
||||
#
|
||||
# Resource chain
|
||||
@ -85,3 +85,10 @@ geoip.http.enabled=true
|
||||
# Hibernate Timezone
|
||||
#
|
||||
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
|
||||
|
||||
#
|
||||
# PDF Templates
|
||||
#
|
||||
# PDF Templates
|
||||
imprimelibros.pdf.templates.PRESUPUESTO_presupuesto-a4=imprimelibros/pdf/presupuesto-a4
|
||||
imprimelibros.pdf.templates.FACTURA_factura-a4=imprimelibros/pdf/factura-a4
|
||||
|
||||
0
src/main/resources/i18n/pdf_en.properties
Normal file
0
src/main/resources/i18n/pdf_en.properties
Normal file
21
src/main/resources/i18n/pdf_es.properties
Normal file
21
src/main/resources/i18n/pdf_es.properties
Normal file
@ -0,0 +1,21 @@
|
||||
pdf.company.name=Impresión ImprimeLibros S.L.
|
||||
pdf.company.address=C/ José Picón, 28 local A
|
||||
pdf.company.postalcode=28028
|
||||
pdf.company.city=Madrid
|
||||
pdf.company.phone=+34 910052574
|
||||
|
||||
pdf.presupuesto=PRESUPUESTO
|
||||
pdf.factura=FACTURA
|
||||
pdf.pedido=PEDIDO
|
||||
|
||||
# Presupuesto
|
||||
pdf.presupuesto.number=PRESUPUESTO Nº:
|
||||
pdf.presupuesto.client=CLIENTE:
|
||||
pdf.presupuesto.date=FECHA:
|
||||
|
||||
pdf.presupuesto.titulo=Título:
|
||||
|
||||
pdf.politica-privacidad=Política de privacidad
|
||||
pdf.politica-privacidad.responsable=Responsable: Impresión Imprime Libros - CIF: B04998886 - Teléfono de contacto: 910052574
|
||||
pdf.politica-privacidad.correo-direccion=Correo electrónico: info@imprimelibros.com - Dirección postal: Calle José Picón, Nº 28 Local A, 28028, Madrid
|
||||
pdf.politica-privacidad.aviso=Le comunicamos que los datos que usted nos facilite quedarán incorporados en nuestro registro interno de actividades de tratamiento con el fin de llevar a cabo una adecuada gestión fiscal y contable. Los datos proporcionados se conservarán mientras se mantenga la relación comercial o durante los años necesarios para cumplir con las obligaciones legales. Así mismo, los datos no serán cedidos a terceros salvo en aquellos casos en que exista una obligación legal. Tiene derecho a acceder a sus datos personales, rectificar los datos inexactos, solicitar su supresión, limitar alguno de los tratamientos u oponerse a algún uso vía e-mail, personalmente o mediante correo postal.
|
||||
@ -72,6 +72,15 @@ presupuesto.espiral-descripcion=Espiral (a partir de 20 páginas)
|
||||
presupuesto.wire-o=Wire-O
|
||||
presupuesto.wireo=Wire-O
|
||||
presupuesto.wire-o-descripcion=Wire-O (a partir de 20 páginas)
|
||||
presupuesto.informacion-adicional=Información adicional
|
||||
presupuesto.informacion-adicional-descripcion=Datos adicionales
|
||||
presupuesto.iva-reducido=I.V.A reducido
|
||||
presupuesto.iva-reducido-descripcion=Se verificará que el pedido cumpla con los requisitos establecidos en el Artículo 91 de la Ley 37/1992, sobre inserción de publicidad, antes de proceder con su producción, lo que garantiza la aplicación del IVA reducido del 4%.
|
||||
presupuesto.entrega=Entrega
|
||||
presupuesto.entrega.peninsula=Península y Baleares
|
||||
presupuesto.entrega.canarias=Canarias
|
||||
presupuesto.entrega.paises-ue=Países UE
|
||||
|
||||
presupuesto.encuadernacion-descripcion=Seleccione la encuadernación del libro
|
||||
presupuesto.continuar-interior=Continuar a diseño interior
|
||||
|
||||
@ -197,7 +206,8 @@ presupuesto.resumen.tabla.cantidad=Cantidad
|
||||
presupuesto.resumen.tabla.precio-unidad=Precio/unidad
|
||||
presupuesto.resumen.tabla.precio-total=Precio total
|
||||
presupuesto.resumen.tabla.base=Base
|
||||
presupuesto.resumen.tabla.iva=I.V.A. (4%)
|
||||
presupuesto.resumen.tabla.iva4=I.V.A. (4%)
|
||||
presupuesto.resumen.tabla.iva21=I.V.A. (21%)
|
||||
presupuesto.resumen.tabla.total=Total presupuesto
|
||||
presupuesto.resumen-texto=Impresion de {0} unidades encuadernadas en {1} en {2} con {3} páginas en formato {4} x {5} mm. \
|
||||
<ul> \
|
||||
|
||||
59
src/main/resources/static/assets/css/bootstrap-for-pdf.css
vendored
Normal file
59
src/main/resources/static/assets/css/bootstrap-for-pdf.css
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
/* =======================
|
||||
Bootstrap for PDF (compatible con OpenHTMLtoPDF)
|
||||
======================= */
|
||||
|
||||
/* -- TEXT ALIGN -- */
|
||||
.text-start { text-align: left !important; }
|
||||
.text-center { text-align: center !important; }
|
||||
.text-end { text-align: right !important; }
|
||||
|
||||
/* -- FONT WEIGHT -- */
|
||||
.fw-normal { font-weight: 400 !important; }
|
||||
.fw-semibold { font-weight: 600 !important; }
|
||||
.fw-bold { font-weight: 700 !important; }
|
||||
|
||||
/* -- SPACING (margin/padding) -- */
|
||||
.mt-1 { margin-top: 0.25rem !important; }
|
||||
.mt-2 { margin-top: 0.5rem !important; }
|
||||
.mt-3 { margin-top: 1rem !important; }
|
||||
.mb-1 { margin-bottom: 0.25rem !important; }
|
||||
.mb-2 { margin-bottom: 0.5rem !important; }
|
||||
.mb-3 { margin-bottom: 1rem !important; }
|
||||
|
||||
.p-1 { padding: 0.25rem !important; }
|
||||
.p-2 { padding: 0.5rem !important; }
|
||||
.p-3 { padding: 1rem !important; }
|
||||
|
||||
/* -- TABLE -- */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 6px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* -- BORDER -- */
|
||||
.border {
|
||||
border: 1px solid #dee2e6 !important;
|
||||
}
|
||||
|
||||
.border-0 { border: 0 !important; }
|
||||
|
||||
/* -- BACKGROUND -- */
|
||||
.bg-light { background-color: #f8f9fa !important; }
|
||||
.bg-white { background-color: #fff !important; }
|
||||
|
||||
/* -- DISPLAY UTILS (limited) -- */
|
||||
.d-block { display: block !important; }
|
||||
.d-inline { display: inline !important; }
|
||||
.d-inline-block { display: inline-block !important; }
|
||||
@ -414,6 +414,7 @@
|
||||
|
||||
.form-switch-presupuesto .form-check-input:checked {
|
||||
border-color: #92b2a7;
|
||||
background-color: #cbcecd;
|
||||
}
|
||||
|
||||
.form-switch-custom.form-switch-presupuesto .form-check-input:checked::before {
|
||||
|
||||
374
src/main/resources/static/assets/css/presupuestopdf.css
Normal file
374
src/main/resources/static/assets/css/presupuestopdf.css
Normal file
@ -0,0 +1,374 @@
|
||||
:root {
|
||||
--verde: #92b2a7;
|
||||
--letterspace: 8px;
|
||||
/* ← puedes ajustar este valor en el root */
|
||||
-ink: #1b1e28;
|
||||
--muted: #5b6472;
|
||||
--accent: #0ea5e9;
|
||||
/* azul tira a cyan */
|
||||
--line: #e6e8ef;
|
||||
--bg-tag: #f4f7fb;
|
||||
}
|
||||
|
||||
/* Open Sans (rutas relativas desde css → fonts) */
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
src: url("../fonts/OpenSans-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
src: url("../fonts/OpenSans-SemiBold.ttf") format("truetype");
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
src: url("../fonts/OpenSans-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: "Open Sans" !important;
|
||||
color: var(--ink);
|
||||
font-size: 11pt;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 15mm 14mm 28mm 14mm; /* ↑ deja 10mm extra para no pisar el footer */
|
||||
box-sizing: border-box; /* para que el padding no desborde */
|
||||
}
|
||||
|
||||
|
||||
body.has-watermark {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* ====== HEADER (tabla) ====== */
|
||||
.il-header {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 8mm 0;
|
||||
/* ↓ espacio bajo el header */
|
||||
}
|
||||
|
||||
.il-left,
|
||||
.il-right {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.il-left {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.il-right {
|
||||
width: 50%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.il-logo {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
/* ← tamaño logo */
|
||||
|
||||
/* Caja superior derecha con esquinas */
|
||||
.il-company-box {
|
||||
display: inline-block;
|
||||
align-items: end;
|
||||
/* para alinear a la derecha sin ocupar todo */
|
||||
position: relative;
|
||||
padding: 4mm 4mm;
|
||||
/* ← espacio texto ↔ esquinas */
|
||||
color: #000;
|
||||
font-size: 10.5pt;
|
||||
/* ← tamaño de letra */
|
||||
line-height: 1;
|
||||
/* ← separación entre líneas */
|
||||
max-width: 75mm;
|
||||
/* ← ancho máximo de la caja */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Esquinas */
|
||||
.il-company-box .corner {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
/* ← anchura esquina */
|
||||
height: 20px;
|
||||
/* ← altura esquina */
|
||||
border-color: #92b2a7;
|
||||
/* ← color esquina */
|
||||
}
|
||||
|
||||
.corner.tl {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-top: 2px solid #92b2a7;
|
||||
border-left: 2px solid #92b2a7;
|
||||
}
|
||||
|
||||
.corner.tr {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-top: 2px solid #92b2a7;
|
||||
border-right: 2px solid #92b2a7;
|
||||
}
|
||||
|
||||
.corner.bl {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-bottom: 2px solid #92b2a7;
|
||||
border-left: 2px solid #92b2a7;
|
||||
}
|
||||
|
||||
.corner.br {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-bottom: 2px solid #92b2a7;
|
||||
border-right: 2px solid #92b2a7;
|
||||
}
|
||||
|
||||
|
||||
.company-line {
|
||||
margin: 1.5mm 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Nueva banda verde PRESUPUESTO */
|
||||
.doc-banner {
|
||||
width: 100%;
|
||||
background-color: #92b2a7 !important; /* ← tu verde corporativo */
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2mm 0;
|
||||
margin-bottom: 4mm;
|
||||
display: block; /* evita conflictos */
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
font-family: "Open Sans", Arial, sans-serif !important;
|
||||
font-weight: 400;
|
||||
font-size: 20pt;
|
||||
letter-spacing: 8px; /* ← configurable */
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ficha superior */
|
||||
.sheet-info {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 4mm 0 6mm 0;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
|
||||
.sheet-info td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.sheet-info .lbl {
|
||||
color: var(--muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/*.sheet-info .val {
|
||||
}*/
|
||||
|
||||
/* Línea título libro */
|
||||
.line-title {
|
||||
font-family: "Open Sans", Arial, sans-serif !important;
|
||||
margin: 3mm 0 5mm 0;
|
||||
padding: 2px 4px;
|
||||
font-size: 10.5pt;
|
||||
font-weight: 600;
|
||||
color: #5c5c5c;
|
||||
}
|
||||
|
||||
.line-title .lbl {
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Specs 2 columnas */
|
||||
.specs-wrapper {
|
||||
width: 180mm;
|
||||
margin-left: 15mm; /* ← margen izquierdo real del A4 */
|
||||
margin-right: auto; /* opcional */
|
||||
color: #5c5c5c;
|
||||
}
|
||||
|
||||
.align-with-text {
|
||||
margin-left: 1mm;
|
||||
margin-right: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.specs {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
margin-bottom: 6mm;
|
||||
}
|
||||
.specs .col {
|
||||
display: table-cell;
|
||||
width: 50%;
|
||||
padding-right: 6mm;
|
||||
vertical-align: top;
|
||||
}
|
||||
.specs .col:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 8pt;
|
||||
margin: 2mm 0 1mm 0;
|
||||
}
|
||||
|
||||
.kv {
|
||||
margin: 1mm 0;
|
||||
}
|
||||
|
||||
.kv span {
|
||||
color: var(--muted);
|
||||
display: inline-block;
|
||||
min-width: 55%;
|
||||
}
|
||||
|
||||
.kv b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subblock {
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.services {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.services li {
|
||||
margin: 1mm 0;
|
||||
}
|
||||
|
||||
/* Bloque marcapáginas */
|
||||
.bookmark {
|
||||
margin-top: 4mm;
|
||||
border: 1px dashed var(--line);
|
||||
padding: 3mm;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bookmark .bk-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
/* Tabla de precios (tiradas) */
|
||||
.prices {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 6mm;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
|
||||
.prices thead th {
|
||||
text-align: left;
|
||||
padding: 6px;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
background: #eef8fe;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prices tbody td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.prices .col-tirada {
|
||||
width: 22%;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 14mm;
|
||||
right: 14mm;
|
||||
bottom: 18mm;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 4mm;
|
||||
font-size: 7.5pt;
|
||||
color: var(--muted);
|
||||
z-index: 10; /* sobre la marca */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
.footer .address {
|
||||
display: table-cell;
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.footer .privacy {
|
||||
display: table-cell;
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
.pv-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1mm;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pv-text {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.page-count {
|
||||
margin-top: 2mm;
|
||||
text-align: right;
|
||||
font-size: 9pt;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page::after {
|
||||
content: counter(page);
|
||||
}
|
||||
|
||||
.pages::after {
|
||||
content: counter(pages);
|
||||
}
|
||||
|
||||
/* Caja a página completa SIN vw/vh y SIN z-index negativo */
|
||||
.watermark {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0; /* ocupa toda la HOJA */
|
||||
pointer-events: none;
|
||||
z-index: 0; /* debajo del contenido */
|
||||
}
|
||||
|
||||
.watermark img {
|
||||
position: absolute;
|
||||
top: 245mm; /* baja/sube (70–85%) */
|
||||
left: 155mm; /* desplaza a la derecha si quieres */
|
||||
transform: translate(-50%, -50%) rotate(-15deg);
|
||||
width: 60%; /* tamaño grande, ya no hay recorte por márgenes */
|
||||
max-width: none;
|
||||
}
|
||||
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.
BIN
src/main/resources/static/assets/images/logo-watermark.png
Normal file
BIN
src/main/resources/static/assets/images/logo-watermark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
@ -26,7 +26,7 @@
|
||||
pageLength: 50,
|
||||
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
||||
responsive: true,
|
||||
dom: 'Bfrtip',
|
||||
dom: 'BlrBtip',
|
||||
buttons: {
|
||||
dom: {
|
||||
button: {
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import PresupuestoWizard from './wizard.js';
|
||||
|
||||
if($('#presupuesto_id').val() == null || $('#presupuesto_id').val() === '') {
|
||||
sessionStorage.removeItem('formData');
|
||||
}
|
||||
|
||||
const app = new PresupuestoWizard({
|
||||
mode: 'private',
|
||||
readonly: false,
|
||||
presupuestoId: $('#presupuesto_id').val(),
|
||||
canSave: true,
|
||||
useSessionCache: false,
|
||||
useSessionCache: true,
|
||||
});
|
||||
app.init();
|
||||
@ -1,5 +1,8 @@
|
||||
import PresupuestoWizard from './wizard.js';
|
||||
|
||||
// remove formData from sessionStorage to avoid conflicts
|
||||
sessionStorage.removeItem('formData');
|
||||
|
||||
const app = new PresupuestoWizard({
|
||||
mode: 'public',
|
||||
readonly: false,
|
||||
|
||||
@ -4,6 +4,6 @@ const app = new PresupuestoWizard({
|
||||
mode: 'public',
|
||||
readonly: true,
|
||||
canSave: false,
|
||||
useSessionCache: false,
|
||||
useSessionCache: true,
|
||||
});
|
||||
app.init();
|
||||
@ -33,6 +33,8 @@ export default class PresupuestoWizard {
|
||||
paginasColor: 0,
|
||||
posicionPaginasColor: '',
|
||||
tipoEncuadernacion: 'fresado',
|
||||
entregaTipo: 'peninsula',
|
||||
ivaReducido: true,
|
||||
},
|
||||
interior: {
|
||||
tipoImpresion: 'negro',
|
||||
@ -122,6 +124,9 @@ export default class PresupuestoWizard {
|
||||
this.divPosicionPaginasColor = $('#div-posicion-paginas-color');
|
||||
this.posicionPaginasColor = $('#posicionPaginasColor');
|
||||
this.paginas = $('#paginas');
|
||||
this.entregaTipo = $('#entregaTipo');
|
||||
this.ivaReducido = $('#iva-reducido');
|
||||
this.btnIvaReducidoDetail = $('#btn-iva-reducido-detail');
|
||||
this.btn_next_datos_generales = $('#next-datos-generales');
|
||||
this.datos_generales_alert = $('#datos-generales-alert');
|
||||
|
||||
@ -180,6 +185,8 @@ export default class PresupuestoWizard {
|
||||
|
||||
async init() {
|
||||
|
||||
const self = this;
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function (xhr) {
|
||||
const token = document.querySelector('meta[name="_csrf"]')?.content;
|
||||
@ -190,7 +197,7 @@ export default class PresupuestoWizard {
|
||||
|
||||
const root = document.getElementById('presupuesto-app');
|
||||
const mode = root?.dataset.mode || 'public';
|
||||
const presupuestoId = root?.dataset.id || null;
|
||||
const presupuestoId = this.opts.presupuestoId || null;
|
||||
|
||||
let stored = null;
|
||||
if (this.opts.useSessionCache) {
|
||||
@ -249,26 +256,19 @@ export default class PresupuestoWizard {
|
||||
|
||||
const alert = $('#form-errors');
|
||||
const servicios = [];
|
||||
$('.service-checkbox:checked').each(function () {
|
||||
const $servicio = $(this);
|
||||
servicios.push({
|
||||
id: $servicio.attr('id') ?? $(`label[for="${$servicio.attr('id')}"] .service-title`).text().trim(),
|
||||
label: $(`label[for="${$servicio.attr('id')}"] .service-title`).text().trim(),
|
||||
units: $servicio.attr('id') === 'marcapaginas' ? self.formData.servicios.datosMarcapaginas.marcapaginas_tirada : 1,
|
||||
price: $servicio.data('price') ?? $(`label[for="${$servicio.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), ''),
|
||||
});
|
||||
});
|
||||
const payload = {
|
||||
id: this.opts.presupuestoId,
|
||||
mode: this.opts.mode,
|
||||
presupuesto: this.#getPresupuestoData(),
|
||||
servicios: servicios,
|
||||
servicios: this.formData.servicios.servicios,
|
||||
datosMaquetacion: this.formData.servicios.datosMaquetacion,
|
||||
datosMarcapaginas: this.formData.servicios.datosMarcapaginas,
|
||||
cliente_id: $('#cliente_id').val() || null,
|
||||
};
|
||||
try {
|
||||
alert.addClass('d-none').find('ul').empty();
|
||||
$.ajax({
|
||||
url: '/presupuesto/save',
|
||||
url: '/presupuesto/api/save',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(payload)
|
||||
@ -341,6 +341,8 @@ export default class PresupuestoWizard {
|
||||
...this.#getInteriorData(),
|
||||
...this.#getCubiertaData(),
|
||||
selectedTirada: this.formData.selectedTirada
|
||||
|
||||
|
||||
};
|
||||
|
||||
const sobrecubierta = data.sobrecubierta;
|
||||
@ -396,6 +398,27 @@ export default class PresupuestoWizard {
|
||||
******************************/
|
||||
#initDatosGenerales() {
|
||||
|
||||
this.btnIvaReducidoDetail.on('click', () => {
|
||||
Swal.fire({
|
||||
position: 'top-end',
|
||||
icon: 'info',
|
||||
title: window.languageBundle.get('presupuesto.iva-reducido'),
|
||||
html: `
|
||||
<div class="acitivity-timeline p-4">
|
||||
${window.languageBundle.get('presupuesto.iva-reducido-descripcion')}
|
||||
</div>
|
||||
`,
|
||||
confirmButtonClass: 'btn btn-primary w-xs mt-2',
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
|
||||
cancelButton: 'btn btn-light' // clases para cancelar
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$('.datos-generales-data').on('change', () => {
|
||||
const dataToStore = this.#getDatosGeneralesData();
|
||||
this.#updateDatosGeneralesData(dataToStore);
|
||||
@ -499,6 +522,18 @@ export default class PresupuestoWizard {
|
||||
|
||||
$('.alto-faja-max').text(`max: ${this.formData.datosGenerales.alto} mm`);
|
||||
|
||||
// check if selected tirada is still valid
|
||||
const tiradas = [
|
||||
parseInt(this.formData.datosGenerales.tirada1),
|
||||
parseInt(this.formData.datosGenerales.tirada2) || 0,
|
||||
parseInt(this.formData.datosGenerales.tirada3) || 0,
|
||||
parseInt(this.formData.datosGenerales.tirada4) || 0,
|
||||
].filter(t => t > 0).sort((a, b) => a - b);
|
||||
|
||||
if (!tiradas.includes(this.formData.selectedTirada)) {
|
||||
this.formData.selectedTirada = tiradas[0];
|
||||
}
|
||||
|
||||
this.#loadInteriorData(data);
|
||||
|
||||
const interiorData = this.#getInteriorData();
|
||||
@ -528,6 +563,8 @@ export default class PresupuestoWizard {
|
||||
paginasColor: this.paginasColor.val(),
|
||||
posicionPaginasColor: this.posicionPaginasColor.val(),
|
||||
tipoEncuadernacion: $('.tipo-libro input:checked').val() || 'fresado',
|
||||
entregaTipo: this.entregaTipo.val(),
|
||||
ivaReducido: this.ivaReducido.is(':checked'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -548,6 +585,8 @@ export default class PresupuestoWizard {
|
||||
paginasColor: data.paginasColor,
|
||||
posicionPaginasColor: data.posicionPaginasColor,
|
||||
tipoEncuadernacion: data.tipoEncuadernacion,
|
||||
entregaTipo: data.entregaTipo,
|
||||
ivaReducido: data.ivaReducido,
|
||||
};
|
||||
}
|
||||
|
||||
@ -593,6 +632,9 @@ export default class PresupuestoWizard {
|
||||
this.formato.val(option.val()).trigger('change');
|
||||
}
|
||||
}
|
||||
|
||||
this.entregaTipo.val(this.formData.datosGenerales.entregaTipo);
|
||||
this.ivaReducido.prop('checked', this.formData.datosGenerales.ivaReducido);
|
||||
}
|
||||
|
||||
#getTamanio() {
|
||||
@ -1590,7 +1632,14 @@ export default class PresupuestoWizard {
|
||||
const $target = $(e.currentTarget);
|
||||
|
||||
if ($target.prop('checked')) {
|
||||
this.formData.servicios.servicios.push($target.val());
|
||||
this.formData.servicios.servicios.push(
|
||||
$target.val(),
|
||||
{
|
||||
id: $target.attr('id') ?? $(`label[for="${$target.attr('id')}"] .service-title`).text().trim(),
|
||||
label: $(`label[for="${$target.attr('id')}"] .service-title`).text().trim(),
|
||||
units: $target.attr('id') === 'marcapaginas' ? self.formData.servicios.datosMarcapaginas.marcapaginas_tirada : 1,
|
||||
price: $target.data('price') ?? $(`label[for="${$target.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), ''),
|
||||
});
|
||||
} else {
|
||||
const index = this.formData.servicios.servicios.indexOf($target.val());
|
||||
if (index > -1) {
|
||||
@ -1613,7 +1662,7 @@ export default class PresupuestoWizard {
|
||||
|
||||
this.divExtras.addClass('animate-fadeInUpBounce');
|
||||
for (const extra of servicios) {
|
||||
if (this.formData.servicios.servicios.includes(extra.id) && !extra.checked) {
|
||||
if (this.formData.servicios.servicios.some(s => s.id === extra.id) && !extra.checked) {
|
||||
extra.checked = true;
|
||||
if (extra.id === "marcapaginas" || extra.id === "maquetacion") {
|
||||
extra.price = extra.id === "marcapaginas" ?
|
||||
@ -1624,11 +1673,6 @@ export default class PresupuestoWizard {
|
||||
}
|
||||
const item = new ServiceOptionCard(extra);
|
||||
this.divExtras.append(item.render());
|
||||
if (item.checked) {
|
||||
if (!this.formData.servicios.servicios.includes(extra.id)) {
|
||||
this.formData.servicios.servicios.push(extra.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#cacheFormData();
|
||||
@ -1741,8 +1785,6 @@ export default class PresupuestoWizard {
|
||||
});
|
||||
const servicios = data.servicios || [];
|
||||
|
||||
let total = 0;
|
||||
|
||||
const locale = document.documentElement.lang || 'es-ES';
|
||||
|
||||
for (const l of lineas) {
|
||||
@ -1755,7 +1797,6 @@ export default class PresupuestoWizard {
|
||||
<td class="text-end">${formateaMoneda(data[l].precio_total, 2, locale)}</td>
|
||||
</tr>
|
||||
`;
|
||||
total += data[l].precio_total;
|
||||
this.tablaResumen.find('tbody').append(row);
|
||||
}
|
||||
for (const s of servicios) {
|
||||
@ -1768,13 +1809,26 @@ export default class PresupuestoWizard {
|
||||
<td class="text-end">${s.id === "marcapaginas" ? formateaMoneda(s.precio * s.unidades, 2, locale) : formateaMoneda(s.precio, 2, locale)}</td>
|
||||
</tr>
|
||||
`;
|
||||
total += s.precio;
|
||||
this.tablaResumen.find('tbody').append(row);
|
||||
}
|
||||
|
||||
$('#resumen-base').text(formateaMoneda(total, 2, locale));
|
||||
$('#resumen-iva').text(formateaMoneda(total * 0.04, 2, locale));
|
||||
$('#resumen-total').text(formateaMoneda(total * 1.04, 2, locale));
|
||||
$('#resumen-base').text(formateaMoneda(data.base_imponible, 2, locale));
|
||||
if(data.iva_importe_4 > 0) {
|
||||
$('#tr-resumen-iva4').removeClass('d-none');
|
||||
$('#resumen-iva4').text(formateaMoneda(data.iva_importe_4, 2, locale));
|
||||
}
|
||||
else{
|
||||
$('#tr-resumen-iva4').addClass('d-none');
|
||||
$('#resumen-iva4').text(formateaMoneda(0, 2, locale));
|
||||
}
|
||||
if(data.iva_importe_21 > 0) {
|
||||
$('#tr-resumen-iva21').removeClass('d-none');
|
||||
$('#resumen-iva21').text(formateaMoneda(data.iva_importe_21, 2, locale));
|
||||
} else {
|
||||
$('#tr-resumen-iva21').addClass('d-none');
|
||||
$('#resumen-iva21').text(formateaMoneda(0, 2, locale));
|
||||
}
|
||||
$('#resumen-total').text(formateaMoneda(data.total_con_iva, 2, locale));
|
||||
}
|
||||
|
||||
/******************************
|
||||
|
||||
@ -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,
|
||||
serverSide: true,
|
||||
orderCellsTop: true,
|
||||
stateSave: true,
|
||||
pageLength: 50,
|
||||
lengthMenu: [10, 25, 50, 100, 500],
|
||||
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
||||
responsive: true,
|
||||
dom: 'Bflrtip',
|
||||
dom: 'lBrtip',
|
||||
buttons: {
|
||||
dom: {
|
||||
button: {
|
||||
@ -51,20 +50,20 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js';
|
||||
},
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{ data: 'id', name: 'id', title: 'ID', orderable: true },
|
||||
{ data: 'titulo', name: 'titulo', title: 'Título', orderable: true },
|
||||
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', title: 'Encuadernación', orderable: true },
|
||||
{ data: 'tipoCubierta', name: 'tipoCubierta', title: 'Cubierta', orderable: true },
|
||||
{ data: 'tipoImpresion', name: 'tipoImpresion', title: 'Tipo de impresión', orderable: true },
|
||||
{ data: 'tirada', name: 'selectedTirada', title: 'Tirada', orderable: true },
|
||||
{ data: 'paginas', name: 'paginas', title: 'Páginas', orderable: true },
|
||||
{ data: 'estado', name: 'estado', title: 'Estado', orderable: true },
|
||||
{ data: 'totalConIva', name: 'totalConIva', title: 'Total con IVA', orderable: true },
|
||||
{ data: 'pais', name: 'pais', title: 'País', orderable: true },
|
||||
{ data: 'region', name: 'region', title: 'Región', orderable: true },
|
||||
{ data: 'ciudad', name: 'ciudad', title: 'Ciudad', orderable: true },
|
||||
{ data: 'updatedAt', name: 'updatedAt', title: 'Actualizado el', orderable: true },
|
||||
{ data: 'actions', title: 'Acciones', orderable: false, searchable: false }
|
||||
{ data: 'id', name: 'id', orderable: true },
|
||||
{ data: 'titulo', name: 'titulo', orderable: true },
|
||||
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', orderable: true },
|
||||
{ data: 'tipoCubierta', name: 'tipoCubierta', orderable: true },
|
||||
{ data: 'tipoImpresion', name: 'tipoImpresion', orderable: true },
|
||||
{ data: 'selectedTirada', name: 'selectedTirada', orderable: true },
|
||||
{ data: 'paginas', name: 'paginas', orderable: true },
|
||||
{ data: 'estado', name: 'estado', orderable: true },
|
||||
{ data: 'totalConIva', name: 'totalConIva', orderable: true },
|
||||
{ data: 'pais', name: 'pais', orderable: true },
|
||||
{ data: 'region', name: 'region', orderable: true },
|
||||
{ data: 'ciudad', name: 'ciudad', orderable: true },
|
||||
{ data: 'updatedAt', name: 'updatedAt', orderable: true },
|
||||
{ data: 'actions', orderable: false, searchable: false }
|
||||
],
|
||||
});
|
||||
|
||||
@ -130,6 +129,148 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js';
|
||||
});
|
||||
});
|
||||
|
||||
$('#presupuestos-anonimos-datatable').on('keyup', '.presupuesto-filter', function (e) {
|
||||
const colName = $(this).data('col');
|
||||
const colIndex = table_anonimos.column(colName + ':name').index();
|
||||
|
||||
if (table_anonimos.column(colIndex).search() !== this.value) {
|
||||
table_anonimos.column(colIndex).search(this.value).draw();
|
||||
}
|
||||
});
|
||||
|
||||
$('#presupuestos-anonimos-datatable').on('change', '.presupuesto-select-filter', function (e) {
|
||||
const colName = $(this).data('col');
|
||||
const colIndex = table_anonimos.column(colName + ':name').index();
|
||||
const value = $(this).val();
|
||||
table_anonimos.column(colIndex).search(value).draw();
|
||||
});
|
||||
|
||||
|
||||
const table_clientes = new DataTable('#presupuestos-clientes-datatable', {
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
orderCellsTop: true,
|
||||
pageLength: 50,
|
||||
lengthMenu: [10, 25, 50, 100, 500],
|
||||
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
||||
responsive: true,
|
||||
dom: 'lBrtip',
|
||||
buttons: {
|
||||
dom: {
|
||||
button: {
|
||||
className: 'btn btn-sm btn-outline-primary me-1'
|
||||
},
|
||||
buttons: [
|
||||
{ extend: 'copy' },
|
||||
{ extend: 'csv' },
|
||||
{ extend: 'excel' },
|
||||
{ extend: 'pdf' },
|
||||
{ extend: 'print' },
|
||||
{ extend: 'colvis' }
|
||||
],
|
||||
}
|
||||
},
|
||||
ajax: {
|
||||
url: '/presupuesto/datatable/clientes',
|
||||
method: 'GET',
|
||||
},
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{ data: 'id', name: 'id', orderable: true },
|
||||
{ data: 'user', name: 'user.fullName', orderable: true },
|
||||
{ data: 'titulo', name: 'titulo', orderable: true },
|
||||
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', orderable: true },
|
||||
{ data: 'tipoCubierta', name: 'tipoCubierta', orderable: true },
|
||||
{ data: 'tipoImpresion', name: 'tipoImpresion', orderable: true },
|
||||
{ data: 'selectedTirada', name: 'selectedTirada', orderable: true },
|
||||
{ data: 'paginas', name: 'paginas', orderable: true },
|
||||
{ data: 'estado', name: 'estado', orderable: true },
|
||||
{ data: 'totalConIva', name: 'totalConIva', orderable: true },
|
||||
{ data: 'updatedAt', name: 'updatedAt', orderable: true },
|
||||
{ data: 'actions', orderable: false, searchable: false }
|
||||
],
|
||||
});
|
||||
|
||||
$('#presupuestos-clientes-datatable').on('click', '.btn-edit-privado', function (e) {
|
||||
|
||||
e.preventDefault();
|
||||
const id = $(this).data('id');
|
||||
if (id) {
|
||||
window.location.href = '/presupuesto/edit/' + id;
|
||||
}
|
||||
});
|
||||
|
||||
$('#presupuestos-clientes-datatable').on('click', '.btn-delete-privado', function (e) {
|
||||
|
||||
e.preventDefault();
|
||||
const id = $(this).data('id');
|
||||
|
||||
Swal.fire({
|
||||
title: window.languageBundle.get(['presupuesto.delete.title']) || 'Eliminar presupuesto',
|
||||
html: window.languageBundle.get(['presupuesto.delete.text']) || 'Esta acción no se puede deshacer.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-danger w-xs mt-2',
|
||||
cancelButton: 'btn btn-light w-xs mt-2'
|
||||
},
|
||||
confirmButtonText: window.languageBundle.get(['presupuesto.delete.button']) || 'Eliminar',
|
||||
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
$.ajax({
|
||||
url: '/presupuesto/' + id,
|
||||
type: 'DELETE',
|
||||
success: function () {
|
||||
Swal.fire({
|
||||
icon: 'success', title: window.languageBundle.get(['presupuesto.delete.ok.title']) || 'Eliminado',
|
||||
text: window.languageBundle.get(['presupuesto.delete.ok.text']) || 'El presupuesto ha sido eliminado con éxito.',
|
||||
showConfirmButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-secondary w-xs mt-2',
|
||||
},
|
||||
});
|
||||
$('#presupuestos-clientes-datatable').DataTable().ajax.reload(null, false);
|
||||
},
|
||||
error: function (xhr) {
|
||||
// usa el mensaje del backend; fallback genérico por si no llega JSON
|
||||
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|
||||
|| 'Error al eliminar el presupuesto.';
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'No se pudo eliminar',
|
||||
text: msg,
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
|
||||
cancelButton: 'btn btn-light' // clases para cancelar
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
$('#presupuestos-clientes-datatable').on('keyup', '.presupuesto-filter', function (e) {
|
||||
const colName = $(this).data('col');
|
||||
const colIndex = table_clientes.column(colName + ':name').index();
|
||||
|
||||
if (table_clientes.column(colIndex).search() !== this.value) {
|
||||
table_clientes.column(colIndex).search(this.value).draw();
|
||||
}
|
||||
});
|
||||
|
||||
$('#presupuestos-clientes-datatable').on('change', '.presupuesto-select-filter', function (e) {
|
||||
const colName = $(this).data('col');
|
||||
const colIndex = table_clientes.column(colName + ':name').index();
|
||||
const value = $(this).val();
|
||||
table_clientes.column(colIndex).search(value).draw();
|
||||
});
|
||||
|
||||
|
||||
$('#addPresupuestoButton').on('click', async function (e) {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
<i class="ri-file-paper-2-line"></i> <span th:text="#{app.sidebar.presupuestos}">Presupuestos</span>
|
||||
</a>
|
||||
</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"
|
||||
role="button" aria-expanded="false" aria-controls="sidebarConfig">
|
||||
<i class="ri-settings-2-line"></i> <span
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
<!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/bootstrap-for-pdf.css" />
|
||||
<link rel="stylesheet" href="assets/css/presupuestopdf.css" />
|
||||
</head>
|
||||
|
||||
<body class="has-watermark">
|
||||
|
||||
<div class="watermark">
|
||||
<img src="assets/images/logo-watermark.png" alt="Marca de agua" />
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<!-- HEADER: logo izq + caja empresa dcha -->
|
||||
|
||||
<!-- HEADER: logo izq + caja empresa dcha (tabla, sin flex) -->
|
||||
<table class="il-header">
|
||||
<tr>
|
||||
<td class="il-left">
|
||||
<img src="assets/images/logo-light.png" alt="ImprimeLibros" class="il-logo" />
|
||||
</td>
|
||||
<td class="il-right">
|
||||
<div class="il-company-box">
|
||||
<span class="corner tl"></span>
|
||||
<span class="corner tr"></span>
|
||||
<span class="corner bl"></span>
|
||||
<span class="corner br"></span>
|
||||
|
||||
<div class="company-line company-name" th:text="#{pdf.company.name} ?: 'ImprimeLibros'">
|
||||
ImprimeLibros ERP</div>
|
||||
<div class="company-line" th:text="#{pdf.company.address} ?: ''">C/ José Picón, 28 local A</div>
|
||||
<div class="company-line">
|
||||
<span th:text="#{pdf.company.postalcode} ?: '28028'">28028</span>
|
||||
<span th:text="#{pdf.company.city} ?: 'Madrid'">Madrid</span>
|
||||
</div>
|
||||
<div class="company-line" th:text="#{pdf.company.phone} ?: '+34 910052574'">+34 910052574</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<!-- BANDA SUPERIOR -->
|
||||
<div class="doc-banner">
|
||||
<div th:text="#{pdf.presupuesto} ?: 'PRESUPUESTO'" class="banner-text">PRESUPUESTO</div>
|
||||
</div>
|
||||
|
||||
<!-- FICHA Nº / CLIENTE / FECHA -->
|
||||
<table class="sheet-info">
|
||||
<tr>
|
||||
<td class="text-start"><span th:text="#{'pdf.presupuesto.number'}" class="lbl">PRESUPUESTO Nº:</span> <span
|
||||
class="val" th:text="${numero}">153153</span></td>
|
||||
<td class="text-center"><span th:text="#{pdf.presupuesto.client}" class="lbl">CLIENTE:</span> <span class="val"
|
||||
th:text="${cliente?.nombre} ?: '-'">JUAN JOSÉ
|
||||
MÉNDEZ</span></td>
|
||||
<td class="text-end"><span class="lbl" th:text="#{pdf.presupuesto.date}">FECHA:</span> <span class="val"
|
||||
th:text="${#temporals.format(fecha, 'dd/MM/yyyy')}">10/10/2025</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- TÍTULO DEL LIBRO -->
|
||||
<div class="line-title">
|
||||
<span class="lbl" th:text="#{pdf.presupuesto.titulo}">Título:</span>
|
||||
<span class="val" th:text="${titulo} ?: '-'">Libro de prueba</span>
|
||||
</div>
|
||||
|
||||
<!-- DATOS TÉCNICOS EN 2 COLUMNAS -->
|
||||
<div class="specs-wrapper align-with-text ">
|
||||
<div class="specs">
|
||||
<div class="col">
|
||||
<div class="block-title">Encuadernación</div>
|
||||
<div class="kv"><span>Encuadernación:</span><b th:text="${encuadernacion} ?: 'Fresado'">Fresado</b></div>
|
||||
<div class="kv"><span>Formato:</span><b>
|
||||
<span th:text="${ancho}">148</span>x<span th:text="${alto}">210</span> mm
|
||||
</b></div>
|
||||
<div class="kv"><span>Páginas:</span><b th:text="${paginasTotales} ?: 132">132</b></div>
|
||||
<div class="kv"><span>Páginas Negro:</span><b th:text="${paginasNegro} ?: 100">100</b></div>
|
||||
<div class="kv"><span>Páginas Color:</span><b th:text="${paginasColor} ?: 32">32</b></div>
|
||||
|
||||
<div class="subblock">
|
||||
<div class="block-title">Interior</div>
|
||||
<div class="kv"><span>Tipo de impresión:</span><b
|
||||
th:text="${interior?.tipoImpresion} ?: 'Color Premium'">Color
|
||||
Premium</b></div>
|
||||
<div class="kv"><span>Papel interior:</span><b th:text="${interior?.papel} ?: 'Estucado Mate'">Estucado
|
||||
Mate</b>
|
||||
</div>
|
||||
<div class="kv"><span>Gramaje interior:</span><b th:text="${interior?.gramaje} ?: 115">115</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="subblock">
|
||||
<div class="block-title">Cubierta</div>
|
||||
<div class="kv"><span>Tipo de cubierta:</span><b th:text="${cubierta?.tipo} ?: 'Tapa blanda'">Tapa
|
||||
blanda</b>
|
||||
</div>
|
||||
<div class="kv"><span>Solapas:</span><b th:text="${cubierta?.solapas} ?: 'Sí'">Sí</b></div>
|
||||
<div class="kv"><span>Tamaño solapas:</span><b th:text="${cubierta?.tamSolapas} ?: '80 mm'">80 mm</b></div>
|
||||
<div class="kv"><span>Impresión:</span><b th:text="${cubierta?.impresion} ?: 'Una cara'">Una cara</b></div>
|
||||
<div class="kv"><span>Papel cubierta:</span><b th:text="${cubierta?.papel} ?: 'Estucado mate'">Estucado
|
||||
mate</b>
|
||||
</div>
|
||||
<div class="kv"><span>Gramaje cubierta:</span><b th:text="${cubierta?.gramaje} ?: 250">250</b></div>
|
||||
<div class="kv"><span>Acabado:</span><b
|
||||
th:text="${cubierta?.acabado} ?: 'Plastificado Brillo 1/C'">Plastificado
|
||||
Brillo 1/C</b></div>
|
||||
</div>
|
||||
|
||||
<div class="subblock">
|
||||
<div class="block-title">Servicios Extras</div>
|
||||
<!-- Ejemplos específicos -->
|
||||
<div class="kv" th:if="${servicios != null}">
|
||||
<ul class="services">
|
||||
<li th:each="s : ${servicios}">
|
||||
<span th:text="${s.descripcion}">Ferro Digital</span>
|
||||
<span th:if="${s.precio != null}"
|
||||
th:text="${#numbers.formatDecimal(s.precio,1,'POINT',2,'COMMA')} + ' €'">0,00 €</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Bloque marcapáginas (si existe en servicios) -->
|
||||
<div class="bookmark" th:if="${marcapaginas != null}">
|
||||
<div class="bk-title">Marcapáginas</div>
|
||||
<div class="kv"><span>Tamaño:</span><b th:text="${marcapaginas.tamano} ?: '50x210'">50x210</b></div>
|
||||
<div class="kv"><span>Papel:</span><b th:text="${marcapaginas.papel} ?: 'Estucado mate 300 g'">Estucado
|
||||
mate
|
||||
300 g</b></div>
|
||||
<div class="kv"><span>Impresión:</span><b th:text="${marcapaginas.impresion} ?: 'Una cara'">Una cara</b>
|
||||
</div>
|
||||
<div class="kv"><span>Plastificado:</span><b th:text="${marcapaginas.plastificado} ?: 'Brillo 1/C'">Brillo
|
||||
1/C</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- .specs-wrapper -->
|
||||
|
||||
<!-- TABLA TIRADAS -->
|
||||
<table class="prices">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-tirada">TIRADA</th>
|
||||
<th>IMPRESIÓN</th>
|
||||
<th>IVA</th>
|
||||
<th>TOTAL</th>
|
||||
<th>UNIDAD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody th:if="${pricing != null and pricing.tiradas != null}">
|
||||
<tr th:each="t, st : ${pricing.tiradas}">
|
||||
<td class="col-tirada" th:text="${t} + ' uds.'">100 uds.</td>
|
||||
<td th:text="${#numbers.formatDecimal(pricing.impresion[st.index],1,'POINT',2,'COMMA')} + '€'">152,15€</td>
|
||||
<td th:text="${#numbers.formatDecimal(pricing.iva[st.index],1,'POINT',2,'COMMA')} + '€'">7,68€</td>
|
||||
<td th:text="${#numbers.formatDecimal(pricing.total[st.index],1,'POINT',2,'COMMA')} + '€'">159,99€</td>
|
||||
<td th:text="${#numbers.formatDecimal(pricing.unidad[st.index],1,'POINT',2,'COMMA')} + '€'">1,52€</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<!-- Si no hay pricing, no renderizar filas -->
|
||||
<tbody th:if="${pricing == null or pricing.tiradas == null}">
|
||||
<tr>
|
||||
<td colspan="5">Sin datos de tiradas.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- PIE -->
|
||||
<div class="footer">
|
||||
<div class="privacy">
|
||||
<div class="pv-title" th:text="#{pdf.politica-privacidad}">Política de privacidad</div>
|
||||
<div class="pv-text" th:text="#{pdf.politica-privacidad.responsable}">Responsable: Impresión Imprime Libros - CIF:
|
||||
B04998886 - Teléfono de contacto: 910052574</div>
|
||||
<div class="pv-text" th:text="#{pdf.politica-privacidad.correo-direccion}">Correo electrónico:
|
||||
info@imprimelibros.com - Dirección postal: Calle José Picón, Nº 28 Local A, 28028, Madrid</div>
|
||||
<div class="pv-text" th:text="#{pdf.politica-privacidad.aviso}">
|
||||
Le comunicamos que los datos que usted nos facilite quedarán incorporados
|
||||
en nuestro registro interno de actividades de tratamiento con el fin de
|
||||
llevar a cabo una adecuada gestión fiscal y contable.
|
||||
Los datos proporcionados se conservarán mientras se mantenga la relación
|
||||
comercial o durante los años necesarios para cumplir con las obligaciones legales.
|
||||
Así mismo, los datos no serán cedidos a terceros salvo en aquellos casos en que exista
|
||||
una obligación legal. Tiene derecho a acceder a sus datos personales, rectificar
|
||||
los datos inexactos, solicitar su supresión, limitar alguno de los tratamientos
|
||||
u oponerse a algún uso vía e-mail, personalmente o mediante correo postal.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,10 +1,15 @@
|
||||
<!-- templates/fragments/common.html -->
|
||||
<div th:fragment="buttons(appMode)"
|
||||
th:if="${appMode == 'add' or appMode == 'edit'}"
|
||||
class="order-3 order-md-2 mx-md-auto d-flex">
|
||||
<button id="btn-guardar" type="button"
|
||||
class="btn btn-success d-flex align-items-center guardar-presupuesto">
|
||||
<button th:if="${appMode == 'add' or appMode == 'edit'}" id="btn-guardar" type="button"
|
||||
class="btn btn-secondary d-flex align-items-center mx-2 guardar-presupuesto">
|
||||
<i class="ri-save-3-line me-2"></i>
|
||||
<span th:text="#{presupuesto.guardar}">Guardar</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-imprimir" type="button"
|
||||
class="btn btn-secondary d-flex align-items-center mx-2 imprimir-presupuesto">
|
||||
<i class="ri-printer-line me-2"></i>
|
||||
<span th:text="#{app.imprimir}">Imprimir</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
|
||||
<div class="ribbon-content mt-4">
|
||||
|
||||
<div class="alert alert-danger alert-label-icon rounded-label fade show material-shadow d-none"
|
||||
role="alert" id="datos-generales-alert">
|
||||
<div class="alert alert-danger alert-label-icon rounded-label fade show material-shadow d-none" role="alert"
|
||||
id="datos-generales-alert">
|
||||
<i class="ri-error-warning-line label-icon"></i>
|
||||
<strong th:text="#{presupuesto.errores-title}">Corrija los siguientes errores:</strong>
|
||||
<ul class="mb-0" id="datos-generales-alert-list">
|
||||
@ -33,13 +33,15 @@
|
||||
<div class="col-sm-6">
|
||||
<div class="mb-3">
|
||||
<label for="autor" class="form-label" th:text="#{presupuesto.autor}">Autor</label>
|
||||
<input type="text" class="form-control datos-generales-data" id="autor" th:value="${presupuesto?.autor} ?: ''">
|
||||
<input type="text" class="form-control datos-generales-data" id="autor"
|
||||
th:value="${presupuesto?.autor} ?: ''">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="mb-3">
|
||||
<label for="isbn" class="form-label" th:text="#{presupuesto.isbn}">ISBN</label>
|
||||
<input type="text" class="form-control datos-generales-data" id="isbn" th:value="${presupuesto?.isbn} ?: ''" >
|
||||
<input type="text" class="form-control datos-generales-data" id="isbn"
|
||||
th:value="${presupuesto?.isbn} ?: ''">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -206,8 +208,8 @@
|
||||
|
||||
|
||||
<!-- Opción: Cosido -->
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.cosido}" id="cosido">
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.cosido}" id="cosido">
|
||||
<input type="radio" name="tipoEncuadernacion" value="cosido" hidden>
|
||||
<img class="image-presupuesto" src="/assets/images/imprimelibros/presupuestador/cosido.png"
|
||||
alt="Cosido" />
|
||||
@ -217,8 +219,8 @@
|
||||
|
||||
|
||||
<!-- Opción: Grapado -->
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.grapado}" id="grapado">
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.grapado}" id="grapado">
|
||||
<input type="radio" name="tipoEncuadernacion" value="grapado" hidden>
|
||||
<img class="image-presupuesto" src="/assets/images/imprimelibros/presupuestador/grapado.png"
|
||||
alt="Grapado" />
|
||||
@ -227,8 +229,8 @@
|
||||
</label>
|
||||
|
||||
<!-- Opción: Espiral -->
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.espiral}" id="espiral">
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.espiral}" id="espiral">
|
||||
<input type="radio" name="tipoEncuadernacion" value="espiral" hidden>
|
||||
<img class="image-presupuesto" src="/assets/images/imprimelibros/presupuestador/espiral.png"
|
||||
alt="Espiral" />
|
||||
@ -237,8 +239,8 @@
|
||||
</label>
|
||||
|
||||
<!-- Opción: Wire-O -->
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.wire-o}" id="wireo">
|
||||
<label class="tipo-libro image-container imagen-selector datos-generales-data"
|
||||
th:attr="data-summary-text=#{presupuesto.wire-o}" id="wireo">
|
||||
<input type="radio" name="tipoEncuadernacion" value="wireo" hidden>
|
||||
<img class="image-presupuesto" src="/assets/images/imprimelibros/presupuestador/wire-o.png"
|
||||
alt="Wire-O" />
|
||||
@ -251,9 +253,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Ribbon Shape -->
|
||||
<div class="card ribbon-box border shadow-none mb-lg-0 material-shadow mt-4">
|
||||
<div class="card-body">
|
||||
<div class="ribbon ribbon-primary ribbon-shape" th:text="#{presupuesto.informacion-adicional}">Información
|
||||
adicional
|
||||
</div>
|
||||
<h5 class="fs-14 text-end" th:text="#{presupuesto.informacion-adicional-descripcion}"></h5>
|
||||
</div>
|
||||
|
||||
<div class="ribbon-content mt-4">
|
||||
|
||||
<div class="row justify-content-center mb-2">
|
||||
<div class="col-sm-3 justify-content-center">
|
||||
<div
|
||||
class="form-check form-switch form-switch-custom form-switch-presupuesto mb-3 d-flex align-items-center">
|
||||
<input type="checkbox" class="form-check-input datos-generales-data me-2" id="iva-reducido"
|
||||
name="iva-reducido" checked/>
|
||||
<label for="iva-reducido" class="form-label d-flex align-items-center mb-0">
|
||||
<span th:text="#{presupuesto.iva-reducido}" class="me-2">I.V. reducido</span>
|
||||
<button type="button" id="btn-iva-reducido-detail"
|
||||
class="btn btn-outline-primary btn-border btn-sm">
|
||||
<i class="ri-questionnaire-line label-icon align-middle fs-16"></i>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center mb-2">
|
||||
<div class="col-sm-3 justify-content-center">
|
||||
<label for="entregaTipo" class="form-label mt-2" th:text="#{presupuesto.entrega}">Entrega</label>
|
||||
<select class="form-select select2 datos-generales-data" id="entregaTipo" name="entregaTipo">
|
||||
<option selected value="peninsula" th:text="#{presupuesto.entrega.peninsula}">Península
|
||||
y
|
||||
Baleares</option>
|
||||
<option value="canarias" th:text="#{presupuesto.entrega.canarias}">Canarias</option>
|
||||
<option value="paises_ue" th:text="#{presupuesto.entrega.paises-ue}">Países UE</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-center gap-3 mt-3">
|
||||
|
||||
<button type="button" id="next-datos-generales" class="btn btn-secondary d-flex align-items-center ms-auto order-2 order-md-3">
|
||||
<button type="button" id="next-datos-generales"
|
||||
class="btn btn-secondary d-flex align-items-center ms-auto order-2 order-md-3">
|
||||
<span th:text="#{presupuesto.continuar-interior}">Continuar a diseño interior</span>
|
||||
<i class="ri-arrow-right-circle-line fs-16 ms-2"></i>
|
||||
</button>
|
||||
|
||||
@ -34,9 +34,13 @@
|
||||
<th colspan="4" class="text-end" th:text="#{presupuesto.resumen.tabla.base}">Total</th>
|
||||
<th class="text-end" id="resumen-base">0,00 €</th>
|
||||
</tr>
|
||||
<tr class="table-active">
|
||||
<th colspan="4" class="text-end" th:text="#{presupuesto.resumen.tabla.iva}">IVA (4%)</th>
|
||||
<th class="text-end" id="resumen-iva">0,00 €</th>
|
||||
<tr id="tr-resumen-iva4" class="table-active">
|
||||
<th colspan="4" class="text-end" th:text="#{presupuesto.resumen.tabla.iva4}">IVA (4%)</th>
|
||||
<th class="text-end" id="resumen-iva4">0,00 €</th>
|
||||
</tr>
|
||||
<tr id="tr-resumen-iva21" class="table-active">
|
||||
<th colspan="4" class="text-end" th:text="#{presupuesto.resumen.tabla.iva21}">IVA (21%)</th>
|
||||
<th class="text-end" id="resumen-iva21">0,00 €</th>
|
||||
</tr>
|
||||
<tr class="table-active">
|
||||
<th colspan="4" class="text-end" th:text="#{presupuesto.resumen.tabla.total}">Total con IVA</th>
|
||||
@ -55,19 +59,15 @@
|
||||
|
||||
<div th:replace="~{imprimelibros/presupuestos/presupuestador-items/_buttons :: buttons(${appMode})}"></div>
|
||||
|
||||
<div th:unless="${#authorization.expression('isAuthenticated()')}">
|
||||
<button id="btn-add-cart" type="button"
|
||||
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>
|
||||
<span th:text="#{presupuesto.resumen.inicie-sesion}">Inicie sesión para continuar</span>
|
||||
</button>
|
||||
</div>
|
||||
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
||||
<button id="btn-add-cart" type="button"
|
||||
class="btn btn-secondary d-flex align-items-center order-2 order-md-3">
|
||||
<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>
|
||||
<button th:unless="${#authorization.expression('isAuthenticated()')}" id="btn-add-cart" type="button"
|
||||
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>
|
||||
<span th:text="#{presupuesto.resumen.inicie-sesion}">Inicie sesión para continuar</span>
|
||||
</button>
|
||||
<button th:if="${#authorization.expression('isAuthenticated()')}" id="btn-add-cart" type="button"
|
||||
class="btn btn-secondary d-flex align-items-center order-2 order-md-3">
|
||||
<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>
|
||||
@ -15,6 +15,7 @@
|
||||
|
||||
<form action="#">
|
||||
<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">
|
||||
<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/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">
|
||||
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
||||
@ -61,6 +61,9 @@
|
||||
<div th:if="${appMode} == 'view'">
|
||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js}"></script>
|
||||
</div>
|
||||
<div th:if="${appMode} == 'edit'">
|
||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js}"></script>
|
||||
</div>
|
||||
<div th:if="${appMode} == 'add'">
|
||||
<div th:if="${mode} == 'public'">
|
||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-publicos-add.js}"></script>
|
||||
@ -69,6 +72,8 @@
|
||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js}"></script>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/presupuesto-maquetacion.js}"></script>
|
||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/presupuesto-marcapaginas.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
|
||||
@ -17,7 +17,58 @@
|
||||
<th scope="col" th:text="#{presupuesto.tabla.updated-at}">Actualizado el</th>
|
||||
<th scope="col" th:text="#{presupuesto.tabla.acciones}">Acciones</th>
|
||||
</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><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="pais" />
|
||||
</th>
|
||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="region" />
|
||||
</th>
|
||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="ciudad" />
|
||||
</th>
|
||||
<th></th> <!-- Actualizado el (sin filtro) -->
|
||||
<th></th> <!-- Acciones (sin filtro) -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
|
||||
@ -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>
|
||||
<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.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.cubierta}">Cubierta</th>
|
||||
<th scope="col" th:text="#{presupuesto.tabla.tipo-impresion}">Tipo de impresión</th>
|
||||
@ -17,9 +17,8 @@
|
||||
</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="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="user" />
|
||||
</th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm presupuesto-select-filter" data-col="tipoEncuadernacion">
|
||||
<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/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">
|
||||
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
||||
|
||||
<input type="hidden" id="cliente_id" th:value="${#authentication.principal.id}" />
|
||||
|
||||
|
||||
<div class="container-fluid">
|
||||
<nav aria-label="breadcrumb">
|
||||
@ -45,7 +47,8 @@
|
||||
</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">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#arrow-presupuestos-cliente" role="tab"
|
||||
aria-selected="true">
|
||||
@ -64,7 +67,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 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
|
||||
@ -79,6 +82,11 @@
|
||||
|
||||
</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>
|
||||
</th:block>
|
||||
@ -100,7 +108,8 @@
|
||||
<script th:src="@{/assets/libs/datatables/buttons.print.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>
|
||||
</body>
|
||||
|
||||
|
||||
133
src/test/java/com/imprimelibros/erp/pdf/PdfSmokeTest.java
Normal file
133
src/test/java/com/imprimelibros/erp/pdf/PdfSmokeTest.java
Normal file
@ -0,0 +1,133 @@
|
||||
// src/test/java/com/imprimelibros/erp/pdf/PdfSmokeTest.java
|
||||
package com.imprimelibros.erp.pdf;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.awt.Desktop;
|
||||
|
||||
|
||||
@SpringBootTest
|
||||
class PdfSmokeTest {
|
||||
|
||||
@Autowired
|
||||
PdfService pdfService;
|
||||
@Autowired
|
||||
PdfTemplateEngine templateEngine;
|
||||
@Autowired
|
||||
TemplateRegistry templateRegistry;
|
||||
|
||||
@Test
|
||||
void generaPresupuesto() throws Exception {
|
||||
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("numero", "2025-00123");
|
||||
model.put("fecha", LocalDate.of(2025, 10, 12));
|
||||
model.put("validezDias", 30);
|
||||
|
||||
model.put("empresa", Map.of(
|
||||
"nombre", "ImprimeLibros ERP",
|
||||
"direccion", "C/ Dirección 123, 28000 Madrid",
|
||||
"telefono", "+34 600 000 000",
|
||||
"email", "info@imprimelibros.com",
|
||||
"cif", "B-12345678",
|
||||
"cp", "28000",
|
||||
"poblacion", "Madrid",
|
||||
"web", "www.imprimelibros.com"));
|
||||
|
||||
model.put("cliente", Map.of(
|
||||
"nombre", "Editorial Ejemplo S.L.",
|
||||
"cif", "B-00000000",
|
||||
"direccion", "Av. de los Libros, 45",
|
||||
"cp", "28001",
|
||||
"poblacion", "Madrid",
|
||||
"provincia", "Madrid",
|
||||
"email", "compras@editorial.com"));
|
||||
|
||||
model.put("titulo", "Libro de Ejemplo");
|
||||
model.put("autor", "Autora Demo");
|
||||
model.put("isbn", "978-1-2345-6789-0");
|
||||
model.put("ancho", 150);
|
||||
model.put("alto", 210);
|
||||
model.put("formatoPersonalizado", true);
|
||||
model.put("paginasNegro", "300");
|
||||
model.put("paginasColor", null);
|
||||
model.put("tiradas", List.of(300, 500, 1000));
|
||||
|
||||
model.put("lineas", List.of(
|
||||
Map.of("descripcion", "Impresión interior B/N offset 80 g",
|
||||
"meta", "300 páginas · tinta negra · papel 80 g",
|
||||
"uds", 1000,
|
||||
"precio", 2.15,
|
||||
"dto", 0,
|
||||
"importe", 2150.0),
|
||||
Map.of("descripcion", "Cubierta color 300 g laminado mate",
|
||||
"meta", "Lomo 15 mm · 4/0 · laminado mate",
|
||||
"uds", 1000,
|
||||
"precio", 0.38,
|
||||
"dto", 5.0,
|
||||
"importe", 361.0)));
|
||||
|
||||
model.put("servicios", List.of(
|
||||
Map.of("descripcion", "Transporte península", "unidades", 1, "precio", 90.00)));
|
||||
|
||||
model.put("baseImponible", 2601.0);
|
||||
model.put("ivaTipo", 21);
|
||||
model.put("ivaImporte", 546.21);
|
||||
model.put("totalConIva", 3147.21);
|
||||
model.put("peso", 120.0);
|
||||
|
||||
model.put("observaciones", "Presupuesto válido 30 días.<br/>Incluye embalaje estándar.");
|
||||
model.put("condiciones", "Entrega 7-10 días laborables tras confirmación de artes finales.");
|
||||
|
||||
Map<String, Object> pricing = new HashMap<>();
|
||||
pricing.put("tiradas", List.of(100, 200, 300));
|
||||
pricing.put("impresion", List.of(152.15, 276.12, 377.36));
|
||||
pricing.put("iva", List.of(7.68, 12.60, 16.72));
|
||||
pricing.put("total", List.of(159.99, 287.91, 395.03));
|
||||
pricing.put("unidad", List.of(1.52, 1.38, 1.26));
|
||||
model.put("pricing", pricing);
|
||||
|
||||
var spec = new DocumentSpec(
|
||||
DocumentType.PRESUPUESTO,
|
||||
"presupuesto-a4",
|
||||
Locale.forLanguageTag("es-ES"),
|
||||
model);
|
||||
|
||||
byte[] pdf = pdfService.generate(spec);
|
||||
|
||||
// HTML
|
||||
|
||||
String templateName = templateRegistry.resolve(DocumentType.PRESUPUESTO, "presupuesto-a4");
|
||||
String html = templateEngine.render(templateName, Locale.forLanguageTag("es-ES"), model);
|
||||
String css = Files.readString(Path.of("src/main/resources/static/assets/css/presupuestopdf.css"));
|
||||
String htmlWithCss = html.replaceFirst(
|
||||
"(?i)</head>",
|
||||
"<style>\n" + css + "\n</style>\n</head>");
|
||||
Path htmlPath = Path.of("target/presupuesto-test.html");
|
||||
Files.writeString(htmlPath, htmlWithCss, StandardCharsets.UTF_8);
|
||||
|
||||
System.out.println("✅ HTML exportado: target/presupuesto-test.html");
|
||||
|
||||
// 🟢 Abrir en el navegador (si está soportado)
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().browse(htmlPath.toUri());
|
||||
}
|
||||
|
||||
Path out = Path.of("target/presupuesto-test.pdf");
|
||||
Files.createDirectories(out.getParent());
|
||||
Files.write(out, pdf);
|
||||
|
||||
System.out.println("✅ PDF generado en: " + out.toAbsolutePath());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user