falta ordenar por paginas y revisar la busqueda de los selects

This commit is contained in:
2025-10-11 18:16:55 +02:00
parent a1359f37b0
commit 62dcff8869
11 changed files with 287 additions and 70 deletions

View File

@ -583,7 +583,9 @@ public class PresupuestoController {
"presupuesto.plantilla-cubierta",
"presupuesto.plantilla-cubierta-text",
"presupuesto.impresion-cubierta",
"presupuesto.impresion-cubierta-help");
"presupuesto.impresion-cubierta-help",
"presupuesto.exito.guardado",
"presupuesto.add.error.save.title");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
@ -592,10 +594,12 @@ 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);
}
model.addAttribute("mode", mode);
return "imprimelibros/presupuestos/presupuesto-form";
}
@ -617,15 +621,24 @@ public class PresupuestoController {
}
}
@GetMapping(value = "/datatable/anonimos", produces = "application/json")
@GetMapping(value = "/datatable/{tipo}", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatableAnonimos(
HttpServletRequest request, Authentication auth, Locale locale) {
public DataTablesResponse<Map<String, Object>> datatable(
HttpServletRequest request, Authentication auth, Locale locale,
@PathVariable("tipo") String tipo) {
DataTablesRequest dt = DataTablesParser.from(request);
return dtService.datatableAnonimos(dt, locale);
if ("anonimos".equals(tipo)) {
return dtService.datatablePublicos(dt, locale);
} else if ("clientes".equals(tipo)) {
return dtService.datatablePrivados(dt, locale);
} else {
throw new IllegalArgumentException("Tipo de datatable no válido");
}
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
@ -709,13 +722,26 @@ public class PresupuestoController {
}
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")) {
presupuesto.setUser(userRepo.findById(cliente_id).orElse(null));
presupuesto.setOrigen(Presupuesto.Origen.privado);
}
if (mode.equals("public")) {
var resumen = presupuestoService.getTextosResumen(presupuesto, serviciosList, locale);
presupuesto = presupuestoService.generateTotalizadores(presupuesto, serviciosList, resumen, locale);
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)));

View File

