preparando el imprimir

This commit is contained in:
2025-10-12 21:42:04 +02:00
parent 6641c1f077
commit 26c2ca543a
41 changed files with 1325 additions and 208 deletions

View File

@ -85,3 +85,9 @@ geoip.http.enabled=true
# Hibernate Timezone
#
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
#
# PDF Templates
#
imprimelibros.pdf.templates.PRESUPUESTO\:presupuesto-a4=imprimelibros/pdf/presupuesto-a4
imprimelibros.pdf.templates.FACTURA\:factura-a4=imprimelibros/pdf/factura-a4

View File

@ -0,0 +1,225 @@
/* === Fuentes ===
* Para HTML→PDF con OpenHTMLtoPDF, es preferible registrar las TTF en el renderer.
* Aun así, incluimos @font-face por si renderizas en navegador.
* Coloca los TTF en /static/fonts/ y ajusta los nombres si procede.
*/
@font-face {
font-family: "Open Sans";
src: url("/assets/fonts/OpenSans-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Open Sans";
src: url("/assets/fonts/OpenSans-SemiBold.ttf") format("truetype");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Open Sans";
src: url("/assets/fonts/OpenSans-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
}
:root {
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--brand: #0ea5e9; /* azul suave */
--bg-light: #f8fafc; /* gris muy claro */
}
@page {
size: A4;
margin: 18mm 15mm 22mm 15mm;
@bottom-right {
content: "Página " counter(page) " / " counter(pages);
}
}
html, body {
font-family: "Open Sans", Arial, sans-serif;
color: var(--text);
font-size: 11pt;
line-height: 1.35;
}
* { box-sizing: border-box; }
.doc-header {
display: table;
width: 100%;
margin-bottom: 10mm;
border-bottom: 2px solid var(--brand);
padding-bottom: 6mm;
}
.brand {
display: table-cell;
vertical-align: top;
width: 60%;
}
.logo {
height: 34px;
margin-bottom: 6px;
}
.brand-meta { margin-top: 4px; }
.company-name {
font-weight: 700;
font-size: 12pt;
}
.company-meta {
color: var(--muted);
font-size: 9.5pt;
}
.doc-title {
display: table-cell;
vertical-align: top;
width: 40%;
text-align: right;
}
.title-main {
font-size: 18pt;
font-weight: 700;
letter-spacing: 0.5px;
}
.doc-info {
margin-top: 6px;
margin-left: auto;
font-size: 10pt;
border-collapse: collapse;
}
.doc-info td {
padding: 3px 0 3px 12px;
border-bottom: 1px solid var(--border);
}
.doc-info td:first-child {
color: var(--muted);
padding-left: 0;
}
.right { text-align: right; }
.blocks {
display: table;
width: 100%;
table-layout: fixed;
margin-bottom: 8mm;
}
.block {
display: table-cell;
width: 50%;
padding-right: 6mm;
vertical-align: top;
}
.block:last-child { padding-right: 0; }
.block-title {
font-weight: 600;
text-transform: uppercase;
font-size: 10pt;
color: var(--brand);
margin-bottom: 2mm;
}
.block-body {
border: 1px solid var(--border);
padding: 4mm;
background: var(--bg-light);
}
.row {
margin-bottom: 2mm;
display: block;
}
.row .label {
display: inline-block;
width: 27%;
color: var(--muted);
}
.row .value {
display: inline-block;
width: 70%;
vertical-align: top;
}
.table-section .section-title,
.notes .section-title {
font-weight: 600;
font-size: 10.5pt;
margin: 6mm 0 2mm 0;
}
.items {
width: 100%;
border-collapse: collapse;
font-size: 10.5pt;
}
.items thead th {
text-align: left;
border-bottom: 2px solid var(--brand);
padding: 6px 6px;
background: #eef8fe;
font-weight: 600;
}
.items tbody td {
border-bottom: 1px solid var(--border);
padding: 6px 6px;
vertical-align: top;
}
.items .col-right { text-align: right; }
.items .col-center { text-align: center; }
.items .col-desc .desc { font-weight: 600; }
.items .col-desc .meta { color: var(--muted); font-size: 9.5pt; margin-top: 2px; }
.items .group-header td {
background: var(--bg-light);
color: var(--brand);
font-weight: 600;
}
.totals {
margin-top: 6mm;
display: block;
width: 100%;
}
.totals-table {
margin-left: auto;
border-collapse: collapse;
min-width: 70mm;
}
.totals-table td {
padding: 4px 0 4px 14px;
border-bottom: 1px solid var(--border);
}
.totals-table td:first-child {
color: var(--muted);
padding-left: 0;
}
.totals-table .total-row td {
border-bottom: 2px solid var(--brand);
padding-top: 6px;
padding-bottom: 6px;
}
.totals-table .right { text-align: right; }
.notes .note-text {
border: 1px solid var(--border);
padding: 4mm;
background: #fff;
font-size: 10pt;
white-space: pre-wrap;
}
.doc-footer {
margin-top: 8mm;
padding-top: 4mm;
border-top: 1px solid var(--border);
display: table;
width: 100%;
color: var(--muted);
font-size: 9pt;
}
.footer-left { display: table-cell; }
.footer-right {
display: table-cell;
text-align: right;
}
/* Compatibilidad PDF (OpenHTMLtoPDF) */
.page-number::after { content: counter(page); }
.page-count::after { content: counter(pages); }

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

@ -180,6 +180,8 @@ export default class PresupuestoWizard {
async init() {
const self = this;
$.ajaxSetup({
beforeSend: function (xhr) {
const token = document.querySelector('meta[name="_csrf"]')?.content;
@ -190,7 +192,7 @@ export default class PresupuestoWizard {
const root = document.getElementById('presupuesto-app');
const mode = root?.dataset.mode || 'public';
const presupuestoId = root?.dataset.id || null;
const presupuestoId = this.opts.presupuestoId || null;
let stored = null;
if (this.opts.useSessionCache) {
@ -249,26 +251,19 @@ export default class PresupuestoWizard {
const alert = $('#form-errors');
const servicios = [];
$('.service-checkbox:checked').each(function () {
const $servicio = $(this);
servicios.push({
id: $servicio.attr('id') ?? $(`label[for="${$servicio.attr('id')}"] .service-title`).text().trim(),
label: $(`label[for="${$servicio.attr('id')}"] .service-title`).text().trim(),
units: $servicio.attr('id') === 'marcapaginas' ? self.formData.servicios.datosMarcapaginas.marcapaginas_tirada : 1,
price: $servicio.data('price') ?? $(`label[for="${$servicio.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), ''),
});
});
const payload = {
id: this.opts.presupuestoId,
mode: this.opts.mode,
presupuesto: this.#getPresupuestoData(),
servicios: servicios,
servicios: this.formData.servicios.servicios,
datosMaquetacion: this.formData.servicios.datosMaquetacion,
datosMarcapaginas: this.formData.servicios.datosMarcapaginas,
cliente_id: $('#cliente_id').val() || null,
};
try {
alert.addClass('d-none').find('ul').empty();
$.ajax({
url: '/presupuesto/save',
url: '/presupuesto/api/save',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload)
@ -341,7 +336,7 @@ export default class PresupuestoWizard {
...this.#getInteriorData(),
...this.#getCubiertaData(),
selectedTirada: this.formData.selectedTirada
};
@ -501,6 +496,18 @@ export default class PresupuestoWizard {
$('.alto-faja-max').text(`max: ${this.formData.datosGenerales.alto} mm`);
// check if selected tirada is still valid
const tiradas = [
parseInt(this.formData.datosGenerales.tirada1),
parseInt(this.formData.datosGenerales.tirada2) || 0,
parseInt(this.formData.datosGenerales.tirada3) || 0,
parseInt(this.formData.datosGenerales.tirada4) || 0,
].filter(t => t > 0).sort((a, b) => a - b);
if (!tiradas.includes(this.formData.selectedTirada)) {
this.formData.selectedTirada = tiradas[0];
}
this.#loadInteriorData(data);
const interiorData = this.#getInteriorData();
@ -1592,7 +1599,14 @@ export default class PresupuestoWizard {
const $target = $(e.currentTarget);
if ($target.prop('checked')) {
this.formData.servicios.servicios.push($target.val());
this.formData.servicios.servicios.push(
$target.val(),
{
id: $target.attr('id') ?? $(`label[for="${$target.attr('id')}"] .service-title`).text().trim(),
label: $(`label[for="${$target.attr('id')}"] .service-title`).text().trim(),
units: $target.attr('id') === 'marcapaginas' ? self.formData.servicios.datosMarcapaginas.marcapaginas_tirada : 1,
price: $target.data('price') ?? $(`label[for="${$target.attr('id')}"] .service-price`).text().trim().replace(" " + self.divExtras.data('currency'), ''),
});
} else {
const index = this.formData.servicios.servicios.indexOf($target.val());
if (index > -1) {
@ -1615,7 +1629,7 @@ export default class PresupuestoWizard {
this.divExtras.addClass('animate-fadeInUpBounce');
for (const extra of servicios) {
if (this.formData.servicios.servicios.includes(extra.id) && !extra.checked) {
if (this.formData.servicios.servicios.some(s => s.id === extra.id) && !extra.checked) {
extra.checked = true;
if (extra.id === "marcapaginas" || extra.id === "maquetacion") {
extra.price = extra.id === "marcapaginas" ?
@ -1626,11 +1640,6 @@ export default class PresupuestoWizard {
}
const item = new ServiceOptionCard(extra);
this.divExtras.append(item.render());
if (item.checked) {
if (!this.formData.servicios.servicios.includes(extra.id)) {
this.formData.servicios.servicios.push(extra.id);
}
}
}
this.#cacheFormData();

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,195 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="es">
<head>
<meta charset="UTF-8" />
<title th:text="'Presupuesto ' + ${numero}">Presupuesto</title>
<link rel="stylesheet" href="/assets/css/presupuesto.css" />
</head>
<body>
<!-- Header -->
<header class="doc-header">
<div class="brand">
<img src="/img/logo-light.png" alt="ImprimeLibros" class="logo" />
<div class="brand-meta">
<div class="company-name" th:text="${empresa?.nombre} ?: 'ImprimeLibros ERP'">ImprimeLibros ERP</div>
<div class="company-meta">
<span th:text="${empresa?.direccion} ?: 'C/ Dirección 123, 28000 Madrid'">C/ Dirección 123, 28000 Madrid</span><br/>
<span th:text="${empresa?.telefono} ?: '+34 600 000 000'">+34 600 000 000</span> ·
<span th:text="${empresa?.email} ?: 'info@imprimelibros.com'">info@imprimelibros.com</span><br/>
<span th:text="${empresa?.cif} ?: 'B-12345678'">B-12345678</span>
</div>
</div>
</div>
<div class="doc-title">
<div class="title-main">PRESUPUESTO</div>
<table class="doc-info">
<tr>
<td></td>
<td class="right" th:text="${numero}">2025-00123</td>
</tr>
<tr>
<td>Fecha</td>
<td class="right" th:text="${#temporals.format(fecha, 'dd/MM/yyyy')}">12/10/2025</td>
</tr>
<tr th:if="${validezDias != null}">
<td>Validez</td>
<td class="right" th:text="${validezDias} + ' días'">30 días</td>
</tr>
</table>
</div>
</header>
<!-- Datos cliente / proyecto -->
<section class="blocks">
<div class="block">
<div class="block-title">Cliente</div>
<div class="block-body">
<div class="row">
<span class="label">Nombre:</span>
<span class="value" th:text="${cliente?.nombre} ?: '-'">Editorial Ejemplo S.L.</span>
</div>
<div class="row">
<span class="label">CIF/NIF:</span>
<span class="value" th:text="${cliente?.cif} ?: '-'">B-00000000</span>
</div>
<div class="row">
<span class="label">Dirección:</span>
<span class="value" th:text="${cliente?.direccion} ?: '-'">Av. de los Libros, 45</span>
</div>
<div class="row" th:if="${cliente?.cp != null or cliente?.poblacion != null or cliente?.provincia != null}">
<span class="label">Localidad:</span>
<span class="value">
<span th:text="${cliente?.cp} ?: ''"></span>
<span th:text="${cliente?.poblacion} ?: ''"></span>
<span th:text="${cliente?.provincia} ?: ''"></span>
</span>
</div>
<div class="row" th:if="${cliente?.email != null}">
<span class="label">Email:</span>
<span class="value" th:text="${cliente?.email}">comercial@editorial.com</span>
</div>
</div>
</div>
<div class="block">
<div class="block-title">Proyecto</div>
<div class="block-body">
<div class="row" th:if="${titulo != null}">
<span class="label">Título:</span>
<span class="value" th:text="${titulo}">Libro de Ejemplo</span>
</div>
<div class="row" th:if="${autor != null}">
<span class="label">Autor:</span>
<span class="value" th:text="${autor}">Autor/a</span>
</div>
<div class="row" th:if="${isbn != null}">
<span class="label">ISBN:</span>
<span class="value" th:text="${isbn}">978-1-2345-6789-0</span>
</div>
<div class="row" th:if="${ancho != null and alto != null}">
<span class="label">Formato:</span>
<span class="value">
<span th:text="${ancho}">150</span> × <span th:text="${alto}">210</span> mm
<span th:if="${formatoPersonalizado == true}">(personalizado)</span>
</span>
</div>
<div class="row" th:if="${paginasNegro != null or paginasColor != null}">
<span class="label">Páginas:</span>
<span class="value">
<span th:if="${paginasNegro != null}" th:text="'B/N ' + ${paginasNegro}"></span>
<span th:if="${paginasColor != null}" th:text="' · Color ' + ${paginasColor}"></span>
</span>
</div>
<div class="row" th:if="${tiradas != null}">
<span class="label">Tiradas:</span>
<span class="value" th:text="${#strings.arrayJoin(tiradas, ', ')}">300, 500, 1000</span>
</div>
</div>
</div>
</section>
<!-- Conceptos principales -->
<section class="table-section">
<div class="section-title">Detalle del presupuesto</div>
<table class="items">
<thead>
<tr>
<th class="col-desc">Descripción</th>
<th class="col-center">Uds</th>
<th class="col-right">Precio unit.</th>
<th class="col-right">Dto.</th>
<th class="col-right">Importe</th>
</tr>
</thead>
<tbody>
<!-- Línea tipo: interior, cubierta, manipulado, etc. -->
<tr th:each="l : ${lineas}">
<td class="col-desc">
<div class="desc" th:text="${l.descripcion}">Impresión interior B/N 80 g</div>
<div class="meta" th:if="${l.meta != null}" th:text="${l.meta}">
300 páginas · offset 80 g · tinta negra
</div>
</td>
<td class="col-center" th:text="${l.uds}">1000</td>
<td class="col-right" th:text="${#numbers.formatDecimal(l.precio, 1, 'POINT', 2, 'COMMA')}">2,1500</td>
<td class="col-right" th:text="${l.dto != null ? #numbers.formatDecimal(l.dto, 1, 'POINT', 2, 'COMMA') + '%':'-'}">-</td>
<td class="col-right" th:text="${#numbers.formatDecimal(l.importe, 1, 'POINT', 2, 'COMMA')}">2.150,00</td>
</tr>
<!-- Servicios extra (si vienen) -->
<tr class="group-header" th:if="${servicios != null and !#lists.isEmpty(servicios)}">
<td colspan="5">Servicios adicionales</td>
</tr>
<tr th:each="s : ${servicios}">
<td class="col-desc">
<div class="desc" th:text="${s.descripcion}">Transporte</div>
</td>
<td class="col-center" th:text="${s.unidades}">1</td>
<td class="col-right" th:text="${#numbers.formatDecimal(s.precio, 1, 'POINT', 2, 'COMMA')}">90,00</td>
<td class="col-right">-</td>
<td class="col-right" th:text="${#numbers.formatDecimal(s.unidades * s.precio, 1, 'POINT', 2, 'COMMA')}">90,00</td>
</tr>
</tbody>
</table>
</section>
<!-- Totales -->
<section class="totals">
<table class="totals-table">
<tr>
<td>Base imponible</td>
<td class="right" th:text="${#numbers.formatDecimal(baseImponible, 1, 'POINT', 2, 'COMMA')}">2.180,00</td>
</tr>
<tr>
<td th:text="'IVA (' + ${ivaTipo} + '%)'">IVA (21%)</td>
<td class="right" th:text="${#numbers.formatDecimal(ivaImporte, 1, 'POINT', 2, 'COMMA')}">457,80</td>
</tr>
<tr class="total-row">
<td><strong>Total</strong></td>
<td class="right"><strong th:text="${#numbers.formatDecimal(totalConIva, 1, 'POINT', 2, 'COMMA')}">2.637,80</strong></td>
</tr>
<tr th:if="${peso != null}">
<td>Peso estimado</td>
<td class="right" th:text="${#numbers.formatDecimal(peso, 1, 'POINT', 2, 'COMMA')} + ' kg'">120,00 kg</td>
</tr>
</table>
</section>
<!-- Observaciones / condiciones -->
<section class="notes" th:if="${observaciones != null or condiciones != null}">
<div class="section-title">Observaciones</div>
<div class="note-text" th:utext="${observaciones}">Presupuesto válido 30 días.</div>
<div class="section-title" th:if="${condiciones != null}">Condiciones</div>
<div class="note-text" th:utext="${condiciones}">
Entrega estimada 7-10 días laborables tras confirmación de artes finales.
</div>
</section>
<!-- Footer -->
<footer class="doc-footer">
<div class="footer-left" th:text="${empresa?.web} ?: 'www.imprimelibros.com'">www.imprimelibros.com</div>
<div class="footer-right">Página <span class="page-number"></span> / <span class="page-count"></span></div>
</footer>
</body>
</html>

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"
<button th:if="${appMode == 'add' or appMode == 'edit'}" id="btn-guardar" type="button"
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>
<button id="btn-imprimir" type="button"
class="btn btn-primary d-flex align-items-center imprimir-presupuesto">
<i class="ri-printer-line me-2"></i>
<span th:text="#{app.imprimir}">Imprimir</span>
</button>
</div>

View File

@ -55,19 +55,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()')}">

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.fullName" />
</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>