terminando pdf de facturas

This commit is contained in:
2026-01-02 21:47:06 +01:00
parent bf823281a5
commit 6bea279066
30 changed files with 7112 additions and 6245 deletions

View File

@ -38,7 +38,12 @@ facturas.lineas.base=Base Imponible
facturas.lineas.iva_4=I.V.A. 4%
facturas.lineas.iva_21=I.V.A. 21%
facturas.lineas.total=Total
facturas.lineas.titulo=Líneas de la Factura
facturas.lineas.iva_4.help=Introduce el importe del I.V.A. (no el %).
facturas.lineas.iva_21.help=Introduce el importe del I.V.A. (no el %).
facturas.lineas.delete.title=¿Eliminar línea de factura?
facturas.lineas.delete.text=Esta acción no se puede deshacer.
facturas.lineas.error.base=La base imponible no es válida.
facturas.direccion.titulo=Dirección de Facturación
facturas.direccion.razon-social=Razón Social
@ -51,6 +56,29 @@ facturas.direccion.pais=País
facturas.direccion.telefono=Teléfono
facturas.pagos.titulo=Pago de factura
facturas.pagos.acciones=Acciones
facturas.pagos.acciones.agregar=Agregar pago
facturas.pagos.acciones.editar=Editar
facturas.pagos.acciones.eliminar=Eliminar
facturas.pagos.metodo=Método de pago
facturas.pagos.notas=Notas
facturas.pagos.cantidad=Cantidad pagada
facturas.pagos.fecha=Fecha de pago
facturas.pagos.tipo=Tipo de pago
facturas.pagos.tipo.tpv_tarjeta=TPV/Tarjeta
facturas.pagos.tipo.tpv_bizum=TPV/Bizum
facturas.pagos.tipo.transferencia=Transferencia
facturas.pagos.tipo.otros=Otros
facturas.pagos.total_pagado=Total pagado
facturas.pagos.delete.title=Eliminar pago
facturas.pagos.delete.text=Esta acción no se puede deshacer.
facturas.pagos.error.cantidad=La cantidad no es válida.
facturas.pagos.error.fecha=La fecha no es válida.
facturas.delete.title=¿Estás seguro de que deseas eliminar esta factura?
facturas.delete.text=Esta acción no se puede deshacer.
facturas.delete.ok.title=Factura eliminada

View File

@ -4,6 +4,8 @@ pdf.company.postalcode=28028
pdf.company.city=Madrid
pdf.company.phone=+34 910052574
pdf.page=Página
pdf.presupuesto=PRESUPUESTO
pdf.factura=FACTURA
pdf.pedido=PEDIDO
@ -29,6 +31,26 @@ pdf.datos-marcapaginas=Datos de marcapáginas:
pdf.incluye-envio=El presupuesto incluye el envío a una dirección de la península.
pdf.presupuesto-validez=Validez del presupuesto: 30 días desde la fecha de emisión.
# Factura
pdf.factura.number=FACTURA Nº:
pdf.factura.razon-social=RAZÓN SOCIAL:
pdf.factura.identificacion-fiscal=IDENTIFICACIÓN FISCAL:
pdf.factura.direccion=DIRECCIÓN:
pdf.factura.codigo-postal=CÓDIGO POSTAL:
pdf.factura.ciudad=CIUDAD:
pdf.factura.provincia=PROVINCIA:
pdf.factura.pais=PAÍS:
pdf.factura.lineas.descripcion=DESCRIPCIÓN
pdf.factura.lineas.base=BASE IMPONIBLE
pdf.factura.lineas.iva_4=IVA 4%
pdf.factura.lineas.iva_21=IVA 21%
pdf.factura.lineas.total=TOTAL
pdf.factura.total-base=TOTAL BASE IMPONIBLE
pdf.factura.total-iva_4=TOTAL IVA 4%
pdf.factura.total-iva_21=TOTAL IVA 21%
pdf.factura.total-general=TOTAL GENERAL
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

View File

