Compare commits

...

10 Commits

59 changed files with 2217 additions and 613 deletions

12
pom.xml
View File

@ -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>

BIN
src.zip

Binary file not shown.

View File

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

View File

@ -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();
}
}

View File

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

View File

@ -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);
}

View File

@ -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);

View File

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

View File

@ -0,0 +1,5 @@
package com.imprimelibros.erp.pdf;
public enum DocumentType {
PRESUPUESTO, PEDIDO, FACTURA
}

View 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);
}
}

View File

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

View File

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

View File

@ -0,0 +1,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);
}
}

View File

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

View File

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

View File

@ -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) {

View File

@ -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>";
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -3,8 +3,8 @@ spring.application.name=erp
#
# Logging
#
logging.level.org.springframework.security=DEBUG
logging.level.root=WARN
logging.level.org.springframework.security=ERROR
logging.level.root=ERROR
logging.level.org.springframework=ERROR
@ -18,7 +18,7 @@ spring.datasource.password=om91irrDctd
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.show-sql=false
#
@ -33,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

View 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.

View File

@ -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> \

View File

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

View File

@ -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 {

View 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 (7085%) */
left: 155mm; /* desplaza a la derecha si quieres */
transform: translate(-50%, -50%) rotate(-15deg);
width: 60%; /* tamaño grande, ya no hay recorte por márgenes */
max-width: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -26,7 +26,7 @@
pageLength: 50,
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
dom: 'Bfrtip',
dom: 'BlrBtip',
buttons: {
dom: {
button: {

View File

@ -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();

View File

@ -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,

View File

@ -4,6 +4,6 @@ const app = new PresupuestoWizard({
mode: 'public',
readonly: true,
canSave: false,
useSessionCache: false,
useSessionCache: true,
});
app.init();

View File

@ -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));
}
/******************************

View File

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

View File

@ -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();

View File

@ -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

View File

@ -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í'"></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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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());
}
}