terminado (provisional) modulo de facturas

This commit is contained in:
2026-01-07 21:21:33 +01:00
parent 292aebcf65
commit 8263d97bf7
27 changed files with 13608 additions and 720 deletions

View File

@ -48,7 +48,7 @@ databaseChangeLog:
sql: |
INSERT INTO variables (clave, valor)
SELECT
'sere_facturacion_rect_default',
'serie_facturacion_rect_default',
CAST(sf.id AS CHAR)
FROM series_facturas sf
WHERE sf.prefijo = 'REC IL'

View File

@ -0,0 +1,114 @@
databaseChangeLog:
- changeSet:
id: create-facturas-direcciones
author: jjo
changes:
- createTable:
tableName: facturas_direcciones
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: factura_id
type: BIGINT
constraints:
nullable: false
- column:
name: unidades
type: MEDIUMINT UNSIGNED
- column:
name: email
type: VARCHAR(255)
- column:
name: att
type: VARCHAR(150)
constraints:
nullable: false
- column:
name: direccion
type: VARCHAR(255)
constraints:
nullable: false
- column:
name: cp
type: MEDIUMINT UNSIGNED
constraints:
nullable: false
- column:
name: ciudad
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: provincia
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: pais_code3
type: CHAR(3)
defaultValue: esp
constraints:
nullable: false
- column:
name: telefono
type: VARCHAR(30)
- column:
name: instrucciones
type: VARCHAR(255)
- column:
name: razon_social
type: VARCHAR(150)
- column:
name: tipo_identificacion_fiscal
type: ENUM('DNI','NIE','CIF','Pasaporte','VAT_ID')
defaultValue: DNI
constraints:
nullable: false
- column:
name: identificacion_fiscal
type: VARCHAR(50)
- column:
name: created_at
type: TIMESTAMP
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- addForeignKeyConstraint:
constraintName: fk_facturas_direcciones_factura
baseTableName: facturas_direcciones
baseColumnNames: factura_id
referencedTableName: facturas
referencedColumnNames: id
onDelete: CASCADE
onUpdate: RESTRICT
rollback:
- dropForeignKeyConstraint:
baseTableName: facturas_direcciones
constraintName: fk_facturas_direcciones_factura
- dropTable:
tableName: facturas_direcciones

View File

@ -46,4 +46,6 @@ databaseChangeLog:
- include:
file: db/changelog/changesets/0023-facturacion.yml
- include:
file: db/changelog/changesets/0024-series-facturacion-seeder.yml
file: db/changelog/changesets/0024-series-facturacion-seeder.yml
- include:
file: db/changelog/changesets/0025-create-facturas-direcciones.yml

View File

@ -1,6 +1,7 @@
facturas.title=Facturas
facturas.breadcrumb=Facturas
facturas.breadcrumb.ver=Ver Factura
facturas.breadcrumb.nueva=Nueva Factura
facturas.tabla.id=ID
facturas.tabla.cliente=Cliente
@ -19,10 +20,17 @@ facturas.estado.borrador=Borrador
facturas.estado.validada=Validada
facturas.form.numero-factura=Número de Factura
facturas.form.id=ID de la Factura
facturas.form.factura-rectificada=Factura rectificada
facturas.form.serie=Serie de facturación
facturas.form.serie.placeholder=Seleccione una serie...
facturas.form.fecha-emision=Fecha de Emisión
facturas.form.cliente=Cliente
facturas.form.direccion-facturacion=Dirección de Facturación
facturas.form.direccion-facturacion.placeholder=Seleccione una dirección...
facturas.form.cliente.placeholder=Seleccione un cliente...
facturas.form.notas=Notas
facturas.form.factura-rectificada=Factura rectificada
facturas.form.btn.validar=Validar Factura
facturas.form.btn.borrador=Pasar a Borrador
@ -85,3 +93,8 @@ 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
facturas.delete.ok.text=La factura ha sido eliminada correctamente.
facturas.add.form.validation.title=Error al crear la factura
facturas.add.form.validation=Revise que todos los campos están rellenos
facturas.error.create=No se ha podido crear la factura. Revise los datos e inténtelo de nuevo.

View File

@ -57,7 +57,9 @@ pedido.view.aceptar-ferro=Aceptar ferro
pedido.view.ferro-download=Descargar ferro
pedido.view.cub-download=Descargar cubierta
pedido.view.tapa-download=Descargar tapa
pedido.view.descargar-factura=Descargar factura
pedido.view.admin-actions=Acciones de administrador
pedido.view.actions=Acciones
pedido.view.cancel-title=¿Estás seguro de que deseas cancelar este pedido?
pedido.view.cancel-text=Esta acción no se puede deshacer.