@ -11998,19 +11998,19 @@ div.dtr-modal div.dtr-modal-close:hover {
bottom: 100%;
}
.flatpickr-calendar.arrowTop::before {
border-bottom-color: #687cfe;
border-bottom-color: #92b2a7;
}
.flatpickr-calendar.arrowTop::after {
border-bottom-color: #687cfe;
border-bottom-color: #92b2a7;
}
.flatpickr-calendar.arrowBottom::before, .flatpickr-calendar.arrowBottom::after {
top: 100%;
}
.flatpickr-calendar.arrowBottom::before {
border-top-color: #687cfe;
border-top-color: #92b2a7;
}
.flatpickr-calendar.arrowBottom::after {
border-top-color: #687cfe;
border-top-color: #92b2a7;
}
.flatpickr-calendar:focus {
outline: 0;
@ -12025,7 +12025,7 @@ div.dtr-modal div.dtr-modal-close:hover {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
background-color: #687cfe;
background-color: #92b2a7;
border-radius: 5px 5px 0px 0px;
}
.flatpickr-months .flatpickr-month {
@ -12297,7 +12297,7 @@ div.dtr-modal div.dtr-modal-close:hover {
}
.flatpickr-weekdays {
background-color: #687cfe;
background-color: #92b2a7;
text-align: center;
overflow: hidden;
width: 100%;
@ -12322,7 +12322,7 @@ div.dtr-modal div.dtr-modal-close:hover {
span.flatpickr-weekday {
cursor: default;
font-size: 90%;
background: #687cfe;
background: #92b2a7;
color: #fff;
line-height: 1;
margin: 0;
@ -12424,11 +12424,11 @@ span.flatpickr-weekday {
color: var(--vz-dark);
}
.flatpickr-day.selected, .flatpickr-day.startRange, .flatpickr-day.endRange, .flatpickr-day.selected.inRange, .flatpickr-day.startRange.inRange, .flatpickr-day.endRange.inRange, .flatpickr-day.selected:focus, .flatpickr-day.startRange:focus, .flatpickr-day.endRange:focus, .flatpickr-day.selected:hover, .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover, .flatpickr-day.selected.prevMonthDay, .flatpickr-day.startRange.prevMonthDay, .flatpickr-day.endRange.prevMonthDay, .flatpickr-day.selected.nextMonthDay, .flatpickr-day.startRange.nextMonthDay, .flatpickr-day.endRange.nextMonthDay {
background: #687cfe;
background: #92b2a7;
-webkit-box-shadow: none;
box-shadow: none;
color: #fff;
border-color: #687cfe;
border-color: #92b2a7;
}
.flatpickr-day.selected.startRange, .flatpickr-day.startRange.startRange, .flatpickr-day.endRange.startRange {
border-radius: 50px 0 0 50px;

View File

@ -0,0 +1,426 @@
: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;
/* Estos márgenes sustituyen a tu padding grande en .page-content */
margin: 15mm 14mm 50mm 14mm; /* bottom grande para el footer */
@bottom-center {
content: element(pdfFooter);
}
}
html,
body {
font-family: "Open Sans" !important;
color: var(--ink);
font-size: 11pt;
line-height: 1.35;
}
.page-content {
padding: 0;
box-sizing: border-box;
}
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;
font-size: 9pt;
}
.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;
}
/* Listas sin margen superior por defecto */
ul, ol {
margin-top: 0;
margin-bottom: 0rem; /* si quieres algo abajo */
padding-left: 1.25rem; /* sangría */
}
/* Párrafos con menos margen inferior */
p {
margin: 0 0 .5rem;
}
/* Si una lista va justo después de un texto o título, que no tenga hueco arriba */
p + ul, p + ol,
h1 + ul, h2 + ul, h3 + ul, h4 + ul, h5 + ul, h6 + ul,
div + ul, div + ol {
margin-top: 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: 3px;
border-bottom: 2px solid var(--accent);
background: #eef8fe;
font-weight: 500;
}
.prices tbody td {
border-bottom: 1px solid var(--line);
padding: 3px;
}
.prices .col-tirada {
width: 22%;
font-weight: 500;
}
/* Footer */
.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;
}
/* 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;
}
.items-table {
width: 100%;
border-color: #92b2a7 ;
border-collapse: collapse;
}
.items-table thead th {
background-color: #f3f6f9;
font-size: small;
}
.items-table tbody td {
font-size: small;
}
/* Saca el footer fuente fuera del papel (pero sigue existiendo para capturarlo) */
.pdf-footer-source {
position: absolute;
left: 0;
top: -200mm; /* cualquier valor grande negativo */
width: 100%;
}
/* El footer que se captura */
#pdf-footer {
position: running(pdfFooter);
}
/* Estilo del footer ya dentro del margin-box */
.footer {
border-top: 1px solid var(--line);
padding-top: 4mm;
padding-bottom: 6mm; /* aire para que no quede pegado abajo */
font-size: 7.5pt;
color: var(--muted);
background: transparent;
}
/* Numeración */
#pdf-footer .page-number {
margin-top: 2mm;
text-align: right;
font-size: 9pt;
}
#pdf-footer .page-number .pn::before {
content: " " counter(page) "/" counter(pages);
}

View File

