haciendo vista de facturas

This commit is contained in:
2026-01-01 20:00:14 +01:00
parent 9d4320db9a
commit bf823281a5
14 changed files with 6482 additions and 5543 deletions

View File

@ -18,6 +18,39 @@ facturas.estado-pago.cancelada=Cancelada
facturas.estado.borrador=Borrador
facturas.estado.validada=Validada
facturas.form.numero-factura=Número de Factura
facturas.form.serie=Serie de facturación
facturas.form.fecha-emision=Fecha de Emisión
facturas.form.cliente=Cliente
facturas.form.notas=Notas
facturas.form.btn.validar=Validar Factura
facturas.form.btn.borrador=Pasar a Borrador
facturas.form.btn.guardar=Guardar
facturas.form.btn.imprimir=Imprimir Factura
facturas.lineas.acciones=Acciones
facturas.lineas.acciones.editar=Editar
facturas.lineas.acciones.eliminar=Eliminar
facturas.lineas.acciones.agregar=Agregar línea
facturas.lineas.descripcion=Descripción
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.direccion.titulo=Dirección de Facturación
facturas.direccion.razon-social=Razón Social
facturas.direccion.identificacion-fiscal=Identificación Fiscal
facturas.direccion.direccion=Dirección
facturas.direccion.codigo-postal=Código Postal
facturas.direccion.ciudad=Ciudad
facturas.direccion.provincia=Provincia
facturas.direccion.pais=País
facturas.direccion.telefono=Teléfono
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

@ -1,3 +1,183 @@
$(() => {
});
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 $container = $('#factura-container');
const MIN_LOADER_TIME = 500; // ms (ajusta a gusto)
let loaderStartTime = 0;
function showLoader() {
loaderStartTime = Date.now();
$('#factura-loader').removeClass('d-none');
}
function hideLoader() {
const elapsed = Date.now() - loaderStartTime;
const remaining = Math.max(MIN_LOADER_TIME - elapsed, 0);
setTimeout(() => {
$('#factura-loader').addClass('d-none');
}, remaining);
}
function getFacturaId() {
return $container.data('factura-id');
}
function reloadFacturaContainer() {
const id = getFacturaId();
if (!id) return $.Deferred().reject('No factura id').promise();
showLoader();
return $.ajax({
url: `/facturas/${id}/container`,
method: 'GET',
cache: false,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.done((html) => {
const $c = $('#factura-container');
// conserva loader dentro del container
const $loader = $c.find('#factura-loader').detach();
// reemplaza el contenido completo
$c.empty().append($loader).append(html);
afterFacturaRender();
})
.fail((xhr) => {
console.error('Error recargando container:', xhr.status, xhr.responseText);
})
.always(() => {
hideLoader();
});
}
function postAndReload(url, data) {
showLoader();
const isJson = data !== undefined && data !== null;
return $.ajax({
url,
method: 'POST',
data: isJson ? JSON.stringify(data) : null,
contentType: isJson ? 'application/json; charset=UTF-8' : undefined,
processData: !isJson, // importante: si es JSON, no procesar
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.done(() => reloadFacturaContainer())
.fail((xhr) => {
console.error('Error en acción:', xhr.status, xhr.responseText);
})
.always(() => {
hideLoader();
});
}
// Delegación (funciona aunque reemplacemos el contenido interno)
$container.on('click', '#btn-validar-factura', function () {
const id = getFacturaId();
postAndReload(`/facturas/${id}/validar`);
});
$container.on('click', '#btn-borrador-factura', function () {
const id = getFacturaId();
postAndReload(`/facturas/${id}/borrador`);
});
$container.on('focusin', 'textarea[name="notas"]', function () {
$(this).data('initial', $(this).val() ?? '');
});
$container.on('focusout', 'textarea[name="notas"]', function () {
const before = $(this).data('initial') ?? '';
const now = $(this).val() ?? '';
if (before === now) return;
const id = getFacturaId();
postAndReload(`/facturas/${id}/notas`, { notas: now });
});
$container.on('click', '#btn-guardar-factura', function () {
});
function destroySelect2($root) {
$root.find('.js-select2-factura').each(function () {
const $el = $(this);
if ($el.hasClass('select2-hidden-accessible')) {
$el.select2('destroy');
}
});
}
function initSelect2ForDraft($root) {
$root.find('.js-select2-factura').each(function () {
const $el = $(this);
// evita doble init
if ($el.hasClass('select2-hidden-accessible')) return;
const url = $el.data('url');
const placeholder = $el.data('placeholder') || '';
$el.select2({
width: '100%',
placeholder,
allowClear: true,
minimumInputLength: 0,
ajax: {
url: url,
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term, // texto buscado
page: params.page || 1
};
},
processResults: function (data) {
return data;
},
cache: true
}
});
});
}
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);
}
}
afterFacturaRender();
});