@ -1,5 +1,6 @@
package com.imprimelibros.erp.presupuesto;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.domain.*;
@ -29,33 +30,38 @@ public class PresupuestoDatatableService {
/* ---------- API pública ---------- */
public DataTablesResponse<Map<String, Object>> datatableAnonimos(DataTablesRequest dt, Locale locale) {
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 -> mapAnonimoRow(p, locale)) // 👈 mapper específico “anonimos”
.map(p -> mapPresupuestoPublico(p, locale))
.toList();
return new DataTablesResponse<>(dt.draw, repo.count(), page.getTotalElements(), rows);
}
public DataTablesResponse<Map<String, Object>> datatableNoAnonimos(DataTablesRequest dt, Locale locale) {
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 -> mapNoAnonimoRow(p, locale)) // 👈 otro mapper con más/otros campos
.map(p -> mapPresupuestoPrivado(p, locale)) // 👈 otro mapper con más/otros campos
.toList();
return new DataTablesResponse<>(dt.draw, repo.count(), page.getTotalElements(), rows);
@ -67,6 +73,24 @@ public class PresupuestoDatatableService {
return (dt.search != null && dt.search.value != null) ? dt.search.value.trim().toLowerCase() : "";
}
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<>();
@ -115,10 +139,16 @@ public class PresupuestoDatatableService {
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));
@ -132,6 +162,56 @@ public class PresupuestoDatatableService {
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>();
@ -139,26 +219,42 @@ public class PresupuestoDatatableService {
String prop = ob.getExpression().toString();
boolean asc = ob.isAscending();
if ("paginas".equals(prop)) {
var totalPag = cb.sum(cb.coalesce(root.get("paginasColor"), 0),
cb.coalesce(root.get("paginasNegro"), 0));
jpaOrders.add(asc ? cb.asc(totalPag) : cb.desc(totalPag));
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 {
jpaOrders.add(asc ? cb.asc(root.get(prop)) : cb.desc(root.get(prop)));
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);
}
return ors.isEmpty() ? cb.conjunction() : cb.or(ors.toArray(new Predicate[0]));
// === 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> mapAnonimoRow(Presupuesto p, Locale locale) {
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());
@ -174,30 +270,55 @@ public class PresupuestoDatatableService {
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{
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>" +
"</div>");
"<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> mapNoAnonimoRow(Presupuesto p, Locale locale) {
Map<String, Object> m = mapAnonimoRow(p, locale); // base común
// añade/remueve campos específicos de “no anónimos”
// m.put("cliente", p.getCliente().getNombre()); // ejemplo
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;
}

View File

@ -822,23 +822,9 @@ public class PresupuestoService {
// 3) Enriquecer el Presupuesto a persistir
presupuesto.setEstado(Presupuesto.Estado.borrador);
if (mode.equals("public")) {
presupuesto.setOrigen(Presupuesto.Origen.publico);
presupuesto.setSessionId(sessionId);
// IP: guarda hash y trunc (si tienes campos). Si no, guarda tal cual en
// ip_trunc/ip_hash según tu modelo.
String ipTrunc = anonymizeIp(ip);
presupuesto.setIpTrunc(ipTrunc);
presupuesto.setIpHash(Integer.toHexString(ip.hashCode()));
// ubicación (si tienes un servicio GeoIP disponible; si no, omite estas tres
// líneas)
try {
GeoIpService.GeoData geo = geoIpService.lookup(ip).orElse(null);
presupuesto.setPais(geo.getPais());
presupuesto.setRegion(geo.getRegion());
presupuesto.setCiudad(geo.getCiudad());
} catch (Exception ignore) {
}
presupuesto = getDatosLocalizacion(presupuesto, sessionId, ip);
} else
presupuesto.setOrigen(Presupuesto.Origen.privado);
@ -878,6 +864,28 @@ public class PresupuestoService {
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) {
}
return presupuesto;
}
public Presupuesto generateTotalizadores(
Presupuesto presupuesto,
List<Map<String, Object>> servicios,

View File

@ -288,6 +288,7 @@ presupuesto.add.cancel=Cancelar
presupuesto.add.select-client=Seleccione cliente
presupuesto.add.error.options=Debe seleccionar una opción
presupuesto.add.error.options-client=Debe seleccionar un cliente
presupuesto.add.error.save.title=Error al guardar
presupuesto.error.save-internal-error=No se puede guardar: error interno.
presupuesto.exito.guardado=Presupuesto guardado con éxito.
presupuesto.exito.guardado-actualizado=Presupuesto actualizado con éxito.

View File

@ -0,0 +1,9 @@
import PresupuestoWizard from './wizard.js';
const app = new PresupuestoWizard({
mode: 'private',
readonly: false,
canSave: true,
useSessionCache: false,
});
app.init();

View File

@ -29,8 +29,8 @@ export default class PresupuestoWizard {
ancho: 148,
alto: 218,
formatoPersonalizado: false,
paginasNegro: 0,
paginasColor: 32,
paginasNegro: 32,
paginasColor: 0,
posicionPaginasColor: '',
tipoEncuadernacion: 'fresado',
},
@ -245,7 +245,7 @@ export default class PresupuestoWizard {
});
if (this.opts.canSave) {
$('#btn-guardar').on('click', async () => {
$('.guardar-presupuesto').on('click', async () => {
const alert = $('#form-errors');
const servicios = [];
@ -263,6 +263,7 @@ export default class PresupuestoWizard {
mode: this.opts.mode,
presupuesto: this.#getPresupuestoData(),
servicios: servicios,
cliente_id: $('#cliente_id').val() || null,
};
try {
alert.addClass('d-none').find('ul').empty();
@ -274,7 +275,7 @@ export default class PresupuestoWizard {
}).then((data) => {
Swal.fire({
icon: 'success',
title: window.languageBundle?.get('common.guardado') || 'Guardado',
title: window.languageBundle?.get('presupuesto.exito.guardado') || 'Guardado',
timer: 1800,
buttonsStyling: false,
customClass: {
@ -307,7 +308,7 @@ export default class PresupuestoWizard {
} catch (e) {
Swal.fire({
icon: 'error',
title: 'Error al guardar',
title: window.languageBundle?.get('presupuesto.add.error.save.title') || 'Error',
text: e?.message || '',
buttonsStyling: false,
customClass: {

View File

@ -138,9 +138,9 @@ import { preguntarTipoPresupuesto } from './presupuesto-utils.js';
if (!res) return;
if (res.tipo === 'anonimo') {
console.log('Crear presupuesto ANÓNIMO');
window.location.href = '/presupuesto/add/public';
} else {
console.log('Crear presupuesto de CLIENTE:', res.clienteId, res.clienteText);
window.location.href = '/presupuesto/add/private/' + res.clienteId;
}
});

View File

@ -3,7 +3,7 @@
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">
class="btn btn-success d-flex align-items-center guardar-presupuesto">
<i class="ri-save-3-line me-2"></i>
<span th:text="#{presupuesto.guardar}">Guardar</span>
</button>

View File

@ -132,7 +132,7 @@
<label for="paginas-negro" class="form-label" th:text="#{presupuesto.paginas-negro}">Páginas
Negro</label>
<input type="number" step="2" class="form-control paginas datos-generales-data"
id="paginas-negro" name="paginas-negro" value="0">
id="paginas-negro" name="paginas-negro" value="32">
<div class="form-text" th:text="#{presupuesto.siempre-pares}">
Siempre deben ser pares</div>
</div>
@ -145,7 +145,7 @@
<label for="paginas-color" class="form-label" th:text="#{presupuesto.paginas-color}">Páginas
Color</label>
<input type="number" step="2" class="form-control paginas datos-generales-data"
id="paginas-color" name="paginas-color" value="32">
id="paginas-color" name="paginas-color" value="0">
<div class="form-text" th:text="#{presupuesto.siempre-pares}">
Siempre deben ser pares</div>
</div>
@ -253,8 +253,6 @@
<div class="d-flex align-items-center justify-content-center gap-3 mt-3">
<div th:replace="~{imprimelibros/presupuestos/presupuestador-items/_buttons :: buttons(${appMode})}"></div>
<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>

View File

@ -28,9 +28,12 @@
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item"><a href="/presupuesto" th:text="#{presupuesto.title}"></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{presupuesto.editar.title}">
Editar presupuesto
</li>
<li class="breadcrumb-item active" aria-current="page" th:if="${appMode == 'add'}" th:text="#{presupuesto.add}">
Nuevo presupuesto
</li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{presupuesto.editar.title}" th:if="${appMode == 'edit'}">
Editar presupuesto
</li>
</ol>
</nav>
</div>
@ -59,7 +62,12 @@
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js}"></script>
</div>
<div th:if="${appMode} == 'add'">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-publicos-add.js}"></script>
<div th:if="${mode} == 'public'">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-publicos-add.js}"></script>
</div>
<div th:if="${mode} != 'public'">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js}"></script>
</div>
</div>
</th:block>
</body>

View File

@ -1,5 +1,5 @@
<div th:fragment="tabla-cliente">
<table id="presupuesto-clientes-datatable" class="table table-striped table-nowrap responsive w-100">
<table id="presupuestos-clientes-datatable" class="table table-striped table-nowrap responsive w-100">
<thead>
<tr>
<th scope="col" th:text="#{presupuesto.tabla.id}">ID</th>
@ -16,7 +16,52 @@
<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><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>
<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>