@ -10,6 +10,7 @@ $(() => {
});
}
const $container = $('#factura-container');
const MIN_LOADER_TIME = 500; // ms (ajusta a gusto)
@ -36,6 +37,13 @@ $(() => {
return $container.data('factura-id');
}
function getFlatpickrLocale() {
const lang = (document.documentElement.lang || 'es').toLowerCase().split('-')[0]; // es-ES -> es
const l10ns = window.flatpickr?.l10ns;
return (l10ns && l10ns[lang]) ? l10ns[lang] : (l10ns?.default || null);
}
function reloadFacturaContainer() {
const id = getFacturaId();
if (!id) return $.Deferred().reject('No factura id').promise();
@ -88,6 +96,18 @@ $(() => {
});
}
$container.on('click', '#btn-imprimir-factura', function () {
const id = getFacturaId();
const url = `/api/pdf/factura/${id}?mode=download`;
const a = document.createElement('a');
a.href = url;
a.target = '_self'; // descarga en la misma pestaña
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
// Delegación (funciona aunque reemplacemos el contenido interno)
$container.on('click', '#btn-validar-factura', function () {
const id = getFacturaId();
@ -114,8 +134,33 @@ $(() => {
$container.on('click', '#btn-guardar-factura', function () {
const facturaId = getFacturaId();
const fechaEmisionStr = $('#facturaFechaEmision').val();
const fechaEmision = parseEsDateToIsoLocal(fechaEmisionStr);
const payload = {
cabecera: {
serieId: $('#facturaSerieId').val() || null,
clienteId: $('#facturaClienteId').val() || null,
fechaEmision: fechaEmision // ISO LocalDateTime (00:00:00)
},
direccionFacturacion: {
razonSocial: $('#dirRazonSocial').val() || '',
identificacionFiscal: $('#dirIdentificacionFiscal').val() || '',
direccion: $('#dirDireccion').val() || '',
cp: $('#dirCp').val() || '',
ciudad: $('#dirCiudad').val() || '',
provincia: $('#dirProvincia').val() || '',
paisKeyword: $('#dirPais').val() || '', // lo que tú guardas como keyword
telefono: $('#dirTelefono').val() || ''
}
};
postAndReload(`/facturas/${facturaId}/guardar`, payload);
});
function destroySelect2($root) {
$root.find('.js-select2-factura').each(function () {
const $el = $(this);
@ -163,21 +208,561 @@ $(() => {
});
}
// =========================================================
// MODIFICACIÓN: Modal + Quill para líneas de factura
// =========================================================
let lineaModalInstance = null;
let lineaQuill = null;
function getLineaModal() {
const el = document.getElementById('lineaFacturaModal');
if (!el) return null;
lineaModalInstance = bootstrap.Modal.getOrCreateInstance(el);
return lineaModalInstance;
}
function showLineaModal() {
const m = getLineaModal();
if (m) m.show();
}
function hideLineaModal() {
const m = getLineaModal();
if (m) m.hide();
}
function showLineaModalError(msg) {
const $err = $('#lineaFacturaModalError');
if (!msg) {
$err.addClass('d-none').text('');
return;
}
$err.removeClass('d-none').text(msg);
}
// Quill config (igual que en presupuestos, pero solo para este editor del modal)
function buildSnowConfig() {
return {
theme: 'snow',
modules: {
toolbar: [
[{ 'font': [] }, { 'size': [] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'script': 'super' }, { 'script': 'sub' }],
[{ 'header': [false, 1, 2, 3, 4, 5, 6] }, 'blockquote', 'code-block'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'indent': '-1' }, { 'indent': '+1' }],
['direction', { 'align': [] }]
]
}
};
}
function getLineaQuill() {
const el = document.getElementById('lineaFacturaDescripcionEditor');
if (!el) return null;
// Evita doble init y evita apuntar a DOM viejo tras reload
if (!lineaQuill) {
lineaQuill = new Quill(el, buildSnowConfig());
}
return lineaQuill;
}
function setLineaDescripcionHtml(html) {
const q = getLineaQuill();
if (!q) return;
q.clipboard.dangerouslyPasteHTML(html ?? '');
}
function getLineaDescripcionHtml() {
const q = getLineaQuill();
if (!q) return '';
return q.root.innerHTML ?? '';
}
function clearLineaDescripcion() {
const q = getLineaQuill();
if (!q) return;
q.setText('');
}
// Formato ES: coma decimal
const nfEs = new Intl.NumberFormat('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function parseEsNumber(str) {
if (str == null) return null;
const s = String(str).trim();
if (!s) return null;
const normalized = s.replace(/\./g, '').replace(',', '.');
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
}
function formatEsNumber(n) {
const num = Number(n);
if (!Number.isFinite(num)) return '';
return nfEs.format(num);
}
function attachEsDecimalHandlers(selector) {
// input: permitir dígitos y separador decimal, normalizar '.' a ','
$container.on('input', selector, function () {
let v = $(this).val() ?? '';
v = String(v).replace(/[^\d\.,]/g, '');
const parts = v.split(/[.,]/);
if (parts.length > 1) {
v = parts[0] + ',' + parts.slice(1).join('');
}
$(this).val(v);
});
// blur: formatear a 2 decimales
$container.on('blur', selector, function () {
const n = parseEsNumber($(this).val());
$(this).val(formatEsNumber(n ?? 0));
});
}
// (handlers una sola vez; usan delegación)
attachEsDecimalHandlers('#lineaFacturaBase');
attachEsDecimalHandlers('#lineaFacturaIva4');
attachEsDecimalHandlers('#lineaFacturaIva21');
function resetLineaModal() {
$('#lineaFacturaModalTitle').text('Nueva línea');
$('#lineaFacturaId').val('');
clearLineaDescripcion();
$('#lineaFacturaBase').val(formatEsNumber(0));
$('#lineaFacturaIva4').val(formatEsNumber(0));
$('#lineaFacturaIva21').val(formatEsNumber(0));
showLineaModalError(null);
}
function fillLineaModalForEdit({ id, descripcionHtml, base, iva4, iva21 }) {
$('#lineaFacturaModalTitle').text('Editar línea');
$('#lineaFacturaId').val(id ?? '');
setLineaDescripcionHtml(descripcionHtml ?? '');
$('#lineaFacturaBase').val(formatEsNumber(Number(base) ?? 0));
$('#lineaFacturaIva4').val(formatEsNumber(Number(iva4) ?? 0));
$('#lineaFacturaIva21').val(formatEsNumber(Number(iva21) ?? 0));
showLineaModalError(null);
}
// Abrir modal: crear
$container.on('click', '#btn-add-linea-factura', function () {
getLineaQuill(); // asegura init sobre DOM actual
resetLineaModal();
showLineaModal();
});
// Abrir modal: editar
$container.on('click', '.btn-edit-linea-factura', function () {
getLineaQuill(); // asegura init sobre DOM actual
const $btn = $(this);
const id = $btn.data('linea-id');
// Leemos HTML guardado en textarea hidden (por seguridad)
const descripcionHtml = $(`#linea-desc-${id}`).val() ?? '';
fillLineaModalForEdit({
id,
descripcionHtml,
base: $btn.data('base'),
iva4: $btn.data('iva4'),
iva21: $btn.data('iva21')
});
showLineaModal();
});
// Borrar línea
$container.on('click', '.btn-delete-linea-factura', function () {
const $btn = $(this);
const id = $btn.data('linea-id');
const facturaId = getFacturaId();
Swal.fire({
title: window.languageBundle.get(['facturas.lineas.delete.title']) || 'Eliminar línea',
html: window.languageBundle.get(['facturas.lineas.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(['app.eliminar']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
postAndReload(`/facturas/${facturaId}/lineas/${id}/delete`);
});
});
// Guardar (todavía sin endpoint; deja el payload preparado)
$container.on('click', '#btnGuardarLineaFactura', function () {
showLineaModalError(null);
const idLinea = $('#lineaFacturaId').val() || null;
const descripcionHtml = getLineaDescripcionHtml();
const base = parseEsNumber($('#lineaFacturaBase').val());
const iva4 = parseEsNumber($('#lineaFacturaIva4').val()) ?? 0;
const iva21 = parseEsNumber($('#lineaFacturaIva21').val()) ?? 0;
if (base == null) {
showLineaModalError(window.languageBundle['facturas.lineas.error.base'] || 'La base no es válida.');
return;
}
// Vacío real de Quill
const descTrim = (descripcionHtml ?? '').trim();
const isEmptyQuill = (descTrim === '' || descTrim === '<p><br></p>');
const descripcion = isEmptyQuill ? '' : descripcionHtml;
const payload = {
id: idLinea,
descripcion, // HTML
base,
iva4,
iva21
};
// Aquí conectaremos endpoints:
const facturaId = getFacturaId();
const url = idLinea
? `/facturas/${facturaId}/lineas/${idLinea}`
: `/facturas/${facturaId}/lineas`;
postAndReload(url, payload).done(() => hideLineaModal());
hideLineaModal();
});
// =========================================================
// FIN MODAL
// =========================================================
// =========================================================
// MODAL + Quill + Flatpickr para pagos de factura
// =========================================================
let pagoModalInstance = null;
let pagoQuill = null;
let pagoFlatpickr = null;
function getPagoModal() {
const el = document.getElementById('pagoFacturaModal');
if (!el) return null;
pagoModalInstance = bootstrap.Modal.getOrCreateInstance(el);
return pagoModalInstance;
}
function showPagoModal() {
const m = getPagoModal();
if (m) m.show();
}
function hidePagoModal() {
const m = getPagoModal();
if (m) m.hide();
}
function showPagoModalError(msg) {
const $err = $('#pagoFacturaModalError');
if (!msg) {
$err.addClass('d-none').text('');
return;
}
$err.removeClass('d-none').text(msg);
}
function getPagoQuill() {
const el = document.getElementById('pagoFacturaNotasEditor');
if (!el) return null;
// Evita doble init y evita apuntar a DOM viejo tras reload
if (!pagoQuill) {
pagoQuill = new Quill(el, buildSnowConfig());
}
return pagoQuill;
}
function setPagoNotasHtml(html) {
const q = getPagoQuill();
if (!q) return;
q.clipboard.dangerouslyPasteHTML(html ?? '');
}
function getPagoNotasHtml() {
const q = getPagoQuill();
if (!q) return '';
return q.root.innerHTML ?? '';
}
function clearPagoNotas() {
const q = getPagoQuill();
if (!q) return;
q.setText('');
}
function getPagoFlatpickr() {
const input = document.getElementById('pagoFacturaFecha');
if (!input) return null;
if (!pagoFlatpickr) {
pagoFlatpickr = flatpickr(input, {
enableTime: false,
dateFormat: "d/m/Y",
placeholder: "",
locale: getFlatpickrLocale()
});
}
return pagoFlatpickr;
}
// Convierte "dd/MM/yyyy HH:mm" => "yyyy-MM-ddTHH:mm:00" (LocalDateTime)
function parseEsDateTimeToIsoLocal(str) {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const parts = s.split(' ');
if (parts.length < 2) return null;
const [dmy, hm] = parts;
const [dd, mm, yyyy] = dmy.split('/').map(n => Number(n));
const [HH, MM] = hm.split(':').map(n => Number(n));
if (!dd || !mm || !yyyy || Number.isNaN(HH) || Number.isNaN(MM)) return null;
const pad2 = (n) => String(n).padStart(2, '0');
return `${String(yyyy).padStart(4, '0')}-${pad2(mm)}-${pad2(dd)}T${pad2(HH)}:${pad2(MM)}:00`;
}
function parseEsDateToIsoLocal(str) {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const [dd, mm, yyyy] = s.split('/').map(n => Number(n));
if (!dd || !mm || !yyyy) return null;
const pad2 = (n) => String(n).padStart(2, '0');
return `${String(yyyy).padStart(4, '0')}-${pad2(mm)}-${pad2(dd)}T00:00:00`;
}
function setPagoFechaFromDataAttr(fechaRaw) {
const fp = getPagoFlatpickr();
if (!fp) return;
if (!fechaRaw) { fp.clear(); return; }
// fechaRaw: "yyyy-MM-dd"
const [Y, M, D] = String(fechaRaw).split('-').map(Number);
if (!Y || !M || !D) return;
fp.setDate(new Date(Y, M - 1, D), true);
}
function resetPagoModal() {
$('#pagoFacturaModalTitle').text('Nuevo pago');
$('#pagoFacturaId').val('');
$('#pagoFacturaMetodo').val('tpv_tarjeta');
$('#pagoFacturaCantidad').val(formatEsNumber(0));
const fp = getPagoFlatpickr();
if (fp) fp.clear();
clearPagoNotas();
showPagoModalError(null);
}
function fillPagoModalForEdit({ id, metodo, cantidad, fechaRaw, notasHtml }) {
$('#pagoFacturaModalTitle').text('Editar pago');
$('#pagoFacturaId').val(id ?? '');
$('#pagoFacturaMetodo').val(metodo ?? 'tpv_tarjeta');
$('#pagoFacturaCantidad').val(formatEsNumber(Number(cantidad) ?? 0));
setPagoFechaFromDataAttr(fechaRaw);
setPagoNotasHtml(notasHtml ?? '');
showPagoModalError(null);
}
// Formato ES para cantidad (mismo handler que líneas)
attachEsDecimalHandlers('#pagoFacturaCantidad');
// Abrir modal: crear
$container.on('click', '#btn-add-pago-factura', function () {
getPagoQuill(); // init sobre DOM actual
getPagoFlatpickr(); // init sobre DOM actual
resetPagoModal();
showPagoModal();
});
// Abrir modal: editar
$container.on('click', '.btn-edit-pago-factura', function () {
getPagoQuill();
getPagoFlatpickr();
const $btn = $(this);
const id = $btn.data('pago-id');
const notasHtml = $(`#pago-notas-${id}`).val() ?? '';
fillPagoModalForEdit({
id,
metodo: $btn.data('metodo'),
cantidad: $btn.data('cantidad'),
fechaRaw: $btn.data('fecha'), // "yyyy-MM-dd HH:mm" desde Thymeleaf
notasHtml
});
showPagoModal();
});
// Borrar pago (Swal igual que líneas)
$container.on('click', '.btn-delete-pago-factura', function () {
const pagoId = $(this).data('pago-id');
const facturaId = getFacturaId();
Swal.fire({
title: window.languageBundle.get(['facturas.pagos.delete.title']) || 'Eliminar pago',
html: window.languageBundle.get(['facturas.pagos.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(['app.eliminar']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
postAndReload(`/facturas/${facturaId}/pagos/${pagoId}/delete`);
});
});
// Guardar pago
$container.on('click', '#btnGuardarPagoFactura', function () {
showPagoModalError(null);
const facturaId = getFacturaId();
const pagoId = $('#pagoFacturaId').val() || null;
const metodoPago = $('#pagoFacturaMetodo').val();
const cantidad = parseEsNumber($('#pagoFacturaCantidad').val());
const fechaStr = $('#pagoFacturaFecha').val();
if (cantidad == null || cantidad <= 0) {
showPagoModalError(window.languageBundle.get(['facturas.pagos.error.cantidad']) || 'La cantidad no es válida.');
return;
}
const fechaPago = parseEsDateToIsoLocal($('#pagoFacturaFecha').val());
if (!fechaPago) {
showPagoModalError(window.languageBundle.get(['facturas.pagos.error.fecha']) || 'La fecha no es válida.');
return;
}
const notasHtml = getPagoNotasHtml();
const notasTrim = (notasHtml ?? '').trim();
const isEmptyQuill = (notasTrim === '' || notasTrim === '<p><br></p>');
const notas = isEmptyQuill ? '' : notasHtml;
const payload = {
id: pagoId,
metodoPago,
cantidadPagada: cantidad,
fechaPago,
notas
};
const url = pagoId
? `/facturas/${facturaId}/pagos/${pagoId}`
: `/facturas/${facturaId}/pagos`;
postAndReload(url, payload).done(() => hidePagoModal());
});
// =========================================================
let facturaFechaEmisionFp = null;
function getFacturaFechaEmisionFlatpickr() {
const input = document.getElementById('facturaFechaEmision');
if (!input) return null;
if (!facturaFechaEmisionFp) {
facturaFechaEmisionFp = flatpickr(input, {
enableTime: false,
dateFormat: "d/m/Y",
locale: getFlatpickrLocale()
});
}
return facturaFechaEmisionFp;
}
// dd/MM/yyyy -> yyyy-MM-ddT00:00:00
function parseEsDateToIsoLocal(str) {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const [dd, mm, yyyy] = s.split('/').map(n => Number(n));
if (!dd || !mm || !yyyy) return null;
const pad2 = (n) => String(n).padStart(2, '0');
return `${String(yyyy).padStart(4, '0')}-${pad2(mm)}-${pad2(dd)}T00:00:00`;
}
function afterFacturaRender() {
const $root = $('#factura-container');
// el data-factura-estado viene del fragmento factura-container
const estado = $root.find('[data-factura-estado]').first().data('factura-estado');
// siempre limpia antes para evitar restos si vienes de borrador->validada
destroySelect2($root);
if (estado === 'borrador') {
initSelect2ForDraft($root);
// ✅ Fecha emisión editable con flatpickr solo en borrador
facturaFechaEmisionFp = null; // reset por si cambió el DOM
getFacturaFechaEmisionFlatpickr(); // init sobre DOM actual
} else {
facturaFechaEmisionFp = null;
}
// resets que ya tenías
lineaQuill = null;
lineaModalInstance = null;
// si tienes pagos: resetea también...
pagoQuill = null;
pagoModalInstance = null;
pagoFlatpickr = null;
}
afterFacturaRender();
});

View File

@ -4,7 +4,6 @@
const scripts = [
"/assets/libs/toastify-js/src/toastify.js",
"/assets/libs/choices.js/public/assets/scripts/choices.min.js",
"/assets/libs/flatpickr/flatpickr.min.js",
"/assets/libs/feather-icons/feather.min.js" // <- AÑADIMOS feather aquí
];

View File

@ -9,6 +9,8 @@
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
<link sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
th:href="@{/assets/libs/quill/quill.snow.css}" rel="stylesheet" type="text/css" />
</th:block>
</head>
@ -68,6 +70,9 @@
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
<script sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
th:src="@{/assets/libs/quill/quill.min.js}"></script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/facturas/view.js}"></script>
</th:block>

View File

@ -15,17 +15,17 @@
<div class="row g-3">
<!-- Número -->
<!-- Número (solo lectura siempre, normalmente) -->
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.form.numero-factura}">Número</label>
<input type="text" class="form-control" th:value="${factura.numeroFactura}"
th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<label class="form-label" th:text="#{facturas.form.numero-factura}">Número de factura</label>
<input id="facturaNumero" type="text" class="form-control" th:value="${factura.numeroFactura}" readonly>
</div>
<!-- Serie -->
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.form.serie}">Serie facturación</label>
<select class="form-control js-select2-factura" data-url="/configuracion/series-facturacion/api/get-series" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<label class="form-label" th:text="#{facturas.form.serie}">Serie</label>
<select id="facturaSerieId" class="form-control js-select2-factura"
data-url="/configuracion/series-facturacion/api/get-series" th:attr="disabled=${isReadonly}">
<option th:value="${factura.serie != null ? factura.serie.id : ''}"
th:text="${factura.serie != null ? factura.serie.nombreSerie : ''}" selected>
</option>
@ -35,19 +35,20 @@
<!-- Cliente -->
<div class="col-md-6">
<label class="form-label" th:text="#{facturas.form.cliente}">Cliente</label>
<select class="form-control js-select2-factura" data-url="/users/api/get-users" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<select id="facturaClienteId" class="form-control js-select2-factura" data-url="/users/api/get-users"
th:attr="disabled=${isReadonly}">
<option th:value="${factura.cliente != null ? factura.cliente.id : ''}"
th:text="${factura.cliente != null ? factura.cliente.fullName : ''}" selected>
</option>
</select>
</div>
<!-- Fecha emisión -->
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.form.fecha-emision}">Fecha</label>
<input type="text" class="form-control" th:value="${factura.fechaEmision != null
? #temporals.format(factura.fechaEmision, 'dd/MM/yyyy')
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<input id="facturaFechaEmision" type="text" class="form-control"
th:value="${factura.fechaEmision != null ? #temporals.format(factura.fechaEmision, 'dd/MM/yyyy') : ''}"
th:attr="readonly=${isReadonly}, data-estado=${factura.estado.name()}">
</div>
<!-- Notas -->
@ -67,7 +68,7 @@
<div class="col-md-6">
<label class="form-label" th:text="#{facturas.direccion.razon-social}">Razón Social</label>
<input type="text" class="form-control" th:value="${direccionFacturacion != null
<input type="text" id="dirRazonSocial" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.razonSocial
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
@ -75,7 +76,7 @@
<div class="col-md-6">
<label class="form-label" th:text="#{facturas.direccion.identificacion-fiscal}">Identificacion
Fiscal</label>
<input type="text" class="form-control" th:value="${direccionFacturacion != null
<input type="text" id="dirIdentificacionFiscal" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.identificacionFiscal
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
@ -83,39 +84,39 @@
<div class="col-md-9">
<label class="form-label" th:text="#{facturas.direccion.direccion}">Dirección</label>
<input type="text" class="form-control" th:value="${direccionFacturacion != null
<input type="text" id="dirDireccion" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.direccion
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.codigo-postal}">Código Postal</label>
<input type="text" class="form-control" th:value="${direccionFacturacion != null
<input type="text" id="dirCp" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.cp
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.ciudad}">Ciudad</label>
<input type="text" class="form-control" th:value="${direccionFacturacion != null
<input type="text" id="dirCiudad" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.ciudad
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.provincia}">Provincia</label>
<input type="text" class="form-control" th:value="${direccionFacturacion != null
<input type="text" id="dirProvincia" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.provincia
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.pais}">País</label>
<select class="form-control js-select2-factura" data-url="/api/paises" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<select id="dirPais" class="form-control js-select2-factura" data-url="/api/paises"
th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<option th:value="${direccionFacturacion != null
? direccionFacturacion.pais.keyword
: ''}"
th:text="${direccionFacturacion != null
? direccionFacturacion.pais.code3
: ''}" th:text="${direccionFacturacion != null
? #messages.msg('paises.' + direccionFacturacion.pais.keyword)
: ''}" selected>
</option>
@ -124,7 +125,7 @@
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.telefono}">Teléfono</label>
<input type="text" class="form-control" th:value="${direccionFacturacion != null
<input type="text" id="dirTelefono" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.telefono
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
@ -133,18 +134,18 @@
<div class="row g-3 mt-4 justify-content-end">
<div class="col-md-12 text-end">
<th:block th:if="${factura.estado.name() == 'borrador'}">
<button type="button" class="btn btn-secondary me-2" id="btn-validar-factura"
th:text="#{facturas.form.btn.validar}">Validar factura</button>
<button type="button" class="btn btn-secondary me-2" id="btn-guardar-factura"
th:text="#{facturas.form.btn.guardar}">Guardar</button>
</th:block>
<th:block th:if="${factura.estado.name() == 'validada'}">
<button type="button" class="btn btn-secondary me-2" id="btn-borrador-factura"
th:text="#{facturas.form.btn.borrador}">Pasar a borrador</button>
</th:block>
<button type="button" class="btn btn-secondary me-2" id="btn-imprimir-factura"
th:text="#{facturas.form.btn.imprimir}">Imprimir factura</button>
<th:block th:if="${factura.estado.name() == 'borrador'}">
<button type="button" class="btn btn-secondary me-2" id="btn-validar-factura"
th:text="#{facturas.form.btn.validar}">Validar factura</button>
<button type="button" class="btn btn-secondary me-2" id="btn-guardar-factura"
th:text="#{facturas.form.btn.guardar}">Guardar</button>
</th:block>
<th:block th:if="${factura.estado.name() == 'validada'}">
<button type="button" class="btn btn-secondary me-2" id="btn-borrador-factura"
th:text="#{facturas.form.btn.borrador}">Pasar a borrador</button>
</th:block>
<button type="button" class="btn btn-secondary me-2" id="btn-imprimir-factura"
th:text="#{facturas.form.btn.imprimir}">Imprimir factura</button>
</div>
</div>
</th:block>