View File

@ -31,59 +31,20 @@
</ol>
</nav>
<div class="container-fluid">
<div class="container-fluid position-relative" id="factura-container"
th:attr="data-factura-id=${factura.id}">
<div class="accordion accordion-fill-imprimelibros mb-3" id="cabeceraFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="cabeceraHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#cabecera" aria-expanded="true" aria-controls="cabecera">
Datos de la factura
</button>
</h2>
<div id="cabecera" class="accordion-collapse collapse show" aria-labelledby="cabeceraHeader"
data-bs-parent="#cabeceraFactura">
<div class="accordion-body">
<div th:replace="~{imprimelibros/facturas/partials/factura-cabecera :: factura-cabecera (factura=${factura})}" />
</div>
</div>
</div>
</div>
<div class="accordion accordion-fill-imprimelibros" id="lineasFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="lineasHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#lineas" aria-expanded="true" aria-controls="lineas">
Líneas de factura
</button>
</h2>
<div id="lineas" class="accordion-collapse collapse show" aria-labelledby="lineasHeader"
data-bs-parent="#lineasFactura">
<div class="accordion-body">
<!-- <div th:replace="~{imprimelibros/facturas/partials/factura-lineas :: factura-lineas (factura=${factura})}" /> -->
</div>
</div>
</div>
</div>
<div class="accordion accordion-fill-imprimelibros" id="pagosFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="pagosHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#pagos" aria-expanded="true" aria-controls="pagos">
Pagos de factura
</button>
</h2>
<div id="pagos" class="accordion-collapse collapse show" aria-labelledby="pagosHeader"
data-bs-parent="#pagosFactura">
<div class="accordion-body">
<!-- <div th:replace="~{imprimelibros/facturas/partials/factura-cabecera :: factura-cabecera (factura=${factura})}" /> -->
</div>
</div>
<!-- overlay loader -->
<div id="factura-loader" class="d-none position-absolute top-0 start-0 w-100 h-100"
style="background: rgba(255,255,255,.6); z-index: 10;">
<div class="position-absolute top-50 start-50 translate-middle">
<div class="spinner-border" role="status" aria-hidden="true"></div>
</div>
</div>
<div class="factura-inner"
th:replace="~{imprimelibros/facturas/partials/factura-container :: factura-container (factura=${factura})}">
</div>
</div>
</div>

View File