View File

@ -0,0 +1,216 @@
$(() => {
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);
}
});
}
let $addBtn = $('#save-btn');
let $cancelBtn = $('#cancel-btn');
let $selectCliente = $('#clienteSelect');
let $serieInput = $('#serieInput');
let $direccionFacturacion = $('#direccionFacturacion');
let $facturaRectificada = $('#facturaRectificada');
let $divFacturaRectificada = $('#div-factura-rectificada');
// -----------------------------
// Initialize select2 for cliente selection
// -----------------------------
$selectCliente.select2({
placeholder: languageBundle['facturas.form.cliente.placeholder'],
width: '100%',
ajax: {
url: '/users/api/get-users',
dataType: 'json',
delay: 250,
data: function (params) {
return {
showUsername: true,
q: params.term,
page: params.page || 1
};
}
},
minimumInputLength: 0
});
$selectCliente.on('select2:select', function (e) {
const data = e.params.data;
$serieInput.val(null).trigger('change');
$direccionFacturacion.val(null).trigger('change');
$facturaRectificada.val(null).trigger('change');
if (data && data.id) {
$serieInput.prop('disabled', false);
$direccionFacturacion.prop('disabled', false);
}
else {
$serieInput.prop('disabled', true);
$direccionFacturacion.prop('disabled', true);
}
});
$serieInput.select2({
placeholder: languageBundle['facturas.form.serie.placeholder'],
width: '100%',
ajax: {
url: '/configuracion/series-facturacion/api/get-series',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term,
page: params.page || 1
};
}
},
minimumInputLength: 0
});
$serieInput.on('select2:select', function (e) {
const data = e.params.data;
const defaultRectSerieId = $serieInput.data('default-serie-rect');
if (data && data.id) {
if (data.id === defaultRectSerieId) {
$divFacturaRectificada.removeClass('d-none');
$facturaRectificada.val(null).trigger('change');
}
else {
$divFacturaRectificada.addClass('d-none');
$facturaRectificada.val(null).trigger('change');
}
}
});
$direccionFacturacion.select2({
placeholder: languageBundle['facturas.form.direccion-facturacion.placeholder'],
width: '100%',
ajax: {
url: '/facturas/api/get-direcciones',
dataType: 'json',
delay: 250,
data: function (params) {
const clienteId = $selectCliente.val();
return {
user_id: clienteId,
q: params.term,
page: params.page || 1
};
},
processResults: (data) => {
const items = Array.isArray(data) ? data : (data.results || []);
return {
results: items.map(item => ({
id: item.id,
text: item.text, // ← Select2 necesita 'id' y 'text'
alias: item.alias || 'Sin alias',
att: item.att || '',
direccion: item.direccion || '',
cp: item.cp || '',
ciudad: item.ciudad || '',
html: `
<div>
<strong>${item.alias || 'Sin alias'}</strong><br>
${item.att ? `<small>${item.att}</small><br>` : ''}
<small>${item.direccion || ''}${item.cp ? ', ' + item.cp : ''}${item.ciudad ? ', ' + item.ciudad : ''}</small>
</div>
`
})),
pagination: { more: false } // opcional, evita que espere más páginas
};
}
},
minimumInputLength: 0,
templateResult: data => {
if (data.loading) return data.text;
return $(data.html || data.text);
},
// Selección más compacta (solo alias + ciudad)
templateSelection: data => {
if (!data.id) return data.text;
const alias = data.alias || data.text;
const ciudad = data.ciudad ? `${data.ciudad}` : '';
return $(`<span>${alias}${ciudad}</span>`);
},
escapeMarkup: m => m
});
$facturaRectificada.select2({
placeholder: languageBundle['facturas.form.factura-rectificada.placeholder'],
width: '100%',
ajax: {
url: '/facturas/api/get-facturas-rectificables',
dataType: 'json',
delay: 250,
data: function (params) {
const clienteId = $selectCliente.val();
return {
user_id: clienteId,
q: params.term,
page: params.page || 1
};
}
},
minimumInputLength: 0
});
// -----------------------------
// Cancel button click
// -----------------------------
$cancelBtn.on('click', () => {
window.location.href = '/facturas';
});
// -----------------------------
// Save button click
// -----------------------------
$addBtn.on('click', () => {
const clienteId = $selectCliente.val();
const serieId = $serieInput.val();
const direccionId = $direccionFacturacion.val();
const facturaRectificadaId = $facturaRectificada.val();
if (!clienteId && !serieId && !direccionId) {
Swal.fire({
icon: 'error',
title: languageBundle['facturas.add.form.validation.title'],
text: languageBundle['facturas.add.form.validation']
});
return;
}
$.ajax({
url: '/facturas/add',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
user: clienteId,
serie: serieId,
direccion: direccionId,
factura_rectificada: facturaRectificadaId
}),
success: function (response) {
if (response.success) {
window.location.href = '/facturas/' + response.facturaId;
} else {
Swal.fire({
icon: 'error',
title: 'Error',
text: response.message
});
}
},
error: function () {
Swal.fire({
icon: 'error',
title: 'Error',
text: languageBundle['facturas.error.create']
});
}
});
});
});