View File

@ -47,7 +47,7 @@
<div id="pagos" class="accordion-collapse collapse show" aria-labelledby="pagosHeader"
data-bs-parent="#pagosFactura">
<div class="accordion-body">
<!-- pagos -->
<div th:replace="~{imprimelibros/facturas/partials/factura-pagos :: factura-pagos (factura=${factura})}"></div>
</div>
</div>
</div>

View File

@ -7,10 +7,11 @@
</button>
</div>
</th:block>
<table class="table table-bordered table-striped table-nowrap w-100">
<table class="table table-bordered table-striped table-wrap w-100">
<thead>
<tr>
<th th:if="${factura.estado != null && factura.estado.name() == 'borrador'}" th:text="#{facturas.lineas.acciones}">Acciones</th>
<th th:if="${factura.estado != null && factura.estado.name() == 'borrador'}"
th:text="#{facturas.lineas.acciones}">Acciones</th>
<th class="w-75" th:text="#{facturas.lineas.descripcion}">Descripción</th>
<th th:text="#{facturas.lineas.base}">Base</th>
<th th:text="#{facturas.lineas.iva_4}">I.V.A. 4%</th>
@ -21,16 +22,26 @@
<tbody>
<tr th:each="lineaFactura : ${factura.lineas}">
<td th:if="${factura.estado != null && factura.estado.name() == 'borrador'}">
<button type="button" class="btn btn-secondary btn-sm me-2"
th:attr="data-linea-id=${lineaFactura.id}" th:text="#{facturas.lineas.acciones.editar}">
<i class="fas fa-edit"></i>
<button type="button" class="btn btn-secondary btn-sm me-2 btn-edit-linea-factura" th:attr="
data-linea-id=${lineaFactura.id},
data-base=${lineaFactura.baseLinea},
data-iva4=${lineaFactura.iva4Linea},
data-iva21=${lineaFactura.iva21Linea}
">
<i class="fas fa-edit me-1"></i>
<span th:text="#{facturas.lineas.acciones.editar}">Editar</span>
</button>
<button type="button" class="btn btn-danger btn-sm"
th:attr="data-linea-id=${lineaFactura.id}" th:text="#{facturas.lineas.acciones.eliminar}">
<button type="button" class="btn btn-danger btn-sm btn-delete-linea-factura" th:attr="data-linea-id=${lineaFactura.id}"
th:text="#{facturas.lineas.acciones.eliminar}">
<i class="fas fa-trash-alt"></i>
</button>
<!-- IMPORTANTE: guardamos el HTML aquí (no en data-*) -->
<textarea class="d-none" th:attr="id=${'linea-desc-' + lineaFactura.id}"
th:text="${lineaFactura.descripcion}"></textarea>
</td>
<td th:utext="${lineaFactura.descripcion}">Descripción de la línea</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.baseLinea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.iva4Linea)}">0.00</td>
@ -40,20 +51,33 @@
</tbody>
<tfoot>
<tr>
<td class="text-end fw-bold" th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}" th:text="#{facturas.lineas.base}">Base</td>
<td class="text-end fw-bold"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.base}">Base</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.baseImponible)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold" th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}" th:text="#{facturas.lineas.iva_4}">I.V.A. 4%</td>
<td class="text-end fw-bold"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.iva_4}">I.V.A. 4%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva4)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold" th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}" th:text="#{facturas.lineas.iva_21}">I.V.A. 21%</td>
<td class="text-end fw-bold"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.iva_21}">I.V.A. 21%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva21)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold text-uppercase" th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}" th:text="#{facturas.lineas.total}">Total</td>
<td class="text-end fw-bold" colspan="1" th:text="${#numbers.formatCurrency(factura.totalFactura)}">0.00</td>
<td class="text-end fw-bold text-uppercase"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.total}">Total</td>
<td class="text-end fw-bold" colspan="1" th:text="${#numbers.formatCurrency(factura.totalFactura)}">0.00
</td>
</tr>
</table>
<!-- Modal líneas factura (crear/editar) -->
<th:block th:replace="~{imprimelibros/facturas/partials/linea-modal :: linea-modal}"></th:block>
</div>

