diff --git a/public/js/chatbot.js b/public/js/chatbot.js
new file mode 100644
index 0000000..357489c
--- /dev/null
+++ b/public/js/chatbot.js
@@ -0,0 +1,227 @@
+/*
+EVENTO: DOMContentLoaded
+
+Se ejecuta cuando el HTML termina de cargarse
+
+Esto asegura que todos los elementos del DOM existan antes de intentar utilizarlos desde JavaScript
+*/
+
+document.addEventListener("DOMContentLoaded", () => {
+
+ /*
+ REFERENCIAS DEL DOM
+
+ Obtenemos:
+ - form -> formulario del chat
+ - input -> input donde escribe el usuario
+ - chatBox -> contenedor de mensajes
+ */
+
+ const form = document.getElementById("chat-form");
+ const input = document.getElementById("mensaje");
+ const chatBox = document.getElementById("chat-box");
+
+ // Se detiene el script si no existe algún elemento
+
+ if (!form || !input || !chatBox) return;
+
+ // EVENTO SUBMIT -> se ejecuta cuando el usuario envía el formulario
+
+ form.addEventListener("submit", async (e) => {
+
+ // PREVENIR RECARGA -> Evita que el formulario recargue la página
+
+ e.preventDefault();
+
+ // OBTENER MENSAJE
+
+ const mensaje = input.value.trim();
+
+ // VALIDAR MENSAJE
+
+ if (!mensaje) return;
+
+ // MOSTRAR MENSAJE DEL USUARIO -> Se agrega inmediatamente al chat
+
+ agregarMensaje("user", mensaje);
+
+ // LIMPIAR INPUT
+
+ input.value = "";
+
+ // MOSTRAR TYPING (ESCRIBIENDO...)
+
+ mostrarTyping();
+
+ try {
+
+ /*
+ FETCH AJAX
+
+ Realizamos una petición POST hacia chatbot.php
+ */
+
+ const response = await fetch(
+ window.location.origin + "/ajax/chatbot.php",
+ {
+ method: "POST",
+
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded"
+ },
+
+ /*
+ BODY
+
+ encodeURIComponent():
+ - Escapa caracteres especiales
+ */
+
+ body:
+ "mensaje=" + encodeURIComponent(mensaje)
+ }
+ );
+
+ // ELIMINAR TYPING
+
+ removerTyping();
+
+ // CONVERTIR RESPUESTA JSON
+
+ const data = await response.json();
+
+ // ERROR BACKEND -> success === false
+
+ if (!data.success) {
+
+ console.error(data.error);
+
+ agregarMensaje(
+ "bot",
+ "Ocurrió un error al procesar la consulta."
+ );
+
+ return;
+ }
+
+ // RESPUESTA IA
+
+ agregarMensaje(
+ "bot",
+ data.respuesta
+ );
+
+ } catch (error) {
+
+ // ERROR FETCH / CONEXIÓN
+
+ removerTyping();
+
+ console.error(error);
+
+ agregarMensaje(
+ "bot",
+ "Error de conexión."
+ );
+ }
+ });
+
+ // Evita inyección HTML/XSS
+
+ function escaparHTML(texto)
+ {
+ return texto
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ // AGREGAR MENSAJE VISUAL AL CHAT -> tipo: user / bot
+
+ function agregarMensaje(tipo, texto)
+ {
+
+ // CREAR DIV
+
+ const div = document.createElement("div");
+
+ // CLASE CSS
+
+ div.className =
+ tipo === "user" ? "mensaje-user" : "mensaje-bot";
+
+ div.innerHTML = `
+
+ ${escaparHTML(texto).replace(/\n/g, "
")}
+
+ `;
+
+ // INSERTAR MENSAJE
+
+ chatBox.appendChild(div);
+
+ // AUTO SCROLL
+
+ scrollBottom();
+ }
+
+ // MOSTRAR TYPING
+
+ function mostrarTyping()
+ {
+ const div = document.createElement("div");
+
+ div.id = "typing";
+
+ div.className = "mensaje-bot";
+
+ div.innerHTML = `
+
+ Escribiendo...
+
+ `;
+
+ chatBox.appendChild(div);
+
+ scrollBottom();
+ }
+
+ // ELIMINAR TYPING
+
+ function removerTyping()
+ {
+ const typing = document.getElementById("typing");
+
+ if (typing) {
+ typing.remove();
+ }
+ }
+
+ // AUTO SCROLL -> Baja automáticamente el scroll del chat
+
+ function scrollBottom()
+ {
+ chatBox.scrollTop = chatBox.scrollHeight;
+ }
+});
+
+/*
+EVENTO: load
+
+Se ejecuta cuando toda la página termina de cargar:
+HTML + CSS + imágenes + recursos
+
+Se usa para restaurar el scroll abajo al refrescar la página
+*/
+
+window.addEventListener("load", () => {
+
+ const chatBox = document.getElementById("chat-box");
+
+ if (chatBox) {
+
+ chatBox.scrollTop = chatBox.scrollHeight;
+ }
+});
\ No newline at end of file
diff --git a/public/js/clientes/clienteTableLista.js b/public/js/clientes/clienteTableLista.js
new file mode 100644
index 0000000..fd6065b
--- /dev/null
+++ b/public/js/clientes/clienteTableLista.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaClientes').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar cliente...",
+ zeroRecords: "No se encontraron clientes",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ clientes",
+ infoEmpty: "No hay clientes para mostrar",
+ infoFiltered: "(filtrado de _MAX_ clientes totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/clientes/clienteValidator.js b/public/js/clientes/clienteValidator.js
new file mode 100644
index 0000000..ead4861
--- /dev/null
+++ b/public/js/clientes/clienteValidator.js
@@ -0,0 +1,273 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-cliente") || document.getElementById("form-editar-cliente");
+ if (!form) return;
+
+ // Funciones utilitarias
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function isValidEmail(email) {
+ return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
+ }
+
+ function isValidName(value) {
+ return /^[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?: [A-ZÁÉÍÓÚÑ][a-záéíóúñ]+)*$/.test(value);
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const nombre = form.querySelector("input[name='nombre']");
+ const apellido = form.querySelector("input[name='apellido']");
+ const telefono = form.querySelector("input[name='telefono']");
+ const email = form.querySelector("input[name='email']");
+
+ // Limpiar estados previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // NOMBRE
+
+ if (nombre) {
+ if (isEmpty(nombre.value)) {
+ errores.push("El nombre es obligatorio.");
+ nombre.classList.add("is-invalid");
+ } else if (!isValidName(nombre.value)) {
+ errores.push("El nombre debe comenzar con mayúscula y solo contener letras.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length < 2) {
+ errores.push("El nombre debe tener al menos 2 caracteres.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length > 50) {
+ errores.push("El nombre no puede superar los 50 caracteres.");
+ nombre.classList.add("is-invalid");
+ }
+ }
+
+ // APELLIDO
+
+ if (apellido) {
+ if (isEmpty(apellido.value)) {
+ errores.push("El apellido es obligatorio.");
+ apellido.classList.add("is-invalid");
+ } else if (!isValidName(apellido.value)) {
+ errores.push("El apellido debe comenzar con mayúscula y solo contener letras.");
+ apellido.classList.add("is-invalid");
+ } else if (apellido.value.length < 2) {
+ errores.push("El apellido debe tener al menos 2 caracteres.");
+ apellido.classList.add("is-invalid");
+ } else if (apellido.value.length > 50) {
+ errores.push("El apellido no puede superar los 50 caracteres.");
+ apellido.classList.add("is-invalid");
+ }
+ }
+
+ // TELÉFONO
+
+ if (telefono) {
+ if (isEmpty(telefono.value)) {
+ errores.push("El teléfono es obligatorio.");
+ telefono.classList.add("is-invalid");
+ } else if (telefono.value.length != 10) {
+ errores.push("El teléfono debe poseer 10 dígitos.");
+ telefono.classList.add("is-invalid");
+ }
+ }
+
+ // EMAIL
+
+ if (email) {
+ if (isEmpty(email.value)) {
+ errores.push("El email es obligatorio.");
+ email.classList.add("is-invalid");
+ } else if (!isValidEmail(email.value)) {
+ errores.push("El email no tiene un formato válido.");
+ email.classList.add("is-invalid");
+ } else if (email.value.length > 100) {
+ errores.push("El email no puede superar los 100 caracteres.");
+ email.classList.add("is-invalid");
+ }
+ }
+
+ // CONDICIÓN IVA
+
+ const condIVA = form.querySelector("select[name='cond_iva']");
+
+ const condicionesPermitidas = [
+ "consumidor_final",
+ "monotributo",
+ "responsable_inscripto"
+ ];
+
+ if (condIVA) {
+
+ if (!condicionesPermitidas.includes(condIVA.value)) {
+
+ errores.push("Condición de IVA inválida.");
+ condIVA.classList.add("is-invalid");
+ }
+ }
+
+ // CUIT
+
+ const cuit1 = form.querySelector("input[name='cuit_1']");
+ const cuit2 = form.querySelector("input[name='cuit_2']");
+ const cuit3 = form.querySelector("input[name='cuit_3']");
+
+ if (cuit1 && cuit2 && cuit3) {
+
+ const cuit = cuit1.value.trim() + cuit2.value.trim() + cuit3.value.trim();
+
+ // Monotributo y Responsable Inscripto:
+ // CUIT obligatorio
+
+ if (condIVA && condIVA.value !== "consumidor_final" && cuit === "") {
+
+ errores.push("Debe ingresar el CUIT.");
+
+ cuit1.classList.add("is-invalid");
+ cuit2.classList.add("is-invalid");
+ cuit3.classList.add("is-invalid");
+ }
+
+ // Si se ingresó algo, validar formato
+
+ if (cuit !== "") {
+
+ if (!/^\d{11}$/.test(cuit)) {
+
+ errores.push(
+ "El CUIT debe contener exactamente 11 dígitos."
+ );
+
+ cuit1.classList.add("is-invalid");
+ cuit2.classList.add("is-invalid");
+ cuit3.classList.add("is-invalid");
+ }
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/configuracion/configuracionValidator.js b/public/js/configuracion/configuracionValidator.js
new file mode 100644
index 0000000..98f3bfe
--- /dev/null
+++ b/public/js/configuracion/configuracionValidator.js
@@ -0,0 +1,336 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-config-empresa");
+ if (!form) return;
+
+ // Funciones utilitarias
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function isValidNombre(value) {
+ return /^[A-Za-zÁÉÍÓÚáéíóú0-9\s.\-]{2,100}$/.test(value);
+ }
+
+ function isValidTexto(value) {
+ return /^[A-Za-zÁÉÍÓÚáéíóúñÑ\s]{2,100}$/.test(value);
+ }
+
+ function isValidEmail(value) {
+ return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value);
+ }
+
+ function isValidTelefono(value) {
+ return /^[0-9]{10}$/.test(value);
+ }
+
+ function isValidCP(value) {
+ return /^[0-9]{4}$/.test(value);
+ }
+
+ function isValidCuit(value) {
+ return /^\d{10,11}$/.test(value);
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const nombre = form.querySelector("input[name='nombre']");
+ const direccion = form.querySelector("input[name='direccion']");
+ const ciudad = form.querySelector("input[name='ciudad']");
+ const codigo_postal = form.querySelector("input[name='codigo_postal']");
+ const provincia = form.querySelector("input[name='provincia']");
+ const telefono = form.querySelector("input[name='telefono']");
+ const email = form.querySelector("input[name='email']");
+ const cuit = form.querySelector("input[name='cuit']");
+ const iva = form.querySelector("input[name='iva']");
+ const cargo = form.querySelector("input[name='cargo_cancelacion_servicio']");
+ const email_emisor = form.querySelector("input[name='email_emisor']");
+
+ const apM = form.querySelector("input[name='hora_apertura_maniana']");
+ const ciM = form.querySelector("input[name='hora_cierre_maniana']");
+ const apT = form.querySelector("input[name='hora_apertura_tarde']");
+ const ciT = form.querySelector("input[name='hora_cierre_tarde']");
+
+ // Limpiar estados previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // NOMBRE
+
+ if (nombre) {
+ if (isEmpty(nombre.value)) {
+ errores.push("El nombre es obligatorio.");
+ nombre.classList.add("is-invalid");
+ } else if (!isValidNombre(nombre.value)) {
+ errores.push("El nombre contiene caracteres inválidos.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length < 2) {
+ errores.push("El nombre debe tener al menos 2 caracteres.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length > 50) {
+ errores.push("El nombre no puede superar los 50 caracteres.");
+ nombre.classList.add("is-invalid");
+ }
+ }
+
+ // DIRECCIÓN
+
+ if (direccion) {
+ if (isEmpty(direccion.value)) {
+ errores.push("La dirección es obligatoria.");
+ direccion.classList.add("is-invalid");
+ } else if (direccion.value.length < 3) {
+ errores.push("La dirección debe tener al menos 3 caracteres.");
+ direccion.classList.add("is-invalid");
+ } else if (direccion.value.length > 100) {
+ errores.push("La dirección no puede superar los 100 caracteres.");
+ direccion.classList.add("is-invalid");
+ }
+ }
+
+ // CIUDAD
+
+ if (ciudad) {
+ if (isEmpty(ciudad.value)) {
+ errores.push("La ciudad es obligatoria.");
+ ciudad.classList.add("is-invalid");
+ } else if (!isValidTexto(ciudad.value)) {
+ errores.push("La ciudad solo puede contener letras y espacios.");
+ ciudad.classList.add("is-invalid");
+ }
+ }
+
+ // CÓDIGO POSTAL
+
+ if (codigo_postal) {
+ if (isEmpty(codigo_postal.value)) {
+ errores.push("El código postal es obligatorio.");
+ codigo_postal.classList.add("is-invalid");
+ } else if (!isValidCP(codigo_postal.value)) {
+ errores.push("El código postal debe tener 4 dígitos.");
+ codigo_postal.classList.add("is-invalid");
+ }
+ }
+
+ // PROVINCIA
+
+ if (provincia) {
+ if (isEmpty(provincia.value)) {
+ errores.push("La provincia es obligatoria.");
+ provincia.classList.add("is-invalid");
+ } else if (!isValidTexto(provincia.value)) {
+ errores.push("La provincia contiene caracteres inválidos.");
+ provincia.classList.add("is-invalid");
+ }
+ }
+
+ // TELÉFONO
+
+ if (telefono) {
+ if (isEmpty(telefono.value)) {
+ errores.push("El teléfono es obligatorio.");
+ telefono.classList.add("is-invalid");
+ } else if (!isValidTelefono(telefono.value)) {
+ errores.push("El teléfono debe tener 10 dígitos.");
+ telefono.classList.add("is-invalid");
+ }
+ }
+
+ // EMAIL
+
+ if (email) {
+ if (isEmpty(email.value)) {
+ errores.push("El email es obligatorio.");
+ email.classList.add("is-invalid");
+ } else if (!isValidEmail(email.value)) {
+ errores.push("El email no tiene un formato válido.");
+ email.classList.add("is-invalid");
+ } else if (email.value.length > 100) {
+ errores.push("El email no puede superar los 100 caracteres.");
+ email.classList.add("is-invalid");
+ }
+ }
+
+ // CUIT
+
+ if (cuit) {
+ if (!isValidCuit(cuit.value)) {
+ errores.push("CUIT inválido.");
+ cuit.classList.add("is-invalid");
+ }
+ }
+
+ // IVA
+
+ if (iva) {
+ if (iva.value && isNaN(iva.value)) {
+ errores.push("El IVA debe ser numérico.");
+ iva.classList.add("is-invalid");
+ }
+ }
+
+ // CARGO CANCELACIÓN
+
+ if (cargo) {
+ if (cargo.value && isNaN(cargo.value)) {
+ errores.push("El cargo por cancelación debe ser numérico.");
+ cargo.classList.add("is-invalid");
+ }
+ }
+
+ // EMAIL EMISOR
+
+ if (email_emisor) {
+ if (isEmpty(email_emisor.value)) {
+ errores.push("El email emisor es obligatorio.");
+ email_emisor.classList.add("is-invalid");
+ } else if (!isValidEmail(email_emisor.value)) {
+ errores.push("El email emisor no tiene un formato válido.");
+ email_emisor.classList.add("is-invalid");
+ } else if (email_emisor.value.length > 100) {
+ errores.push("El email emisor no puede superar los 100 caracteres.");
+ email_emisor.classList.add("is-invalid");
+ }
+ }
+
+ // HORARIOS
+
+ if (apM && ciM) {
+ if (apM.value && ciM.value && apM.value >= ciM.value) {
+ errores.push("El horario de apertura de la mañana debe ser menor al de cierre.");
+ apM.classList.add("is-invalid");
+ ciM.classList.add("is-invalid");
+ }
+ }
+
+ if (apT && ciT) {
+ if (apT.value && ciT.value && apT.value >= ciT.value) {
+ errores.push("El horario de apertura de la tarde debe ser menor al de cierre.");
+ apT.classList.add("is-invalid");
+ ciT.classList.add("is-invalid");
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/globalModal.js b/public/js/globalModal.js
new file mode 100644
index 0000000..325659b
--- /dev/null
+++ b/public/js/globalModal.js
@@ -0,0 +1,113 @@
+// Guarda referencia al form original (si existe)
+
+let formConfirmacion = null;
+
+// Acción dinámica a ejecutar (opt del backend)
+
+let action = null;
+
+// Objeto donde se copian todos los data-* del botón
+
+let dataset = {};
+
+// Recorre todos los botones que disparan el modal
+
+document.querySelectorAll(".btnConfirmar").forEach(boton => {
+
+ boton.addEventListener("click", function () {
+
+ // Inserta el mensaje en el modal
+
+ document.getElementById("modalMensaje").textContent = this.dataset.mensaje;
+
+ // Inserta el título en el modal
+
+ document.getElementById("modalTitulo").textContent = this.dataset.titulo;
+
+ // Busca si el botón pertenece a un form existente
+
+ formConfirmacion = this.closest("form");
+
+ // Obtiene la acción dinámica (si existe)
+
+ action = this.dataset.action || null;
+
+ // Copia TODOS los data-* del botón a un objeto JS
+
+ dataset = { ...this.dataset };
+
+ });
+});
+
+// Evento del botón "Confirmar" dentro del modal
+
+document.getElementById("modalBtnConfirmar").addEventListener("click", function (e) {
+
+ // Evita el submit automático del botón
+
+ e.preventDefault();
+
+ // CASO 1: FORM EXISTENTE
+
+ // Si hay un form padre y no hay acción dinámica
+
+ if (formConfirmacion && !action) {
+
+ // Envía directamente ese form
+
+ formConfirmacion.submit();
+ return;
+ }
+
+ // CASO 2: FORM DINÁMICO
+
+ if (action) {
+
+ // Form global del modal
+
+ const form = document.getElementById("formConfirmacionGlobal");
+
+ // Contenedor donde se insertan inputs ocultos
+
+ const container = document.getElementById("modalDynamicInputs");
+
+ // Define la URL de envío con el parámetro opt
+
+ form.action = `index.php?opt=${action}`;
+
+ // Limpia inputs anteriores para evitar duplicados
+
+ container.innerHTML = '';
+
+ // Recorre todas las claves del dataset
+
+ Object.keys(dataset).forEach(key => {
+
+ // Ignora atributos que no deben enviarse
+
+ if ([
+ 'action',
+ 'mensaje',
+ 'titulo',
+ 'bsToggle',
+ 'bsTarget'
+ ].includes(key)) return;
+
+ // Crea un input hidden por cada data-*
+
+ const input = document.createElement('input');
+
+ input.type = 'hidden'; // oculto
+ input.name = key; // nombre (ej: mano_obra, etc)
+ input.value = dataset[key]; // valor correspondiente
+
+ // Lo agrega al form
+
+ container.appendChild(input);
+ });
+
+ // Envía el form dinámico
+
+ form.submit();
+ }
+});
\ No newline at end of file
diff --git a/public/js/informes.js b/public/js/informes.js
new file mode 100644
index 0000000..b34cd3b
--- /dev/null
+++ b/public/js/informes.js
@@ -0,0 +1,284 @@
+// Variable global para almacenar la instancia del gráfico, Chart.js no permite reutilizar el canvas sin destruir el anterior
+
+let grafico;
+
+// FUNCIÓN PARA ACTUALIZAR KPIS
+
+function actualizarKpis(kpis) {
+
+ // KPI: INGRESOS
+ // Formateo a moneda argentina con separador de miles
+
+ document.getElementById('kpiIngresos').innerText = '$' + kpis.totalIngresos.toLocaleString('es-AR');
+
+ // KPI: EGRESOS
+
+ document.getElementById('kpiEgresos').innerText = '$' + kpis.totalCostos.toLocaleString('es-AR');
+
+ // KPI: RESULTADO NETO
+
+ const resultadoEl = document.getElementById('kpiResultado');
+
+ resultadoEl.innerText = '$' + kpis.resultado.toLocaleString('es-AR');
+
+ // Se ajusta el color dinámicamente, verde si hay ganancia y rojo si hay pérdida
+
+ resultadoEl.classList.remove('text-success', 'text-danger');
+ resultadoEl.classList.add(
+ kpis.resultado >= 0 ? 'text-success' : 'text-danger'
+ );
+
+ // KPI: MEJOR MES (calculado desde backend)
+
+ document.getElementById('kpiMejorMes').innerText = kpis.mejorMes;
+}
+
+// FUNCIÓN PARA CARGAR BALANCE
+
+async function cargarBalance(anio) {
+
+ // Llamada al backend (controller PHP), se pasa el año como parámetro
+
+ const response = await fetch(`index.php?opt=generar_balance_anual&anio=${anio}`);
+
+ // Convertimos la respuesta a JSON
+
+ const responseJson = await response.json();
+
+ // TRANSFORMACIÓN DE DATOS
+
+ // El backend devuelve los meses como números, acá se convierten a nombres abreviados
+
+ const meses = responseJson.meses.map(m => {
+ const nombres = ["Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"];
+ return nombres[m - 1];
+ });
+
+ // Parseo de datos, convertimos todos los valores a números (por seguridad)
+
+ const ventas = responseJson.ventas.map(v => parseFloat(v));
+ const servicios = responseJson.servicios.map(s => parseFloat(s));
+ const costos = responseJson.costos.map(c => parseFloat(c));
+ const utilidad = responseJson.utilidad.map(u => parseFloat(u));
+
+ // Obtenemos el contexto del canvas
+
+ const ctx = document.getElementById('graficoBalance').getContext('2d');
+
+ // Destruir gráfico anterior
+
+ if (grafico) {
+ grafico.destroy();
+ }
+
+ // CREAR NUEVO GRÁFICO
+
+ grafico = new Chart(ctx, {
+ type: 'bar', // Tipo base (gráfico de barras)
+ data: {
+ labels: meses, // Etiquetas (meses)
+ datasets: [
+
+ // Dataset VENTAS
+ {
+ label: 'Ventas',
+ data: ventas
+ },
+
+ // Dataset SERVICIOS
+ {
+ label: 'Servicios',
+ data: servicios
+ },
+
+ // Dataset COSTOS
+ {
+ label: 'Costos',
+ data: costos
+ },
+
+ // Dataset UTILIDAD (linea)
+ {
+ label: 'Utilidad',
+ data: utilidad,
+ type: 'line'
+ }
+ ]
+ },
+
+ options: {
+
+ // Hace que el gráfico se adapte automáticamente al tamanio del contenedor
+ responsive: true,
+
+ // Permite mostrar todos los valores al pasar el mouse
+ interaction: {
+ mode: 'index', // Muestra todos los datos
+ intersect: false // No hace falta tocar la línea (con estar cerca ya muestra los datos)
+ },
+
+ plugins: {
+
+ // Tooltip personalizado
+ tooltip: {
+ callbacks: {
+ label: function(context) { // CONTEXT: objeto que da Chart.js que contiene info del punto actual
+ return context.dataset.label + ': $' + context.parsed.y.toLocaleString('es-AR');
+ }
+ }
+ }
+ },
+
+ scales: {
+
+ // Eje Y (valores)
+ y: {
+ beginAtZero: true, // Fuerza a que el eje empiece desde cero
+
+ // Formateo de valores como moneda
+ ticks: {
+ callback: function(value) {
+ return '$' + value.toLocaleString('es-AR');
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // Actualizar KPIs
+
+ actualizarKpis(responseJson.kpis);
+}
+
+// FUNCIÓN CARGAR DINERO A COBRAR
+
+async function cargarACobrar(anio) {
+
+ // Llamada al backend
+
+ const response = await fetch(`index.php?opt=generar_cuentas_por_cobrar&anio=${anio}`);
+ const responseJson = await response.json();
+
+ // Transformación de meses
+
+ const meses = responseJson.meses.map(m => {
+ const nombres = ["Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"];
+ return nombres[m - 1];
+ });
+
+ // Datos (totales a cobrar por mes)
+
+ const totales = responseJson.totales.map(t => parseFloat(t));
+
+ const ctx = document.getElementById('graficoBalance').getContext('2d');
+
+ // Destruimos gráfico anterior
+
+ if (grafico) {
+ grafico.destroy();
+ }
+
+ // Creamos nuevo gráfico
+
+ grafico = new Chart(ctx, {
+ type: 'bar', // Del tipo barra
+ data: {
+ labels: meses,
+ datasets: [
+ {
+ label: 'Dinero a Cobrar',
+ data: totales
+ }
+ ]
+ },
+
+ options: {
+ responsive: true,
+ interaction: {
+ mode: 'index',
+ intersect: false
+ },
+
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: function(context) {
+ return context.dataset.label + ': $' + context.parsed.y.toLocaleString('es-AR');
+ }
+ }
+ }
+ },
+
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: {
+ callback: function(value) {
+ return '$' + value.toLocaleString('es-AR');
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // KPIs SIMPLIFICADOS
+
+ // Solo mostramos el total a cobrar
+
+ document.getElementById('kpiIngresos').innerText = '$' + responseJson.kpis.totalACobrar.toLocaleString('es-AR');
+
+ // En este contexto los demas KPIs no aplican
+
+ document.getElementById('kpiEgresos').innerText = '-';
+ document.getElementById('kpiResultado').innerText = '-';
+ document.getElementById('kpiMejorMes').innerText = '-';
+}
+
+// EVENTO: CAMBIO DE ANIO
+
+document.getElementById('anio').addEventListener('change', function() {
+
+ // Se obtiene el tipo de informe actual
+
+ const tipo = document.getElementById('tipoInforme').value;
+
+ // Segú el tipo, se llama a la función correspondiente
+
+ if (tipo === 'balance') {
+ cargarBalance(this.value);
+ } else {
+ cargarACobrar(this.value);
+ }
+});
+
+// EVENTO: CAMBIO DE TIPO DE INFORME
+
+document.getElementById('tipoInforme').addEventListener('change', function() {
+
+ const anio = document.getElementById('anio').value;
+
+ if (this.value === 'balance') {
+
+ // Cambia el título dinámicamente
+
+ document.getElementById('tituloInforme').innerHTML = ' Balance Anual';
+ cargarBalance(anio);
+
+ } else {
+ document.getElementById('tituloInforme').innerHTML = ' Dinero a Cobrar';
+ cargarACobrar(anio);
+ }
+
+});
+
+// INICIALIZACIÓN
+
+// Se obtiene el año actual automáticamente
+
+const anioInicial = new Date().getFullYear();
+
+// Se carga el balance al inciar la vista
+
+cargarBalance(anioInicial);
\ No newline at end of file
diff --git a/public/js/login/logInValidator.js b/public/js/login/logInValidator.js
new file mode 100644
index 0000000..9f4247a
--- /dev/null
+++ b/public/js/login/logInValidator.js
@@ -0,0 +1,204 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ // Mostrar errores del servidor si existen
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-login");
+ const usuarioInput = document.querySelector("input[name='usuario']");
+ const passInput = document.querySelector("input[name='password']");
+
+ if (!form || !usuarioInput || !passInput) return;
+
+ // Protección básica de intentos (localStorage)
+
+ function verificarIntentos() {
+ const ahora = Date.now();
+ const intentosData = JSON.parse(localStorage.getItem("login_intentos")) || { c: 0, t: ahora };
+
+ if (ahora - intentosData.t > 60000) {
+ intentosData.c = 0;
+ intentosData.t = ahora;
+ }
+
+ localStorage.setItem("login_intentos", JSON.stringify(intentosData));
+ return intentosData;
+ }
+
+ // Funciones utilitarias
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function hasNoSpaces(value) {
+ return !value.includes(' ');
+ }
+
+ function isValidUsername(value) {
+ return /^(?=.*[a-zA-Z])(?!.*[._]{2})[a-zA-Z0-9._]{5,30}$/.test(value);
+ }
+
+ function isValidPassword(value) {
+ return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{6,}$/.test(value);
+ }
+
+ form.addEventListener("submit", (e) => {
+ let errores = [];
+
+ const usuario = usuarioInput.value.trim();
+ const password = passInput.value.trim();
+ const intentos = verificarIntentos();
+
+ usuarioInput.classList.remove("is-invalid");
+ passInput.classList.remove("is-invalid");
+
+ // INTENTOS (se verifica primero para cortar rápido)
+
+ if (intentos.c >= 3) {
+ e.preventDefault();
+ mostrarErrores(["Demasiados intentos. Espere 1 minuto antes de volver a intentar."]);
+ return;
+ }
+
+ // USUARIO
+
+ if (isEmpty(usuario)) {
+ errores.push("El usuario es obligatorio.");
+ usuarioInput.classList.add("is-invalid");
+ } else if (!isValidUsername(usuario)) {
+ errores.push("El usuario debe tener 5–30 caracteres, incluir al menos una letra y solo usar letras, números, puntos o guiónes bajos no consecutivos.");
+ usuarioInput.classList.add("is-invalid");
+ }
+
+ // PASSWORD
+
+ if (isEmpty(password)) {
+ errores.push("La contraseña es obligatoria.");
+ passInput.classList.add("is-invalid");
+ } else if (!hasNoSpaces(password)) {
+ errores.push("La contraseña no puede contener espacios.");
+ passInput.classList.add("is-invalid");
+ } else if (!isValidPassword(password)) {
+ errores.push("La contraseña debe tener al menos una mayúscula, una minúscula y un número, y mínimo 6 caracteres.");
+ passInput.classList.add("is-invalid");
+ }
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+
+ intentos.c++;
+ localStorage.setItem("login_intentos", JSON.stringify(intentos));
+ return;
+ }
+
+ localStorage.removeItem("login_intentos");
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
+});
\ No newline at end of file
diff --git a/public/js/pedidos/pedidoTableDetalle.js b/public/js/pedidos/pedidoTableDetalle.js
new file mode 100644
index 0000000..fe0318f
--- /dev/null
+++ b/public/js/pedidos/pedidoTableDetalle.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaDetallePedido').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: 0}
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar producto...",
+ zeroRecords: "No se encontraron productos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ productos",
+ infoEmpty: "No hay productos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ productos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/pedidos/pedidoTableLista.js b/public/js/pedidos/pedidoTableLista.js
new file mode 100644
index 0000000..fbe311e
--- /dev/null
+++ b/public/js/pedidos/pedidoTableLista.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaPedidos').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar pedido...",
+ zeroRecords: "No se encontraron pedidos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ pedidos",
+ infoEmpty: "No hay pedidos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ pedidos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/pedidos/pedidoTableProducto.js b/public/js/pedidos/pedidoTableProducto.js
new file mode 100644
index 0000000..83e0c3a
--- /dev/null
+++ b/public/js/pedidos/pedidoTableProducto.js
@@ -0,0 +1,105 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaProductos').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 6,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: -1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar productos...",
+ zeroRecords: "No se encontraron productos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ productos",
+ infoEmpty: "No hay productos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ productos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Mover buscador al bloque izquierdo
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+
+ // (Para poder guardar todos los datos en caso de que el producto no esté visible)
+
+ $('form').on('submit', function () {
+
+ const form = this;
+
+ // Eliminar hidden generados anteriormente
+ $(form).find('.dt-hidden').remove();
+
+ // Recorrer TODAS las filas del DataTable
+ table.rows().every(function () {
+
+ const row = $(this.node());
+ const checkbox = row.find('input[name="ids[]"]');
+
+ if (checkbox.is(':checked')) {
+
+ const id = checkbox.val();
+ const cantidad = row.find('input[name="cantidades[' + id + ']"]').val();
+ const costo = row.find('input[name="costos[' + id + ']"]').val();
+
+ // ID
+ $('')
+ .attr('type', 'hidden')
+ .attr('name', 'ids[]')
+ .val(id)
+ .addClass('dt-hidden')
+ .appendTo(form);
+
+ // Cantidad
+ $('')
+ .attr('type', 'hidden')
+ .attr('name', 'cantidades[' + id + ']')
+ .val(cantidad)
+ .addClass('dt-hidden')
+ .appendTo(form);
+
+ // Costo
+ $('')
+ .attr('type', 'hidden')
+ .attr('name', 'costos[' + id + ']')
+ .val(costo)
+ .addClass('dt-hidden')
+ .appendTo(form);
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/pedidos/pedidoTableProveedor.js b/public/js/pedidos/pedidoTableProveedor.js
new file mode 100644
index 0000000..320ce34
--- /dev/null
+++ b/public/js/pedidos/pedidoTableProveedor.js
@@ -0,0 +1,58 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaProveedores').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar proveedores...",
+ zeroRecords: "No se encontraron proveedores",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ proveedores",
+ infoEmpty: "No hay proveedores para mostrar",
+ infoFiltered: "(filtrado de _MAX_ proveedores totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/pedidos/pedidoValidator.js b/public/js/pedidos/pedidoValidator.js
new file mode 100644
index 0000000..c7ca6a9
--- /dev/null
+++ b/public/js/pedidos/pedidoValidator.js
@@ -0,0 +1,110 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/presupuestos/presupuestoTableCliente.js b/public/js/presupuestos/presupuestoTableCliente.js
new file mode 100644
index 0000000..ce111d7
--- /dev/null
+++ b/public/js/presupuestos/presupuestoTableCliente.js
@@ -0,0 +1,58 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaClientes').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar clientes...",
+ zeroRecords: "No se encontraron clientes",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ clientes",
+ infoEmpty: "No hay clientes para mostrar",
+ infoFiltered: "(filtrado de _MAX_ clientes totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/presupuestos/presupuestoTableProducto.js b/public/js/presupuestos/presupuestoTableProducto.js
new file mode 100644
index 0000000..5b52f55
--- /dev/null
+++ b/public/js/presupuestos/presupuestoTableProducto.js
@@ -0,0 +1,83 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaProductos').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 6,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: [- 1, - 2] }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar productos...",
+ zeroRecords: "No se encontraron productos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ productos",
+ infoEmpty: "No hay productos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ productos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Mover buscador al bloque izquierdo
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+
+ // SUBMIT (problema de checkbox)
+
+ $('form').on('submit', function () {
+
+ const form = this;
+
+ // Obtener TODOS los checkboxes marcados del DataTable (no solo los visibles)
+
+ const checked = table.$('input[name="ids[]"]:checked');
+
+ // Eliminar cualquier ids[] previo agregado dinámicamente
+
+ $(form).find('input[name="ids[]"][type="hidden"]').remove();
+
+ // Agregar hidden inputs reales al form
+
+ checked.each(function () {
+ $('')
+ .attr('type', 'hidden')
+ .attr('name', 'ids[]')
+ .val($(this).val())
+ .appendTo(form);
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/presupuestos/presupuestoValidator.js b/public/js/presupuestos/presupuestoValidator.js
new file mode 100644
index 0000000..f72bc12
--- /dev/null
+++ b/public/js/presupuestos/presupuestoValidator.js
@@ -0,0 +1,191 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-presupuesto");
+ if (!form) return;
+
+ // Utilidades
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function toFloat(value) {
+ return parseFloat(value) || 0;
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const condIva = form.querySelector("[name='cond_iva']");
+ const cuit = form.querySelector("[name='cuit']");
+ const formaPago = form.querySelector("[name='forma_pago']");
+ const tipoPago = form.querySelector("[name='tipo_pago']");
+ const descuento = form.querySelector("[name='descuento']");
+ const recargo = form.querySelector("[name='recargo']");
+
+ // limpiar estados previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // FORMA Y TIPO DE PAGO
+
+ const reglasPago = {
+ 'efectivo': ['pago_unico'],
+ 'transferencia': ['pago_unico'],
+ 'echeq': ['pago_unico'],
+ 'debito': ['pago_unico'],
+ 'credito': ['pago_unico', '3_cuotas', '6_cuotas', '12_cuotas'],
+ };
+
+ if (formaPago && tipoPago) {
+
+ const tiposValidos = reglasPago[formaPago.value] || [];
+
+ if (!tiposValidos.includes(tipoPago.value)) {
+ errores.push("Combinación de forma y tipo de pago inválida.");
+ formaPago.classList.add("is-invalid");
+ tipoPago.classList.add("is-invalid");
+ }
+ }
+
+ // DESCUENTO / RECARGO
+
+ if (descuento) {
+ const d = toFloat(descuento.value);
+
+ if (d < 0 || d > 100) {
+ errores.push("El descuento debe estar entre 0 y 100%.");
+ descuento.classList.add("is-invalid");
+ }
+ }
+
+ if (recargo) {
+ const r = toFloat(recargo.value);
+
+ if (r < 0 || r > 100) {
+ errores.push("El recargo debe estar entre 0 y 100%.");
+ recargo.classList.add("is-invalid");
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/productos/categoriaTableLista.js b/public/js/productos/categoriaTableLista.js
new file mode 100644
index 0000000..f680923
--- /dev/null
+++ b/public/js/productos/categoriaTableLista.js
@@ -0,0 +1,58 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaCategorias').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar categorías...",
+ zeroRecords: "No se encontraron categorías",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ categorías",
+ infoEmpty: "No hay categorías para mostrar",
+ infoFiltered: "(filtrado de _MAX_ categorías totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/productos/categoriaValidator.js b/public/js/productos/categoriaValidator.js
new file mode 100644
index 0000000..7b8c951
--- /dev/null
+++ b/public/js/productos/categoriaValidator.js
@@ -0,0 +1,211 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-categoria") || document.getElementById("form-editar-categoria");
+ if (!form) return;
+
+ // Utilidades
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function isValidName(value) {
+ return /^[A-Za-zÁÉÍÓÚÑáéíóúñ0-9]+(?: [A-Za-zÁÉÍÓÚÑáéíóúñ0-9]+)*$/.test(value);
+ }
+
+ function isValidNumero(value) {
+ return /^\d{1,3}(\.\d{1,2})?$/.test(value);
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const nombre = form.querySelector("input[name='nombre']");
+ const ganancia = form.querySelector("input[name='porcentaje_ganancia']");
+ const descuento = form.querySelector("input[name='descuento']");
+
+ // limpiar errores previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // NOMBRE
+
+ if (nombre) {
+ if (isEmpty(nombre.value)) {
+ errores.push("El nombre es obligatorio.");
+ nombre.classList.add("is-invalid");
+ } else if (!isValidName(nombre.value)) {
+ errores.push("El nombre solo puede contener letras, números y espacios.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length < 2) {
+ errores.push("El nombre debe tener al menos 2 caracteres.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length > 50) {
+ errores.push("El nombre no puede superar los 50 caracteres.");
+ nombre.classList.add("is-invalid");
+ }
+ }
+
+ // GANANCIA (opcional)
+
+ if (ganancia) {
+ const value = ganancia.value.trim();
+
+ if (value !== '') {
+
+ if (!isValidNumero(value)) {
+ errores.push("La ganancia debe ser un número válido con hasta 2 decimales.");
+ ganancia.classList.add("is-invalid");
+ } else {
+ const num = Number(value);
+
+ if (num <= 0 || num > 100) {
+ errores.push("La ganancia debe ser mayor a 0 y hasta 100.");
+ ganancia.classList.add("is-invalid");
+ }
+ }
+ }
+ }
+
+ // DESCUENTO (opcional)
+
+ if (descuento) {
+ const value = descuento.value.trim();
+
+ if (value !== '') {
+
+ if (!isValidNumero(value)) {
+ errores.push("El descuento debe ser un número válido con hasta 2 decimales.");
+ descuento.classList.add("is-invalid");
+ } else {
+ const num = Number(value);
+
+ if (num < 0 || num > 100) {
+ errores.push("El descuento debe estar entre 0 y 100.");
+ descuento.classList.add("is-invalid");
+ }
+ }
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/productos/productoTableLista.js b/public/js/productos/productoTableLista.js
new file mode 100644
index 0000000..477bb13
--- /dev/null
+++ b/public/js/productos/productoTableLista.js
@@ -0,0 +1,58 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaProductos').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar productos...",
+ zeroRecords: "No se encontraron productos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ productos",
+ infoEmpty: "No hay productos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ productos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/productos/productoTableListaInactivos.js b/public/js/productos/productoTableListaInactivos.js
new file mode 100644
index 0000000..720ca46
--- /dev/null
+++ b/public/js/productos/productoTableListaInactivos.js
@@ -0,0 +1,58 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaProductosInactivos').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar productos...",
+ zeroRecords: "No se encontraron productos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ productos",
+ infoEmpty: "No hay productos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ productos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/productos/productoValidator.js b/public/js/productos/productoValidator.js
new file mode 100644
index 0000000..e668da6
--- /dev/null
+++ b/public/js/productos/productoValidator.js
@@ -0,0 +1,276 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-producto") || document.getElementById("form-editar-producto");
+ if (!form) return;
+
+ // Desactivar campo descuento si el campo tipo es "servicio"
+
+ const selectTipo = form.querySelector("[name='tipo']");
+ const inputDescuento = form.querySelector("input[name='descuento']");
+
+ const TIPO_SERVICIO_ID = "2"; // ID de tipo "servicio"
+
+ let descuentoPrevio = '';
+
+ function toggleDescuento() {
+ if (!selectTipo || !inputDescuento) return;
+
+ if (selectTipo.value === TIPO_SERVICIO_ID) {
+
+ // Guardar valor actual antes de limpiar
+ descuentoPrevio = inputDescuento.value;
+
+ inputDescuento.value = '';
+ inputDescuento.readOnly = true;
+ inputDescuento.classList.add('campo-readonly');
+
+ } else {
+
+ inputDescuento.readOnly = false;
+ inputDescuento.classList.remove('campo-readonly');
+
+ // Restaurar si había algo antes
+ if (descuentoPrevio !== '') {
+ inputDescuento.value = descuentoPrevio;
+ }
+ }
+ }
+
+ // Evento change
+
+ if (selectTipo) {
+ selectTipo.addEventListener("change", toggleDescuento);
+ }
+
+ // Ejecutar al cargar
+
+ toggleDescuento();
+
+ // Utilidades
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function isValidMarca(value) {
+ return /^[A-Za-zÁÉÍÓÚÑáéíóúñ0-9]+(?: [A-Za-zÁÉÍÓÚÑáéíóúñ0-9]+)*$/.test(value);
+ }
+
+ function isValidModelo(value) {
+ return /^[A-Za-zÁÉÍÓÚÑáéíóúñ0-9.\-]+(?: [A-Za-zÁÉÍÓÚÑáéíóúñ0-9.\-]+)*$/.test(value);
+ }
+
+ function isValidNumero(value) {
+ return /^\d{1,3}(\.\d{1,2})?$/.test(value);
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const marca = form.querySelector("input[name='marca']");
+ const modelo = form.querySelector("input[name='modelo']");
+ const descuento = form.querySelector("input[name='descuento']");
+ const categoria = form.querySelector("[name='categoria']");
+ const tipo = form.querySelector("[name='tipo']");
+
+ // Limpiar errores previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // MARCA
+
+ if (marca) {
+ if (isEmpty(marca.value)) {
+ errores.push("La marca es obligatoria.");
+ marca.classList.add("is-invalid");
+ } else if (!isValidMarca(marca.value)) {
+ errores.push("La marca solo puede contener letras, números y espacios.");
+ marca.classList.add("is-invalid");
+ } else if (marca.value.length < 2) {
+ errores.push("La marca debe tener al menos 2 caracteres.");
+ marca.classList.add("is-invalid");
+ } else if (marca.value.length > 50) {
+ errores.push("La marca no puede superar los 50 caracteres.");
+ marca.classList.add("is-invalid");
+ }
+ }
+
+ // MODELO
+
+ if (modelo) {
+ if (isEmpty(modelo.value)) {
+ errores.push("El modelo es obligatorio.");
+ modelo.classList.add("is-invalid");
+ } else if (!isValidModelo(modelo.value)) {
+ errores.push("El modelo solo puede contener letras, números, espacios, guiones y puntos.");
+ modelo.classList.add("is-invalid");
+ } else if (modelo.value.length < 2) {
+ errores.push("El modelo debe tener al menos 2 caracteres.");
+ modelo.classList.add("is-invalid");
+ } else if (modelo.value.length > 50) {
+ errores.push("El modelo no puede superar los 50 caracteres.");
+ modelo.classList.add("is-invalid");
+ }
+ }
+
+ // DESCUENTO
+
+ if (descuento && tipo && tipo.value !== TIPO_SERVICIO_ID) {
+
+ const value = descuento.value.trim();
+
+ if (value !== '') {
+
+ if (!isValidNumero(value)) {
+ errores.push("El descuento debe ser un número válido con hasta 2 decimales.");
+ descuento.classList.add("is-invalid");
+ } else {
+ const num = Number(value);
+
+ if (num < 0 || num > 100) {
+ errores.push("El descuento debe estar entre 0 y 100.");
+ descuento.classList.add("is-invalid");
+ }
+ }
+ }
+ }
+
+ // CATEGORIA
+
+ if (categoria) {
+ if (isEmpty(categoria.value)) {
+ errores.push("La categoría es obligatoria.");
+ categoria.classList.add("is-invalid");
+ }
+ }
+
+ // TIPO
+
+ if (tipo) {
+ if (isEmpty(tipo.value)) {
+ errores.push("El tipo es obligatorio.");
+ tipo.classList.add("is-invalid");
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/proveedores/proveedorTableLista.js b/public/js/proveedores/proveedorTableLista.js
new file mode 100644
index 0000000..3533637
--- /dev/null
+++ b/public/js/proveedores/proveedorTableLista.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaProveedores').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar proveedor...",
+ zeroRecords: "No se encontraron proveedores",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ proveedores",
+ infoEmpty: "No hay proveedores para mostrar",
+ infoFiltered: "(filtrado de _MAX_ proveedores totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/proveedores/proveedorValidator.js b/public/js/proveedores/proveedorValidator.js
new file mode 100644
index 0000000..2e4315d
--- /dev/null
+++ b/public/js/proveedores/proveedorValidator.js
@@ -0,0 +1,254 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-proveedor") || document.getElementById("form-editar-proveedor");
+ if (!form) return;
+
+ // Funciones utilitarias
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function isValidEmail(email) {
+ return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
+ }
+
+ function isValidName(value) {
+ return /^[A-Za-zÁÉÍÓÚÑáéíóúñ0-9&',.\-]+(?: [A-Za-zÁÉÍÓÚÑáéíóúñ0-9&',.\-]+)*(?: (S\.A\.|S\.R\.L\.|S\.C\.|S\.C\.P\.|S\.A\.S\.))?$/.test(value);
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const razon_social = form.querySelector("input[name='razon_social']");
+ const telefono = form.querySelector("input[name='telefono']");
+ const email = form.querySelector("input[name='email']");
+
+ // Limpiar estados previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // RAZÓN SOCIAL
+
+ if (razon_social) {
+ if (isEmpty(razon_social.value)) {
+ errores.push("La razón social es obligatorio.");
+ razon_social.classList.add("is-invalid");
+ } else if (!isValidName(razon_social.value)) {
+ errores.push("La razón social contiene caracteres inválidos o la abreviatura legal final no es correcta.");
+ razon_social.classList.add("is-invalid");
+ } else if (razon_social.value.length < 2) {
+ errores.push("La razón social debe tener al menos 2 caracteres.");
+ razon_social.classList.add("is-invalid");
+ } else if (razon_social.value.length > 50) {
+ errores.push("La razón social no puede superar los 50 caracteres.");
+ razon_social.classList.add("is-invalid");
+ }
+ }
+
+ // TELÉFONO
+
+ if (telefono) {
+ if (isEmpty(telefono.value)) {
+ errores.push("El teléfono es obligatorio.");
+ telefono.classList.add("is-invalid");
+ } else if (telefono.value.length != 10) {
+ errores.push("El teléfono debe poseer 10 dígitos.");
+ telefono.classList.add("is-invalid");
+ }
+ }
+
+ // EMAIL
+
+ if (email) {
+ if (isEmpty(email.value)) {
+ errores.push("El email es obligatorio.");
+ email.classList.add("is-invalid");
+ } else if (!isValidEmail(email.value)) {
+ errores.push("El email no tiene un formato válido.");
+ email.classList.add("is-invalid");
+ } else if (email.value.length > 100) {
+ errores.push("El email no puede superar los 100 caracteres.");
+ email.classList.add("is-invalid");
+ }
+ }
+
+ // CONDICIÓN IVA
+
+ const condIVA = form.querySelector("select[name='cond_iva']");
+
+ const condicionesPermitidas = [
+ "consumidor_final",
+ "monotributo",
+ "responsable_inscripto"
+ ];
+
+ if (condIVA) {
+
+ if (!condicionesPermitidas.includes(condIVA.value)) {
+
+ errores.push("Condición de IVA inválida.");
+ condIVA.classList.add("is-invalid");
+ }
+ }
+
+ // CUIT
+
+ const cuit1 = form.querySelector("input[name='cuit_1']");
+ const cuit2 = form.querySelector("input[name='cuit_2']");
+ const cuit3 = form.querySelector("input[name='cuit_3']");
+
+ if (cuit1 && cuit2 && cuit3) {
+
+ const cuit = cuit1.value.trim() + cuit2.value.trim() + cuit3.value.trim();
+
+ // Monotributo y Responsable Inscripto:
+ // CUIT obligatorio
+
+ if (condIVA && condIVA.value !== "consumidor_final" && cuit === "") {
+
+ errores.push("Debe ingresar el CUIT.");
+
+ cuit1.classList.add("is-invalid");
+ cuit2.classList.add("is-invalid");
+ cuit3.classList.add("is-invalid");
+ }
+
+ // Si se ingresó algo, validar formato
+
+ if (cuit !== "") {
+
+ if (!/^\d{11}$/.test(cuit)) {
+
+ errores.push(
+ "El CUIT debe contener exactamente 11 dígitos."
+ );
+
+ cuit1.classList.add("is-invalid");
+ cuit2.classList.add("is-invalid");
+ cuit3.classList.add("is-invalid");
+ }
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/servicios/servicioTableCliente.js b/public/js/servicios/servicioTableCliente.js
new file mode 100644
index 0000000..ce111d7
--- /dev/null
+++ b/public/js/servicios/servicioTableCliente.js
@@ -0,0 +1,58 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaClientes').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar clientes...",
+ zeroRecords: "No se encontraron clientes",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ clientes",
+ infoEmpty: "No hay clientes para mostrar",
+ infoFiltered: "(filtrado de _MAX_ clientes totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/servicios/servicioTableDetalleInsumos.js b/public/js/servicios/servicioTableDetalleInsumos.js
new file mode 100644
index 0000000..3488af9
--- /dev/null
+++ b/public/js/servicios/servicioTableDetalleInsumos.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaDetalleVenta').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false}
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar insumo...",
+ zeroRecords: "No se encontraron insumos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ insumos",
+ infoEmpty: "No hay insumos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ insumos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/servicios/servicioTableInsumo.js b/public/js/servicios/servicioTableInsumo.js
new file mode 100644
index 0000000..efd9040
--- /dev/null
+++ b/public/js/servicios/servicioTableInsumo.js
@@ -0,0 +1,83 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaInsumos').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 6,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: [- 1, - 2] }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar insumo...",
+ zeroRecords: "No se encontraron insumos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ insumos",
+ infoEmpty: "No hay insumos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ insumos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Mover buscador al bloque izquierdo
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+
+ // SUBMIT (problema de checkbox)
+
+ $('form').on('submit', function () {
+
+ const form = this;
+
+ // Obtener TODOS los checkboxes marcados del DataTable (no solo los visibles)
+
+ const checked = table.$('input[name="ids[]"]:checked');
+
+ // Eliminar cualquier ids[] previo agregado dinámicamente
+
+ $(form).find('input[name="ids[]"][type="hidden"]').remove();
+
+ // Agregar hidden inputs reales al form
+
+ checked.each(function () {
+ $('')
+ .attr('type', 'hidden')
+ .attr('name', 'ids[]')
+ .val($(this).val())
+ .appendTo(form);
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/servicios/servicioTableLista.js b/public/js/servicios/servicioTableLista.js
new file mode 100644
index 0000000..c9dc73d
--- /dev/null
+++ b/public/js/servicios/servicioTableLista.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaServicios').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar servicio...",
+ zeroRecords: "No se encontraron servicios",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ servicios",
+ infoEmpty: "No hay servicios para mostrar",
+ infoFiltered: "(filtrado de _MAX_ servicios totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/servicios/servicioValidator.js b/public/js/servicios/servicioValidator.js
new file mode 100644
index 0000000..67d8840
--- /dev/null
+++ b/public/js/servicios/servicioValidator.js
@@ -0,0 +1,225 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-servicio");
+ if (!form) return;
+
+ // Funciones utilitarias
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function isValidEmail(value) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const descripcionEquipo = form.querySelector("input[name='descripcion_equipo']");
+ const contraseniaEquipo = form.querySelector("input[name='contrasenia_equipo']");
+ const cuenta = form.querySelector("input[name='cuenta']");
+ const contraseniaCuenta = form.querySelector("input[name='contrasenia_cuenta']");
+ const descripcionProblema = form.querySelector("textarea[name='descripcion_problema']");
+ const observacion = form.querySelector("textarea[name='observacion']");
+
+ // Limpiar estados previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // DESCRIPCIÓN DEL EQUIPO
+
+ if (descripcionEquipo) {
+ if (isEmpty(descripcionEquipo.value)) {
+ errores.push("La descripción del equipo es obligatoria.");
+ descripcionEquipo.classList.add("is-invalid");
+ } else if (descripcionEquipo.value.length > 100) {
+ errores.push("La descripción del equipo es demasiado larga.");
+ descripcionEquipo.classList.add("is-invalid");
+ }
+ }
+
+ // DESCRIPCIÓN DEL PROBLEMA
+
+ if (descripcionProblema) {
+ if (isEmpty(descripcionProblema.value)) {
+ errores.push("Debe describir el problema.");
+ descripcionProblema.classList.add("is-invalid");
+ } else if (descripcionProblema.value.length > 2000) {
+ errores.push("La descripción del problema es demasiado larga.");
+ descripcionProblema.classList.add("is-invalid");
+ }
+ }
+
+ // CONTRASENIA EQUIPO
+
+ if (contraseniaEquipo) {
+ if (contraseniaEquipo.value.length > 100) {
+ errores.push("Contraseña demasiado larga.");
+ contraseniaEquipo.classList.add("is-invalid");
+ }
+
+ if (!isEmpty(contraseniaEquipo.value) && isEmpty(descripcionEquipo.value)) {
+ errores.push("Debe indicar el equipo si ingresa contraseña.");
+ descripcionEquipo.classList.add("is-invalid");
+ }
+ }
+
+ // CUENTA
+
+ if (cuenta) {
+ if (cuenta.value.length > 100) {
+ errores.push("Cuenta demasiado larga.");
+ cuenta.classList.add("is-invalid");
+ }
+
+ if (!isEmpty(cuenta.value) && !isValidEmail(cuenta.value)) {
+ errores.push("El email de la cuenta no es válido.");
+ cuenta.classList.add("is-invalid");
+ }
+ }
+
+ // CONTRASENIA CUENTA
+
+ if (contraseniaCuenta) {
+ if (contraseniaCuenta.value.length > 100) {
+ errores.push("Contraseña de cuenta demasiado larga.");
+ contraseniaCuenta.classList.add("is-invalid");
+ }
+
+ if (!isEmpty(contraseniaCuenta.value) && isEmpty(cuenta.value)) {
+ errores.push("Debe indicar la cuenta si ingresa contraseña.");
+ cuenta.classList.add("is-invalid");
+ }
+ }
+
+ // OBSERVACIÓN
+
+ if (observacion) {
+ if (observacion.value.length > 500) {
+ errores.push("La descripción de la observación es demasiada larga.");
+ observacion.classList.add("is-invalid");
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/usuarios/usuarioTableLista.js b/public/js/usuarios/usuarioTableLista.js
new file mode 100644
index 0000000..14bdd31
--- /dev/null
+++ b/public/js/usuarios/usuarioTableLista.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaUsuarios').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar proveedor...",
+ zeroRecords: "No se encontraron proveedores",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ proveedores",
+ infoEmpty: "No hay proveedores para mostrar",
+ infoFiltered: "(filtrado de _MAX_ usuarios totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/usuarios/usuarioValidator.js b/public/js/usuarios/usuarioValidator.js
new file mode 100644
index 0000000..d46a0c8
--- /dev/null
+++ b/public/js/usuarios/usuarioValidator.js
@@ -0,0 +1,256 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-usuario") || document.getElementById("form-editar-usuario");
+ if (!form) return;
+
+ // Si no hay submit (operador), no validar
+
+ if (!form.querySelector("button[type='submit']")) return;
+
+ // Funciones utilitarias
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function hasNoSpaces(value) {
+ return !value.includes(' ');
+ }
+
+ function isValidEmail(email) {
+ return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
+ }
+
+ function isValidName(value) {
+ return /^[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?: [A-ZÁÉÍÓÚÑ][a-záéíóúñ]+)*$/.test(value);
+ }
+
+ function isValidUsername(value) {
+ return /^(?=.*[a-zA-Z])(?!.*[._]{2})[a-zA-Z0-9._]{5,30}$/.test(value);
+ }
+
+ function isValidPassword(value) {
+ return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{6,}$/.test(value);
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const nombre = form.querySelector("input[name='nombre']");
+ const apellido = form.querySelector("input[name='apellido']");
+ const usuario = form.querySelector("input[name='usuario']");
+ const telefono = form.querySelector("input[name='telefono']");
+ const email = form.querySelector("input[name='email']");
+ const password = form.querySelector("input[name='password']");
+
+ // Limpiar estados previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // NOMBRE
+
+ if (nombre) {
+ if (isEmpty(nombre.value)) {
+ errores.push("El nombre es obligatorio.");
+ nombre.classList.add("is-invalid");
+ } else if (!isValidName(nombre.value)) {
+ errores.push("El nombre debe comenzar con mayúscula y solo contener letras.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length < 2) {
+ errores.push("El nombre debe tener al menos 2 caracteres.");
+ nombre.classList.add("is-invalid");
+ } else if (nombre.value.length > 50) {
+ errores.push("El nombre no puede superar los 50 caracteres.");
+ nombre.classList.add("is-invalid");
+ }
+ }
+
+ // APELLIDO
+
+ if (apellido) {
+ if (isEmpty(apellido.value)) {
+ errores.push("El apellido es obligatorio.");
+ apellido.classList.add("is-invalid");
+ } else if (!isValidName(apellido.value)) {
+ errores.push("El apellido debe comenzar con mayúscula y solo contener letras.");
+ apellido.classList.add("is-invalid");
+ } else if (apellido.value.length < 2) {
+ errores.push("El apellido debe tener al menos 2 caracteres.");
+ apellido.classList.add("is-invalid");
+ } else if (apellido.value.length > 50) {
+ errores.push("El apellido no puede superar los 50 caracteres.");
+ apellido.classList.add("is-invalid");
+ }
+ }
+
+ // USUARIO
+
+ if (isEmpty(usuario)) {
+ errores.push("El usuario es obligatorio.");
+ usuarioInput.classList.add("is-invalid");
+ } else if (!isValidUsername(usuario)) {
+ errores.push("El usuario debe tener 5–30 caracteres, incluir al menos una letra y solo usar letras, números, puntos o guiónes bajos no consecutivos.");
+ usuarioInput.classList.add("is-invalid");
+ }
+
+ // TELÉFONO
+
+ if (telefono) {
+ if (isEmpty(telefono.value)) {
+ errores.push("El teléfono es obligatorio.");
+ telefono.classList.add("is-invalid");
+ } else if (telefono.value.length != 10) {
+ errores.push("El teléfono debe poseer 10 dígitos.");
+ telefono.classList.add("is-invalid");
+ }
+ }
+
+ // EMAIL
+
+ if (email) {
+ if (isEmpty(email.value)) {
+ errores.push("El email es obligatorio.");
+ email.classList.add("is-invalid");
+ } else if (!isValidEmail(email.value)) {
+ errores.push("El email no tiene un formato válido.");
+ email.classList.add("is-invalid");
+ } else if (email.value.length > 100) {
+ errores.push("El email no puede superar los 100 caracteres.");
+ email.classList.add("is-invalid");
+ }
+ }
+
+ // PASSWORD
+
+ if (isEmpty(password)) {
+ errores.push("La contraseña es obligatoria.");
+ passInput.classList.add("is-invalid");
+ } else if (!hasNoSpaces(password)) {
+ errores.push("La contraseña no puede contener espacios.");
+ passInput.classList.add("is-invalid");
+ } else if (!isValidPassword(password)) {
+ errores.push("La contraseña debe tener al menos una mayúscula, una minúscula y un número, y mínimo 6 caracteres.");
+ passInput.classList.add("is-invalid");
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file
diff --git a/public/js/ventas/ventaTableCliente.js b/public/js/ventas/ventaTableCliente.js
new file mode 100644
index 0000000..ce111d7
--- /dev/null
+++ b/public/js/ventas/ventaTableCliente.js
@@ -0,0 +1,58 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaClientes').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar clientes...",
+ zeroRecords: "No se encontraron clientes",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ clientes",
+ infoEmpty: "No hay clientes para mostrar",
+ infoFiltered: "(filtrado de _MAX_ clientes totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/ventas/ventaTableDetalle.js b/public/js/ventas/ventaTableDetalle.js
new file mode 100644
index 0000000..1796967
--- /dev/null
+++ b/public/js/ventas/ventaTableDetalle.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaDetalleVenta').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false}
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar producto...",
+ zeroRecords: "No se encontraron productos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ productos",
+ infoEmpty: "No hay productos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ productos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/ventas/ventaTableLista.js b/public/js/ventas/ventaTableLista.js
new file mode 100644
index 0000000..d3162ee
--- /dev/null
+++ b/public/js/ventas/ventaTableLista.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaVentas').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 7,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: - 1 }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar venta...",
+ zeroRecords: "No se encontraron ventas",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ ventas",
+ infoEmpty: "No hay ventas para mostrar",
+ infoFiltered: "(filtrado de _MAX_ ventas totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Insertamos el select al lado del buscador
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center')
+ .append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/ventas/ventaTableProducto.js b/public/js/ventas/ventaTableProducto.js
new file mode 100644
index 0000000..488ac70
--- /dev/null
+++ b/public/js/ventas/ventaTableProducto.js
@@ -0,0 +1,83 @@
+document.addEventListener('DOMContentLoaded', function () {
+
+ const table = $('#tablaProductos').DataTable({
+ paging: true,
+ searching: true,
+ ordering: true,
+ pageLength: 6,
+ lengthChange: false,
+ info: true,
+ autoWidth: false,
+
+ columnDefs: [
+ { orderable: false, targets: [-1, -2] }
+ ],
+
+ dom:
+ "<'row mb-3'<'col-md-8'f><'col-md-4 text-end'l>>" +
+ "<'row'<'col-12'tr>>" +
+ "<'row mt-3'<'col-md-5'i><'col-md-7'p>>",
+
+ language: {
+ search: "",
+ searchPlaceholder: "Buscar productos...",
+ zeroRecords: "No se encontraron productos",
+ info: "Mostrando _START_ a _END_ de _TOTAL_ productos",
+ infoEmpty: "No hay productos para mostrar",
+ infoFiltered: "(filtrado de _MAX_ productos totales)",
+ paginate: {
+ next: "›",
+ previous: "‹"
+ }
+ }
+ });
+
+ // Mover buscador al bloque izquierdo
+
+ $('.dataTables_filter')
+ .addClass('d-flex align-items-center gap-2')
+ .appendTo('#filtrosIzquierda');
+
+ $('#filtrosIzquierda').append($('#columnFilter'));
+
+ const searchInput = $('.dataTables_filter input');
+
+ $('#columnFilter').on('change', function () {
+ const colIndex = $(this).val();
+
+ table.search('').columns().search('').draw();
+
+ searchInput.off('keyup').on('keyup', function () {
+ if (colIndex === "") {
+ table.search(this.value).draw();
+ } else {
+ table.column(colIndex).search(this.value).draw();
+ }
+ });
+ });
+
+ // SUBMIT (problema de checkbox)
+
+ $('form').on('submit', function () {
+
+ const form = this;
+
+ // Obtener TODOS los checkboxes marcados del DataTable (no solo los visibles)
+
+ const checked = table.$('input[name="ids[]"]:checked');
+
+ // Eliminar cualquier ids[] previo agregado dinámicamente
+
+ $(form).find('input[name="ids[]"][type="hidden"]').remove();
+
+ // Agregar hidden inputs reales al form
+
+ checked.each(function () {
+ $('')
+ .attr('type', 'hidden')
+ .attr('name', 'ids[]')
+ .val($(this).val())
+ .appendTo(form);
+ });
+ });
+});
\ No newline at end of file
diff --git a/public/js/ventas/ventaValidator.js b/public/js/ventas/ventaValidator.js
new file mode 100644
index 0000000..e2f1783
--- /dev/null
+++ b/public/js/ventas/ventaValidator.js
@@ -0,0 +1,193 @@
+document.addEventListener("DOMContentLoaded", () => {
+
+ if (serverErrors.length > 0) mostrarErrores(serverErrors);
+ if (serverSuccess.length > 0) mostrarSuccess(serverSuccess);
+
+ const form = document.getElementById("form-crear-venta");
+ if (!form) return;
+
+ // Utilidades
+
+ function isEmpty(value) {
+ return !value || value.trim() === '';
+ }
+
+ function toFloat(value) {
+ return parseFloat(value) || 0;
+ }
+
+ // Submit
+
+ form.addEventListener("submit", (e) => {
+
+ let errores = [];
+
+ const condIva = form.querySelector("[name='cond_iva']");
+ const cuit = form.querySelector("[name='cuit']");
+ const formaPago = form.querySelector("[name='forma_pago']");
+ const tipoPago = form.querySelector("[name='tipo_pago']");
+ const descuento = form.querySelector("[name='descuento']");
+ const recargo = form.querySelector("[name='recargo']");
+
+ // limpiar estados previos
+
+ form.querySelectorAll(".is-invalid").forEach(el =>
+ el.classList.remove("is-invalid")
+ );
+
+ // FORMA Y TIPO DE PAGO
+
+ const reglasPago = {
+ 'efectivo': ['pago_unico'],
+ 'transferencia': ['pago_unico'],
+ 'echeq': ['pago_unico'],
+ 'debito': ['pago_unico'],
+ 'credito': ['pago_unico', '3_cuotas', '6_cuotas', '12_cuotas'],
+ };
+
+ if (formaPago && tipoPago) {
+
+ const tiposValidos = reglasPago[formaPago.value] || [];
+
+ if (!tiposValidos.includes(tipoPago.value)) {
+ errores.push("Combinación de forma y tipo de pago inválida.");
+ formaPago.classList.add("is-invalid");
+ tipoPago.classList.add("is-invalid");
+ }
+ }
+
+ // DESCUENTO / RECARGO
+
+ if (descuento) {
+ const d = toFloat(descuento.value);
+
+ if (d < 0 || d > 100) {
+ errores.push("El descuento debe estar entre 0 y 100%.");
+ descuento.classList.add("is-invalid");
+ }
+ }
+
+ // RECARGO
+
+ if (recargo) {
+ const r = toFloat(recargo.value);
+
+ if (r < 0 || r > 100) {
+ errores.push("El recargo debe estar entre 0 y 100%.");
+ recargo.classList.add("is-invalid");
+ }
+ }
+
+ // Resultado
+
+ if (errores.length > 0) {
+ e.preventDefault();
+ mostrarErrores(errores);
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR ERRORES
+
+ function mostrarErrores(errores) {
+
+ // Buscar si hay un toast previo
+
+ const viejo = document.getElementById("toastErrores");
+
+ // Si existe, se elimina (evita duplicados)
+
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID para identificarlo
+
+ toast.id = "toastErrores";
+
+ // Clases de estilo (Bootstrap + CSS)
+
+ toast.className = "toast-flotante alert alert-danger";
+
+ // Inserta el contenido dinámico (lista de errores)
+
+ toast.innerHTML = `
+ Se encontraron errores:
+
+ ${errores.map(e => `- ${e}
`).join("")}
+
+ `;
+
+ // Agrega el toast al body (lo hace visible)
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre
+
+ setTimeout(() => {
+
+ // Agrega clase para animación de salida (CSS)
+
+ toast.classList.add("toast-out");
+
+ // Elimina el elemento después de la animación
+
+ setTimeout(() => {
+ toast.remove(); // Elimina el DOM
+ }, 350);
+
+ }, 5000); // Visible durante 5 segundos
+ }
+ });
+
+ // FUNCIÓN PARA MOSTRAR SUCCESS
+
+ function mostrarSuccess(mensajes) {
+
+ // Elimina toast previo de éxito si existe
+
+ const viejo = document.getElementById("toastSuccess");
+ if (viejo) viejo.remove();
+
+ // Crea el contenedor del toast
+
+ const toast = document.createElement("div");
+
+ // Se le asigna ID único
+
+ toast.id = "toastSuccess";
+
+ // Estilo
+
+ toast.className = "toast-flotante alert alert-success";
+
+ // Inserta mensajes dinámicos
+
+ toast.innerHTML = `
+ Mensajes de éxito:
+
+ ${mensajes.map(m => `- ${m}
`).join("")}
+
+ `;
+
+ // Agrega al DOM
+
+ document.body.appendChild(toast);
+
+ // Auto-cierre a los 5 segundos
+
+ setTimeout(() => {
+
+ // Animación de salida
+
+ toast.classList.add("toast-out");
+
+ // Eliminación final
+
+ setTimeout(() => {
+ toast.remove();
+ }, 350);
+
+ }, 5000);
+ }
\ No newline at end of file