View File

@ -62,7 +62,9 @@ $(() => {
// -----------------------------
// Add
// -----------------------------
$addBtn.on();
$addBtn.on('click', () => {
window.location.href = '/facturas/add';
});
// -----------------------------
// Edit click

View File

@ -83,4 +83,20 @@ $(() => {
});
});
}
if ($(".btn-download-factura").length) {
$(document).on('click', '.btn-download-factura', function () {
const facturaId = $(this).data('factura-id');
const url = `/api/pdf/factura/${facturaId}?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);
});
}
});

View File

@ -1,3 +1,4 @@
import { duplicar, reimprimir } from './presupuesto-utils.js';
(() => {
// si jQuery está cargado, añade CSRF a AJAX
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
@ -124,6 +125,27 @@
});
});
$('#presupuestos-clientes-user-datatable').on('click', '.btn-duplicate-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
let data = table.row($(this).parents('tr')).data();
const tituloOriginal = data.titulo;
duplicar(id, tituloOriginal);
});
$('#presupuestos-clientes-user-datatable').on('click', '.btn-reprint-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
let data = table.row($(this).parents('tr')).data();
const tituloOriginal = data.titulo;
reimprimir(id, tituloOriginal);
});
$('#presupuestos-clientes-user-datatable').on('keyup', '.presupuesto-filter', function (e) {
const colName = $(this).data('col');
const colIndex = table.column(colName + ':name').index();

View File

@ -0,0 +1,120 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<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>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item"><a href="/facturas" th:text="#{facturas.breadcrumb}"></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{facturas.breadcrumb.nueva}">
Nueva factura</li>
</ol>
</nav>
<div class="container-fluid position-relative">
<div class="row">
<div class="col-xs-12 col-md-6 mb-3">
<label for="clienteSelect" class="form-label" th:text="#{facturas.form.cliente}">Cliente</label>
<select id="clienteSelect" class="form-select select2"
th:placeholder="#{facturas.form.cliente.placeholder}">
<option th:each="cliente : ${clientes}" th:value="${cliente.id}"
th:text="${cliente.nombre} + ' (' + cliente.email + ')'"></option>
</select>
</div>
<div class="col-xs-12 col-md-6 mb-3">
<label for="serieInput" class="form-label" th:text="#{facturas.form.serie}">Serie de
facturación</label>
<select id="serieInput" class="form-select select2" disabled
th:data-default-serie-rect="${defaultSerieRectificativa}">
</select>
</div>
<!-- salto de fila SOLO en md+ -->
<div class="w-100 d-none d-md-block"></div>
<div class="col-12 col-md-6 mb-3">
<label for="direccionFacturacion" class="form-label"
th:text="#{facturas.form.direccion-facturacion}">
Factura rectificada
</label>
<select id="direccionFacturacion" class="form-select select2" disabled></select>
</div>
<div class="col-12 col-md-6 mb-3 d-none" id="div-factura-rectificada">
<label for="facturaRectificada" class="form-label"
th:text="#{facturas.form.factura-rectificada}">
Factura rectificada
</label>
<select id="facturaRectificada" class="form-select select2"></select>
</div>
</div> <!-- end row -->
<div class="row mt-3 justify-content-md-end g-2">
<div class="col-12 col-md-auto">
<button type="button" th:text="#{app.guardar}" id="save-btn" class="btn btn-secondary w-100">
Guardar
</button>
</div>
<div class="col-12 col-md-auto">
<button type="button" th:text="#{app.cancelar}" id="cancel-btn" class="btn btn-light w-100">
Cancelar
</button>
</div>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<!-- JS de Buttons y dependencias -->
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
<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/add.js}"></script>
</th:block>
</body>
</html>

View File

@ -16,10 +16,14 @@
<div class="row g-3">
<!-- Número (solo lectura siempre, normalmente) -->
<div class="col-md-3">
<div th:if="${factura.numeroFactura != null}" class="col-md-3">
<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>
<div th:if="${factura.numeroFactura == null}" class="col-md-3">
<label class="form-label" th:text="#{facturas.form.id}">ID de la factura</label>
<input id="facturaId" type="text" class="form-control" th:value="${factura.id}" readonly>
</div>
<!-- Serie -->
<div class="col-md-3">
@ -43,6 +47,15 @@
</select>
</div>
<!-- Factura rectificada -->
<div th:if="${factura.facturaRectificada != null}" class="w-100 d-md-block"></div>
<div th:if="${factura.facturaRectificada != null}" class="col-md-3">
<label class="form-label" th:text="#{facturas.form.factura-rectificada}">Factura rectificada</label>
<input readonly id="facturaRectificadaId" class="form-control"
th:value="${factura.facturaRectificada.numeroFactura}" />
</div>
<div th:if="${factura.facturaRectificada != null}" class="w-100 d-md-block"></div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.form.fecha-emision}">Fecha</label>

View File

@ -73,24 +73,24 @@
Aceptar ferro
</button>
<button th:if="${item.estado.priority >= 7 && item.estado.priority < 11 && item.buttons.ferro}"
<button
th:if="${item.estado.priority >= 7 and item.estado.priority <= 11 and item['buttons'] != null and item['buttons']['ferro'] == true}"
type="button" class="btn btn-light w-100 btn-download-ferro"
th:text="#{pedido.view.ferro-download}"
th:attr="data-linea-id=${item.lineaId}">
th:text="#{pedido.view.ferro-download}" th:attr="data-linea-id=${item.lineaId}">
Descargar ferro
</button>
<button th:if="${item.estado.priority >= 7 && item.estado.priority < 11 && item.buttons.cub}"
<button
th:if="${item.estado.priority >= 7 and item.estado.priority <= 11 and item['buttons'] != null and item['buttons']['cub'] == true}"
type="button" class="btn btn-light w-100 btn-download-cub"
th:text="#{pedido.view.cub-download}"
th:attr="data-linea-id=${item.lineaId}">
th:text="#{pedido.view.cub-download}" th:attr="data-linea-id=${item.lineaId}">
Descargar cubierta
</button>
<button th:if="${item.estado.priority >= 7 && item.estado.priority < 11 && item.buttons.tapa}"
<button
th:if="${item.estado.priority >= 7 and item.estado.priority <= 11 and item['buttons'] != null and item['buttons']['tapa'] == true}"
type="button" class="btn btn-light w-100 btn-download-tapa"
th:text="#{pedido.view.tapa-download}"
th:attr="data-linea-id=${item.lineaId}">
th:text="#{pedido.view.tapa-download}" th:attr="data-linea-id=${item.lineaId}">
Descargar tapa
</button>
</div>
@ -136,7 +136,7 @@
<div class="d-flex flex-wrap my-n1">
<!-- Actualizar estado-->
<div class="update-estado-button"
th:if="${item.estado.name != 'cancelado' && item.estado.name != 'maquetacion' && item.estado.name != 'terminado'}">
th:if="${item.estado.name != 'cancelado' && item.estado.name != 'maquetacion' && item.estado.name != 'enviado'}">
<a href="javascript:void(0);" class="d-block text-body p-1 px-2 update-status-item"
th:attr="data-linea-id=${item.lineaId}">
<i class="ri-refresh-line text-muted align-bottom me-1"><span

View File

@ -43,6 +43,7 @@
pais=${direccionFacturacion != null ? direccionFacturacion.paisNombre : ''}
)}">
</div>
</div>
<th:block th:if="${isAdmin and showCancel}">
<div sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
@ -60,11 +61,23 @@
</div>
</div>
</th:block>
<th:block th:if="${showDownloadFactura}">
<div class="col-12 col-md-auto">
<div class="card card border mb-3">
<div class="card-header bg-light">
<span class="fs-16" th:text="#{'pedido.view.actions'}"></span>
</div>
<div class="card-body">
<button type="button" th:attr="data-factura-id=${facturaId}" class="btn btn-secondary w-100 btn-download-factura"
th:text="#{pedido.view.descargar-factura}">
Descargar factura
</button>
</div>
</div>
</div>
</th:block>
</div>
<th:block th:each="linea: ${lineas}">
<div
th:insert="~{imprimelibros/pedidos/pedidos-linea :: pedido-linea (item=${linea}, isAdmin=${isAdmin})}">