View File

@ -0,0 +1,69 @@
<!-- imprimelibros/facturas/partials/factura-pagos.html -->
<div th:fragment="factura-pagos (factura)">
<div class="mb-3">
<button type="button" class="btn btn-secondary" id="btn-add-pago-factura">
<i class="fas fa-plus-circle me-2"></i>
<span th:text="#{facturas.pagos.acciones.agregar}">Agregar pago</span>
</button>
</div>
<table class="table table-bordered table-striped table-wrap w-100">
<thead>
<tr>
<th th:text="#{facturas.pagos.acciones}">Acciones</th>
<th th:text="#{facturas.pagos.metodo}">Método</th>
<th th:text="#{facturas.pagos.fecha}">Fecha</th>
<th class="text-center w-50" th:text="#{facturas.pagos.notas}">Notas</th>
<th class="text-end" th:text="#{facturas.pagos.cantidad}">Cantidad</th>
</tr>
</thead>
<tbody>
<tr th:each="pago : ${factura.pagos}" th:if="${pago.deletedAt == null}">
<td>
<button type="button" class="btn btn-secondary btn-sm me-2 btn-edit-pago-factura" th:attr="
data-pago-id=${pago.id},
data-metodo=${pago.metodoPago},
data-cantidad=${pago.cantidadPagada},
data-fecha=${#temporals.format(pago.fechaPago,'yyyy-MM-dd')}">
<i class="fas fa-edit me-1"></i>
<span th:text="#{facturas.pagos.acciones.editar}">Editar</span>
</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-pago-factura"
th:attr="data-pago-id=${pago.id}">
<i class="fas fa-trash-alt"></i>
<span th:text="#{facturas.pagos.acciones.eliminar}">Eliminar</span>
</button>
<!-- notas en HTML (igual que líneas: guardadas en textarea oculto) -->
<textarea class="d-none" th:attr="id=${'pago-notas-' + pago.id}" th:text="${pago.notas}"></textarea>
</td>
<td th:text="${#messages.msg('facturas.pagos.tipo.' + pago.metodoPago.name().toLowerCase())}">
TPV/Tarjeta
</td>
<!-- Formato visual dd/MM/yyyy -->
<td th:text="${#temporals.format(pago.fechaPago,'dd/MM/yyyy')}">01/01/2026 10:00</td>
<td class="text-muted">
<span th:if="${pago.notas == null || #strings.isEmpty(pago.notas)}"></span>
<span th:if="${pago.notas != null && !#strings.isEmpty(pago.notas)}"
th:utext="${pago.notas}"></span>
</td>
<td class="text-end" th:text="${#numbers.formatCurrency(pago.cantidadPagada)}">0,00 €</td>
</tr>
</tbody>
<tfoot>
<tr>
<th colspan="4" class="text-end" th:text="#{facturas.pagos.total_pagado}">Total pagado</th>
<th class="text-end" th:text="${#numbers.formatCurrency(factura.totalPagado)}">0,00 €</th>
</tr>
</tfoot>
</table>
<!-- Modal pagos (crear/editar) -->
<th:block th:replace="~{imprimelibros/facturas/partials/pago-modal :: pago-modal}"></th:block>
</div>

View File

@ -0,0 +1,79 @@
<!-- imprimelibros/facturas/partials/linea-modal.html -->
<div th:fragment="linea-modal">
<div class="modal fade" id="lineaFacturaModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="lineaFacturaModalTitle" th:text="#{facturas.lineas.titulo}">Línea de factura</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<div class="modal-body">
<!-- hidden: id de la línea (vacío = nueva) -->
<input type="hidden" id="lineaFacturaId" value=""/>
<!-- Descripción con Quill -->
<div class="mb-3">
<label class="form-label" th:text="#{facturas.lineas.descripcion}">Descripción</label>
<!-- Quill Snow Editor -->
<div id="lineaFacturaDescripcionEditor"
class="snow-editor" style="min-height: 200px;"
data-contenido=""></div>
</div>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="lineaFacturaBase" class="form-label"
th:text="#{facturas.lineas.base}">Base</label>
<input type="text"
class="form-control text-end"
id="lineaFacturaBase"
inputmode="decimal"
autocomplete="off"
placeholder="0,00">
</div>
<div class="col-12 col-md-4">
<label for="lineaFacturaIva4" class="form-label"
th:text="#{facturas.lineas.iva_4}">I.V.A. 4%</label>
<input type="text"
class="form-control text-end"
id="lineaFacturaIva4"
inputmode="decimal"
autocomplete="off"
placeholder="0,00">
<div class="form-text" th:text="#{facturas.lineas.iva_4.help}">Introduce el importe del I.V.A. (no el %).</div>
</div>
<div class="col-12 col-md-4">
<label for="lineaFacturaIva21" class="form-label"
th:text="#{facturas.lineas.iva_21}">I.V.A. 21%</label>
<input type="text"
class="form-control text-end"
id="lineaFacturaIva21"
inputmode="decimal"
autocomplete="off"
placeholder="0,00">
<div class="form-text" th:text="#{facturas.lineas.iva_21.help}">Introduce el importe del I.V.A. (no el %).</div>
</div>
</div>
<!-- zona errores -->
<div class="alert alert-danger d-none mt-3" id="lineaFacturaModalError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal" th:text="#{app.cancelar}">Cancelar</button>
<button type="button" class="btn btn-secondary" id="btnGuardarLineaFactura" th:text="#{app.guardar}">
Guardar
</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,66 @@
<!-- imprimelibros/facturas/partials/pago-modal.html -->
<div th:fragment="pago-modal">
<div class="modal fade" id="pagoFacturaModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pagoFacturaModalTitle" th:text="#{facturas.pagos.titulo}">
Pago de factura
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<div class="modal-body">
<input type="hidden" id="pagoFacturaId" value="" />
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="pagoFacturaMetodo" class="form-label"
th:text="#{facturas.pagos.tipo}">Tipo de pago</label>
<select class="form-select" id="pagoFacturaMetodo">
<option value="tpv_tarjeta" th:text="#{facturas.pagos.tipo.tpv_tarjeta}">TPV/Tarjeta
</option>
<option value="tpv_bizum" th:text="#{facturas.pagos.tipo.tpv_bizum}">TPV/Bizum</option>
<option value="transferencia" th:text="#{facturas.pagos.tipo.transferencia}">
Transferencia</option>
<option value="otros" th:text="#{facturas.pagos.tipo.otros}">Otros</option>
</select>
</div>
<div class="col-12 col-md-4">
<label for="pagoFacturaCantidad" class="form-label"
th:text="#{facturas.pagos.cantidad}">Cantidad</label>
<input type="text" class="form-control text-end" id="pagoFacturaCantidad"
inputmode="decimal" autocomplete="off" placeholder="0,00">
</div>
<div class="col-12 col-md-4">
<label for="pagoFacturaFecha" class="form-label" th:text="#{facturas.pagos.fecha}">Fecha de
pago</label>
<input type="text" class="form-control" id="pagoFacturaFecha" autocomplete="off"
placeholder="dd/mm/aaaa hh:mm">
</div>
</div>
<div class="mt-3">
<label class="form-label" th:text="#{facturas.pagos.notas}">Notas</label>
<div id="pagoFacturaNotasEditor" class="snow-editor" style="min-height: 180px;"
data-contenido=""></div>
</div>
<div class="alert alert-danger d-none mt-3" id="pagoFacturaModalError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal"
th:text="#{app.cancelar}">Cancelar</button>
<button type="button" class="btn btn-secondary" id="btnGuardarPagoFactura" th:text="#{app.guardar}">
Guardar
</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -14,6 +14,7 @@
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<link href="/assets/libs/sweetalert2/sweetalert2.min.css" rel="stylesheet" type="text/css" />
<link href="/assets/libs/select2/select2.min.css" rel="stylesheet" />
<link rel="stylesheet" th:href="@{/assets/libs/flatpickr/flatpickr.min.css}">
<th:block layout:fragment="pagecss" />
</head>
@ -38,6 +39,13 @@
<script src="/assets/libs/jquery/jquery-3.6.0.min.js"></script>
<script src="/assets/libs/sweetalert2/sweetalert2.min.js"></script>
<script src="/assets/libs/select2/select2.min.js"></script>
<script defer th:src="@{/assets/libs/flatpickr/flatpickr.min.js}"></script>
<th:block th:with="fpLang=${#locale.language}">
<script defer th:src="@{'/assets/libs/flatpickr/l10n/' + ${fpLang} + '.js'}"
onerror="console.error('No se pudo cargar flatpickr locale:', this.src)">
</script>
</th:block>
<th:block layout:fragment="pagejs" />
<script th:src="@{/assets/js/app.js}"></script>
<script th:src="@{/assets/js/pages/imprimelibros/languageBundle.js}"></script>

View File

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="es">
<head>
<meta charset="UTF-8" />
<title th:text="'Factura ' + ${factura.numeroFactura}">Factura</title>
<link rel="stylesheet" href="assets/css/bootstrap-for-pdf.css" />
<link rel="stylesheet" href="assets/css/facturapdf.css" />
</head>
<body class="has-watermark">
<div class="watermark">
<img src="assets/images/logo-watermark.png" alt="Marca de agua" />
</div>
<!-- PIE -->
<div class="pdf-footer-source">
<div class="footer" id="pdf-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 class="page-number">
<span th:text="#{pdf.page} ?: 'Página'">Página</span>
<span class="pn"></span>
</div>
</div>
</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.factura} ?: 'FACTURA'" class="banner-text">FACTURA</div>
</div>
<!-- FICHA Nº / CLIENTE / FECHA -->
<table class="sheet-info">
<tr>
<td class="text-start w-50"><span th:text="#{'pdf.factura.number'}" class="lbl">FACTURA Nº:</span> <span
class="val" th:text="${factura.numeroFactura}">153153</span></td>
<td class="text-end"><span class="lbl" th:text="#{pdf.presupuesto.date}">FECHA:</span> <span class="val"
th:text="${#temporals.format(factura.fechaEmision, 'dd/MM/yyyy')}">10/10/2025</span></td>
</tr>
</table>
<table class="sheet-info">
<tr>
<td class="text-start"><span th:text="#{'pdf.factura.razon-social'}" class="lbl">Razón Social:</span> <span
class="val" th:text="${direccionFacturacion.razonSocial}">153153</span></td>
<td class="text-end"><span th:text="#{'pdf.factura.direccion'}" class="lbl">Dirección:</span>
</td>
</tr>
<tr>
<td class="text-start"><span th:text="#{'pdf.factura.identificacion-fiscal'}" class="lbl">Identificación
Fiscal:</span> <span class="val" th:text="${direccionFacturacion.identificacionFiscal}">153153</span></td>
<td class="text-end">
<span class="val"
th:text="${direccionFacturacion.direccion + ', ' + direccionFacturacion.cp + ', ' + direccionFacturacion.ciudad}">153153</span>
</td>
</tr>
<tr>
<td class="text-start">
</td>
<td class="text-end">
<span class="val"
th:text="${direccionFacturacion.provincia + ', ' + #messages.msg('paises.' + direccionFacturacion.pais.keyword)}">153153</span>
</td>
</tr>
</table>
<!-- DATOS TÉCNICOS -->
<table class="items-table table table-bordered table-striped table-wrap w-100">
<thead>
<tr>
<th class="w-75" th:text="#{pdf.factura.lineas.descripcion}">Descripción</th>
<th th:text="#{pdf.factura.lineas.base}">Base</th>
<th th:text="#{pdf.factura.lineas.iva_4}">I.V.A. 4%</th>
<th th:text="#{pdf.factura.lineas.iva_21}">I.V.A. 21%</th>
<th th:text="#{pdf.factura.lineas.total}">Total</th>
</tr>
</thead>
<tbody>
<tr th:each="lineaFactura : ${factura.lineas}">
<td th:utext="${lineaFactura.descripcion}">Descripción de la línea</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.baseLinea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.iva4Linea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.iva21Linea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.totalLinea)}">0.00</td>
</tr>
</tbody>
<tfoot>
<tr>
<td class="text-end fw-bold" colspan="4" th:text="#{pdf.factura.lineas.base}">Base</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.baseImponible)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold" colspan="4" th:text="#{pdf.factura.lineas.iva_4}">I.V.A. 4%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva4)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold" colspan="4" th:text="#{pdf.factura.lineas.iva_21}">I.V.A. 21%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva21)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold text-uppercase" colspan="4" th:text="#{pdf.factura.lineas.total}">Total</td>
<td class="text-end fw-bold" colspan="1" th:text="${#numbers.formatCurrency(factura.totalFactura)}">0.00
</td>
</tr>
</tfoot>
</table>
</div>
</body>
</html>