@ -17,40 +17,135 @@
<!-- Número -->
<div class="col-md-3">
<label class="form-label">Número</label>
<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">
</div>
<!-- Serie -->
<div class="col-md-3">
<label class="form-label">Serie facturación</label>
<input type="text" class="form-control"
th:value="${factura.serie != null ? factura.serie.nombreSerie : ''}" readonly>
<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">
<option th:value="${factura.serie != null ? factura.serie.id : ''}"
th:text="${factura.serie != null ? factura.serie.nombreSerie : ''}" selected>
</option>
</select>
</div>
<!-- Cliente -->
<div class="col-md-6">
<label class="form-label">Cliente</label>
<input type="text" class="form-control" th:value="${factura.cliente.fullName}" readonly>
<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">
<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">Fecha</label>
<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">
</div>
<!-- Notas -->
<div class="col-md-12">
<label class="form-label">Notas</label>
<textarea class="form-control" rows="3" th:text="${factura.notas}"
th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<div class="col-md-9">
<label class="form-label" th:text="#{facturas.form.notas}">Notas</label>
<textarea class="form-control" rows="3" name="notas" th:text="${factura.notas}">
</textarea>
</div>
</div>
<div class="row g-3">
<div class="col-md-12">
<h5 class="mt-4" th:text="#{facturas.direccion.titulo}">Dirección de facturación</h5>
</div>
<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
? direccionFacturacion.razonSocial
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<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
? direccionFacturacion.identificacionFiscal
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<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
? 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
? 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
? 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
? 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">
<option th:value="${direccionFacturacion != null
? direccionFacturacion.pais.keyword
: ''}"
th:text="${direccionFacturacion != null
? #messages.msg('paises.' + direccionFacturacion.pais.keyword)
: ''}" selected>
</option>
</select>
</div>
<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
? direccionFacturacion.telefono
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
</div>
<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>
</div>
</div>
</th:block>
</div>

View File

@ -0,0 +1,56 @@
<div th:fragment="factura-container (factura)"
th:attr="data-factura-estado=${factura.estado.name()}">
<div class="accordion accordion-fill-imprimelibros mb-3" id="cabeceraFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="cabeceraHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#cabecera" aria-expanded="true" aria-controls="cabecera">
Datos de la factura
</button>
</h2>
<div id="cabecera" class="accordion-collapse collapse show" aria-labelledby="cabeceraHeader"
data-bs-parent="#cabeceraFactura">
<div class="accordion-body">
<div th:replace="~{imprimelibros/facturas/partials/factura-cabecera :: factura-cabecera (factura=${factura})}"></div>
</div>
</div>
</div>
</div>
<div class="accordion accordion-fill-imprimelibros mb-3" id="lineasFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="lineasHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#lineas" aria-expanded="true" aria-controls="lineas">
Líneas de factura
</button>
</h2>
<div id="lineas" class="accordion-collapse collapse show" aria-labelledby="lineasHeader"
data-bs-parent="#lineasFactura">
<div class="accordion-body">
<div th:replace="~{imprimelibros/facturas/partials/factura-lineas :: factura-lineas (factura=${factura})}"></div>
</div>
</div>
</div>
</div>
<div class="accordion accordion-fill-imprimelibros mb-3" id="pagosFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="pagosHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#pagos" aria-expanded="true" aria-controls="pagos">
Pagos de factura
</button>
</h2>
<div id="pagos" class="accordion-collapse collapse show" aria-labelledby="pagosHeader"
data-bs-parent="#pagosFactura">
<div class="accordion-body">
<!-- pagos -->
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,59 @@
<div th:fragment="factura-lineas (factura)">
<th:block th:if="${factura.estado != null && factura.estado.name() == 'borrador'}">
<div class="mb-3">
<button type="button" class="btn btn-secondary" id="btn-add-linea-factura">
<i class="fas fa-plus-circle me-2"></i>
<span th:text="#{facturas.lineas.acciones.agregar}">Agregar línea</span>
</button>
</div>
</th:block>
<table class="table table-bordered table-striped table-nowrap w-100">
<thead>
<tr>
<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>
<th th:text="#{facturas.lineas.iva_21}">I.V.A. 21%</th>
<th th:text="#{facturas.lineas.total}">Total</th>
</tr>
</thead>
<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>
<button type="button" class="btn btn-danger btn-sm"
th:attr="data-linea-id=${lineaFactura.id}" th:text="#{facturas.lineas.acciones.eliminar}">
<i class="fas fa-trash-alt"></i>
</button>
</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>
<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" 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" 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" 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>
</tr>
</table>
</div>