From 9c75ff154ecd5738858ed26c02d5acc7bcf7c500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81?= Date: Fri, 6 Feb 2026 23:00:04 +0100 Subject: [PATCH] mejorar la interfaz --- index.html | 4 +- public/doli.svg | 14 + src/components/BlockedNavigationToast.js | 45 - src/components/ClientItem.js | 8 +- src/components/ConfirmDialog.js | 87 ++ src/components/ConfirmExitModal.js | 8 +- src/components/InvoiceItem.js | 10 +- src/components/InvoiceModal.js | 137 +- src/components/Login.js | 59 +- src/components/SessionExpiredOverlay.js | 75 + src/components/Sidebar.js | 9 +- src/components/Toast.js | 108 ++ src/counter.js | 9 - src/main.js | 14 +- src/pages/ClientesPage.js | 45 +- src/pages/CreateInvoicePage.js | 40 +- src/pages/DashboardPage.js | 133 +- src/pages/Facturas.js | 53 +- src/pages/LoginPage.js | 24 +- src/pages/pagesRegistry.js | 125 +- src/pages/test.js | 19 - src/router.js | 18 + src/services/auth.js | 73 + src/services/clients.js | 17 +- src/services/invoices.js | 20 +- src/style.css | 1818 ---------------------- src/styles/base.css | 347 +++++ src/styles/clientes.css | 400 +++++ src/styles/create-invoice.css | 428 +++++ src/styles/dashboard.css | 192 +++ src/styles/facturas.css | 163 ++ src/styles/login.css | 101 ++ src/styles/modal.css | 168 +- src/styles/overlays.css | 103 ++ src/styles/settings.css | 205 +++ src/styles/sidebar.css | 194 +++ src/styles/toast.css | 138 ++ src/utils/escapeHtml.js | 15 + 38 files changed, 3355 insertions(+), 2071 deletions(-) create mode 100644 public/doli.svg delete mode 100644 src/components/BlockedNavigationToast.js create mode 100644 src/components/ConfirmDialog.js create mode 100644 src/components/SessionExpiredOverlay.js create mode 100644 src/components/Toast.js delete mode 100755 src/counter.js delete mode 100755 src/pages/test.js delete mode 100755 src/style.css create mode 100644 src/styles/base.css create mode 100644 src/styles/clientes.css create mode 100644 src/styles/create-invoice.css create mode 100644 src/styles/dashboard.css create mode 100644 src/styles/facturas.css create mode 100644 src/styles/login.css create mode 100644 src/styles/overlays.css create mode 100644 src/styles/settings.css create mode 100644 src/styles/sidebar.css create mode 100644 src/styles/toast.css create mode 100644 src/utils/escapeHtml.js diff --git a/index.html b/index.html index 491f46d..5dea627 100755 --- a/index.html +++ b/index.html @@ -3,9 +3,9 @@ - + - doli-front + Doli App diff --git a/public/doli.svg b/public/doli.svg new file mode 100644 index 0000000..da6ed3d --- /dev/null +++ b/public/doli.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/components/BlockedNavigationToast.js b/src/components/BlockedNavigationToast.js deleted file mode 100644 index 3c95e52..0000000 --- a/src/components/BlockedNavigationToast.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Muestra un toast de aviso cuando se bloquea la navegación - * @param {string} message - Mensaje a mostrar - * @param {number} duration - Duración en ms (default: 3000) - */ -export function showBlockedNavigationToast(message = 'Guarda los cambios antes de salir', duration = 3000) { - // Eliminar toast existente si hay uno - const existingToast = document.querySelector('.blocked-nav-toast'); - if (existingToast) { - existingToast.remove(); - } - - const toast = document.createElement('div'); - toast.className = 'blocked-nav-toast'; - - toast.innerHTML = /*html*/` -
- - - - - - ${message} -
- `; - - document.body.appendChild(toast); - - // Animar entrada - requestAnimationFrame(() => { - toast.classList.add('visible'); - }); - - // Auto-cerrar después del tiempo especificado - setTimeout(() => { - toast.classList.remove('visible'); - toast.classList.add('hiding'); - - setTimeout(() => { - toast.remove(); - }, 300); - }, duration); - - return toast; -} diff --git a/src/components/ClientItem.js b/src/components/ClientItem.js index c789982..bb2e70c 100644 --- a/src/components/ClientItem.js +++ b/src/components/ClientItem.js @@ -1,7 +1,9 @@ +import { escapeHtml } from '../utils/escapeHtml.js'; + // Helper para mostrar N/A si el valor es nulo/vacío/inválido function displayValue(value) { if (value === null || value === undefined || value === '' || value === 'null') return 'N/A'; - return value; + return escapeHtml(value); } export function ClientItem(client, onView) { @@ -50,7 +52,7 @@ export function ClientItem(client, onView) { item.innerHTML = /*html*/`
- ${getInitials()} + ${escapeHtml(getInitials())}
@@ -68,7 +70,7 @@ export function ClientItem(client, onView) { ${displayValue(client.phone)} - + + + + `; + + // Set text with textContent (safe from XSS) + overlay.querySelector('.confirm-dialog-title').textContent = title; + overlay.querySelector('.confirm-dialog-message').textContent = message; + + const close = (result) => { + overlay.classList.remove('visible'); + setTimeout(() => overlay.remove(), 250); + resolve(result); + }; + + overlay.querySelector('.confirm-dialog-btn-cancel').addEventListener('click', () => close(false)); + overlay.querySelector('.confirm-dialog-btn-confirm').addEventListener('click', () => close(true)); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(false); + }); + + // Trap focus + overlay.addEventListener('keydown', (e) => { + if (e.key === 'Escape') close(false); + }); + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + + // Focus the cancel button by default (safer option) + overlay.querySelector('.confirm-dialog-btn-cancel').focus(); + }); +} diff --git a/src/components/ConfirmExitModal.js b/src/components/ConfirmExitModal.js index 0fcad55..68671b8 100644 --- a/src/components/ConfirmExitModal.js +++ b/src/components/ConfirmExitModal.js @@ -25,10 +25,10 @@ export function ConfirmExitModal(options = {}) { modal.innerHTML = /*html*/`
- - - - + + + +

${title}

diff --git a/src/components/InvoiceItem.js b/src/components/InvoiceItem.js index 275e0e8..d70e846 100644 --- a/src/components/InvoiceItem.js +++ b/src/components/InvoiceItem.js @@ -1,3 +1,5 @@ +import { escapeHtml } from '../utils/escapeHtml.js'; + export function InvoiceItem(invoice, onView) { const item = document.createElement('tr'); item.className = 'invoice-item'; @@ -41,21 +43,21 @@ export function InvoiceItem(invoice, onView) { }; item.innerHTML = /*html*/` - ${invoice.number} + ${escapeHtml(invoice.number)} - + ${getStatusText(invoice.status)} - ${invoice.clientName || 'Sin nombre'} + ${escapeHtml(invoice.clientName || 'Sin nombre')} ${formatDate(invoice.date)} ${formatCurrency(invoice.total)} ${formatCurrency(invoice.remainToPay)} - diff --git a/src/components/InvoiceModal.js b/src/components/InvoiceModal.js index d178182..a8fb188 100644 --- a/src/components/InvoiceModal.js +++ b/src/components/InvoiceModal.js @@ -1,9 +1,15 @@ -import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments } from '../services/invoices.js'; +import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments, deleteInvoice } from '../services/invoices.js'; import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js'; +import { showToast } from './Toast.js'; +import { showConfirmDialog } from './ConfirmDialog.js'; +import { escapeHtml } from '../utils/escapeHtml.js'; export function InvoiceModal(invoiceId, onClose, onUpdate) { const modal = document.createElement('div'); modal.className = 'modal-overlay'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', 'Detalles de factura'); let invoice = null; let payments = []; @@ -55,7 +61,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { modalContent.innerHTML = ` @@ -210,7 +217,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { ${invoice.lines && invoice.lines.length > 0 ? invoice.lines.map(line => ` - ${line.description || ''} + ${escapeHtml(line.description || '')} ${line.quantity || 0} ${formatCurrency(line.unitPrice)} ${line.taxRate || 0}% @@ -296,7 +303,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
- +
@@ -320,9 +327,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { @@ -386,6 +394,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { const payBtn = modal.querySelector('#btn-pay'); payBtn?.addEventListener('click', handlePayment); + // Botón eliminar factura (solo borradores) + const deleteInvoiceBtn = modal.querySelector('.btn-delete-invoice'); + deleteInvoiceBtn?.addEventListener('click', handleDeleteInvoice); + + // Botón pasar a borrador (validadas / impagadas) + const cancelInvoiceBtn = modal.querySelector('.btn-cancel-invoice'); + cancelInvoiceBtn?.addEventListener('click', handleCancelInvoice); + // Click fuera del modal - muestra modal si hay cambios modal.addEventListener('click', (e) => { if (e.target === modal) { @@ -442,6 +458,15 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { notePrivate: formData.get('note_private') || undefined }; + // Validar que fecha de vencimiento no sea anterior a la fecha de factura + if (data.expireDate) { + const invoiceDateInput = modal.querySelector('input[name="date"]'); + if (invoiceDateInput && invoiceDateInput.value && data.expireDate < invoiceDateInput.value) { + showToast('La fecha de vencimiento no puede ser anterior a la fecha de factura.', 'warning'); + return; + } + } + // Filtrar valores undefined Object.keys(data).forEach(key => { if (data[key] === undefined || data[key] === '') { @@ -460,12 +485,12 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { savedSuccessfully = true; changeTracker.markAsSaved(); - alert('Factura actualizada correctamente'); + showToast('Factura actualizada correctamente', 'success'); if (onUpdate) onUpdate(); handleClose(); } catch (error) { console.error('Error al guardar:', error); - alert('Error al guardar la factura: ' + error.message); + showToast('Error al guardar la factura: ' + error.message, 'error'); } finally { const saveBtn = modal.querySelector('.btn-save'); if (saveBtn) { @@ -477,9 +502,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { // Manejar validación const handleValidate = async () => { - if (!confirm('¿Estás seguro de que quieres validar esta factura? No podrás editarla completamente después.')) { - return; - } + const confirmed = await showConfirmDialog({ + title: '¿Validar factura?', + message: 'Una vez validada, no podrás editarla completamente. Esta acción no se puede deshacer.', + confirmText: 'Validar', + cancelText: 'Cancelar', + variant: 'warning' + }); + if (!confirmed) return; try { const validateBtn = modal.querySelector('.btn-validate'); @@ -492,7 +522,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { savedSuccessfully = true; changeTracker.markAsSaved(); - alert('Factura validada correctamente'); + showToast('Factura validada correctamente', 'success'); if (onUpdate) onUpdate(); // Recargar la factura para mostrar el nuevo estado @@ -502,7 +532,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { savedSuccessfully = false; } catch (error) { console.error('Error al validar:', error); - alert('Error al validar la factura: ' + error.message); + showToast('Error al validar la factura: ' + error.message, 'error'); const validateBtn = modal.querySelector('.btn-validate'); if (validateBtn) { @@ -537,22 +567,22 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { const taxRate = parseFloat(modal.querySelector('#line-tax').value); if (!description) { - alert('La descripción es obligatoria'); + showToast('La descripción es obligatoria', 'warning'); return; } if (!quantity || quantity <= 0) { - alert('La cantidad debe ser mayor que 0'); + showToast('La cantidad debe ser mayor que 0', 'warning'); return; } if (unitPrice < 0) { - alert('El precio no puede ser negativo'); + showToast('El precio no puede ser negativo', 'warning'); return; } if (taxRate < 0 || taxRate > 100) { - alert('El IVA debe estar entre 0 y 100'); + showToast('El IVA debe estar entre 0 y 100', 'warning'); return; } @@ -568,7 +598,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { taxRate }); - alert('Línea añadida correctamente'); + showToast('Línea añadida correctamente', 'success'); toggleAddLineForm(); // Recargar y recapturar estado inicial @@ -576,7 +606,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { changeTracker.markAsSaved(); } catch (error) { console.error('Error al añadir línea:', error); - alert('Error al añadir línea: ' + error.message); + showToast('Error al añadir línea: ' + error.message, 'error'); } finally { const saveBtn = modal.querySelector('.btn-save-line'); if (saveBtn) { @@ -588,10 +618,22 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { // Eliminar línea de factura const handleDeleteLine = async (lineId) => { - if (!confirm('¿Estás seguro de que quieres eliminar esta línea?')) { + // No permitir eliminar la última línea + const lineRows = modal.querySelectorAll('.lines-table tbody tr[data-line-id]'); + if (lineRows.length <= 1) { + showToast('La factura debe tener al menos una línea.', 'warning'); return; } + const confirmed = await showConfirmDialog({ + title: '¿Eliminar línea?', + message: 'Esta línea se eliminará de la factura.', + confirmText: 'Eliminar', + cancelText: 'Cancelar', + variant: 'danger' + }); + if (!confirmed) return; + try { // Deshabilitar el botón mientras se elimina const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`); @@ -607,7 +649,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { changeTracker.markAsSaved(); } catch (error) { console.error('Error al eliminar línea:', error); - alert('Error al eliminar línea: ' + error.message); + showToast('Error al eliminar línea: ' + error.message, 'error'); const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`); if (btn) { @@ -632,7 +674,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { let amount = null; if (!isNaN(rawAmount) && rawAmount > 0) { if (rawAmount > remainToPay) { - alert(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`); + showToast(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`, 'warning'); return; } amount = rawAmount; @@ -640,7 +682,13 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { const paymentDate = dateInput.value; if (!paymentDate) { - alert('La fecha de pago es obligatoria'); + showToast('La fecha de pago es obligatoria', 'warning'); + return; + } + + const invoiceDateInput = modal.querySelector('input[name="date"]'); + if (invoiceDateInput && invoiceDateInput.value && paymentDate < invoiceDateInput.value) { + showToast('La fecha de pago no puede ser anterior a la fecha de factura.', 'warning'); return; } @@ -664,14 +712,14 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { changeTracker.markAsSaved(); const displayAmount = amount ? `${amount.toFixed(2)} €` : `${remainToPay.toFixed(2)} € (total)`; - alert(`Pago de ${displayAmount} registrado correctamente`); + showToast(`Pago de ${displayAmount} registrado correctamente`, 'success'); if (onUpdate) onUpdate(); await loadInvoice(); savedSuccessfully = false; } catch (error) { console.error('Error al registrar pago:', error); - alert('Error al registrar el pago: ' + error.message); + showToast('Error al registrar el pago: ' + error.message, 'error'); } finally { const payBtn = modal.querySelector('#btn-pay'); if (payBtn) { @@ -681,6 +729,35 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { } }; + // Eliminar factura (solo borradores) + const handleDeleteInvoice = async () => { + const confirmed = await showConfirmDialog({ + title: '¿Eliminar factura?', + message: `Se eliminará la factura ${invoice?.number || ''}. Esta acción no se puede deshacer.`, + confirmText: 'Eliminar', + cancelText: 'Cancelar', + variant: 'danger' + }); + if (!confirmed) return; + + try { + const btn = modal.querySelector('.btn-delete-invoice'); + if (btn) { btn.disabled = true; btn.textContent = 'Eliminando...'; } + + await deleteInvoice(invoiceId); + savedSuccessfully = true; + changeTracker.markAsSaved(); + showToast('Factura eliminada correctamente', 'success'); + if (onUpdate) onUpdate(); + modal.remove(); + } catch (error) { + console.error('Error al eliminar factura:', error); + showToast('Error al eliminar la factura: ' + error.message, 'error'); + const btn = modal.querySelector('.btn-delete-invoice'); + if (btn) { btn.disabled = false; btn.innerHTML = 'Eliminar'; } + } + }; + // Cargar factura const loadInvoice = async () => { try { diff --git a/src/components/Login.js b/src/components/Login.js index 51222c0..29e5e10 100755 --- a/src/components/Login.js +++ b/src/components/Login.js @@ -4,27 +4,70 @@ export function renderLogin(onLogin) { loginContainer.innerHTML = ` `; - loginContainer.querySelector('#login-form').addEventListener('submit', (e) => { + const form = loginContainer.querySelector('#login-form'); + const loginBtn = loginContainer.querySelector('#login-btn'); + const loginIcon = loginBtn.querySelector('.login-icon'); + const loginBtnText = loginBtn.querySelector('.login-btn-text'); + + form.addEventListener('submit', async (e) => { e.preventDefault(); + + // Disable form while loading + const inputs = form.querySelectorAll('input'); + loginBtn.disabled = true; + inputs.forEach(i => i.disabled = true); + loginIcon.style.display = 'none'; + loginBtnText.textContent = 'Iniciando sesión...'; + + // Add spinner + const spinner = document.createElement('span'); + spinner.className = 'spinner'; + loginBtn.insertBefore(spinner, loginBtnText); + const email = loginContainer.querySelector('#identifier').value; const password = loginContainer.querySelector('#password').value; - onLogin(email, password); + + try { + await onLogin(email, password); + } finally { + // Re-enable form + loginBtn.disabled = false; + inputs.forEach(i => i.disabled = false); + spinner.remove(); + loginIcon.style.display = ''; + loginBtnText.textContent = 'Iniciar Sesión'; + } }); return loginContainer; diff --git a/src/components/SessionExpiredOverlay.js b/src/components/SessionExpiredOverlay.js new file mode 100644 index 0000000..f6a0564 --- /dev/null +++ b/src/components/SessionExpiredOverlay.js @@ -0,0 +1,75 @@ +/** + * Muestra un overlay a pantalla completa avisando que la sesión ha expirado + * o que se ha perdido la conexión con el servidor. + */ +let overlayVisible = false; + +const ICONS = { + lock: /*html*/` + + + + `, + disconnect: /*html*/` + + + + + + + + ` +}; + +/** + * @param {'expired' | 'disconnected'} type - Tipo de aviso + */ +export function showSessionExpiredOverlay(type = 'expired') { + // Evitar mostrar múltiples overlays + if (overlayVisible) return; + overlayVisible = true; + + const isDisconnected = type === 'disconnected'; + const title = isDisconnected ? 'Sin conexión' : 'Sesión expirada'; + const message = isDisconnected + ? 'No se pudo conectar con el servidor. Comprueba que el backend está en funcionamiento e inicia sesión de nuevo.' + : 'Tu sesión ha caducado. Por favor, inicia sesión de nuevo.'; + const icon = isDisconnected ? ICONS.disconnect : ICONS.lock; + const iconClass = isDisconnected ? 'session-expired-icon disconnected' : 'session-expired-icon'; + + const overlay = document.createElement('div'); + overlay.className = 'session-expired-overlay'; + overlay.innerHTML = /*html*/` +
+
+ ${icon} +
+

${title}

+

${message}

+ +
+ `; + + document.body.appendChild(overlay); + + // Animar entrada + requestAnimationFrame(() => { + overlay.classList.add('visible'); + }); + + overlay.querySelector('#session-expired-login-btn').addEventListener('click', () => { + overlayVisible = false; + overlay.classList.remove('visible'); + setTimeout(() => { + overlay.remove(); + window.location.hash = '#login'; + }, 250); + }); +} diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index 19e3a21..cac13ab 100755 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -6,7 +6,10 @@ export function createSidebar(pages, currentPage) { const sidebarHeader = document.createElement('div'); sidebarHeader.className = 'sidebar-header'; sidebarHeader.innerHTML = /*html*/` -

Doli App

+ + +

Doli App

+
@@ -14,6 +17,7 @@ export function createSidebar(pages, currentPage) { const nav = document.createElement('nav'); nav.className = 'sidebar-nav'; + nav.setAttribute('aria-label', 'Navegación principal'); const navList = document.createElement('ul'); navList.className = 'sidebar-menu'; @@ -25,6 +29,7 @@ export function createSidebar(pages, currentPage) { link.className = 'sidebar-link'; if (page.route === currentPage) { link.classList.add('active'); + link.setAttribute('aria-current', 'page'); } link.innerHTML = /*html*/` ${page.icon || '📄'} @@ -42,6 +47,8 @@ export function createSidebar(pages, currentPage) { const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle'); toggleBtn.addEventListener('click', () => { sidebar.classList.toggle('collapsed'); + const isCollapsed = sidebar.classList.contains('collapsed'); + toggleBtn.setAttribute('aria-expanded', !isCollapsed); }); return sidebar; diff --git a/src/components/Toast.js b/src/components/Toast.js new file mode 100644 index 0000000..e8b9d23 --- /dev/null +++ b/src/components/Toast.js @@ -0,0 +1,108 @@ +/** + * Sistema de notificaciones Toast reutilizable. + * Reemplaza todos los alert() del proyecto. + * + * Uso: + * import { showToast } from '../components/Toast.js'; + * showToast('Factura creada correctamente', 'success'); + * showToast('Error al guardar', 'error'); + * showToast('Atención: campo vacío', 'warning'); + * showToast('Información adicional', 'info'); + */ + +const ICONS = { + success: /*html*/``, + error: /*html*/``, + warning: /*html*/``, + info: /*html*/`` +}; + +const TITLES = { + success: 'Éxito', + error: 'Error', + warning: 'Atención', + info: 'Información' +}; + +let container = null; + +function getContainer() { + if (!container || !document.body.contains(container)) { + container = document.createElement('div'); + container.className = 'toast-container'; + container.setAttribute('role', 'alert'); + container.setAttribute('aria-live', 'polite'); + document.body.appendChild(container); + } + return container; +} + +/** + * Muestra una notificación toast. + * @param {string} message - Mensaje a mostrar + * @param {'success'|'error'|'warning'|'info'} type - Tipo de notificación + * @param {object} options - Opciones adicionales + * @param {string} options.title - Título personalizado + * @param {number} options.duration - Duración en ms (default: 4000, 0 = no auto-cerrar) + */ +export function showToast(message, type = 'info', options = {}) { + const { title = TITLES[type], duration = 4000 } = options; + const toastContainer = getContainer(); + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.style.position = 'relative'; + toast.setAttribute('role', 'status'); + + toast.innerHTML = /*html*/` +
${ICONS[type]}
+
+

${title}

+

+
+ + ${duration > 0 ? '
' : ''} + `; + + // Set message with textContent to avoid XSS + toast.querySelector('.toast-message').textContent = message; + + toastContainer.appendChild(toast); + + // Animate in + requestAnimationFrame(() => { + toast.classList.add('visible'); + }); + + // Progress bar + if (duration > 0) { + const progress = toast.querySelector('.toast-progress'); + if (progress) { + progress.style.width = '100%'; + progress.style.transitionDuration = `${duration}ms`; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + progress.style.width = '0%'; + }); + }); + } + } + + const removeToast = () => { + toast.classList.remove('visible'); + toast.classList.add('removing'); + setTimeout(() => toast.remove(), 300); + }; + + // Close button + toast.querySelector('.toast-close').addEventListener('click', removeToast); + + // Auto remove + if (duration > 0) { + setTimeout(removeToast, duration); + } + + return toast; +} diff --git a/src/counter.js b/src/counter.js deleted file mode 100755 index 881e2d7..0000000 --- a/src/counter.js +++ /dev/null @@ -1,9 +0,0 @@ -export function setupCounter(element) { - let counter = 0 - const setCounter = (count) => { - counter = count - element.innerHTML = `count is ${counter}` - } - element.addEventListener('click', () => setCounter(counter + 1)) - setCounter(0) -} diff --git a/src/main.js b/src/main.js index fba4a17..8f06e63 100755 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,16 @@ -import './style.css' +/* Modular CSS imports */ +import './styles/base.css' +import './styles/login.css' +import './styles/sidebar.css' +import './styles/dashboard.css' +import './styles/facturas.css' +import './styles/create-invoice.css' +import './styles/clientes.css' +import './styles/settings.css' +import './styles/overlays.css' +import './styles/modal.css' +import './styles/toast.css' + import { initRouter } from './router.js' initRouter() diff --git a/src/pages/ClientesPage.js b/src/pages/ClientesPage.js index 87cf624..a35b517 100644 --- a/src/pages/ClientesPage.js +++ b/src/pages/ClientesPage.js @@ -1,5 +1,6 @@ import { ClientItem } from '../components/ClientItem.js'; import { getClients } from '../services/clients.js'; +import { escapeHtml } from '../utils/escapeHtml.js'; export function renderClientesPage() { const container = document.createElement('div'); @@ -19,7 +20,7 @@ export function renderClientesPage() {
- +
@@ -96,7 +97,15 @@ export function renderClientesPage() { // Si no hay clientes if (clients.length === 0) { - clientsList.innerHTML = 'No hay clientes disponibles'; + clientsList.innerHTML = ` +
+
+ +
+

No hay clientes

+

${searchTerm ? 'No se encontraron clientes con ese término de búsqueda.' : 'No hay clientes registrados en el sistema.'}

+
+ `; return; } @@ -118,7 +127,7 @@ export function renderClientesPage() { // Función helper para mostrar N/A si el valor es nulo/vacío/inválido function displayValue(value) { if (value === null || value === undefined || value === '' || value === 'null') return 'N/A'; - return value; + return escapeHtml(value); } // Obtener texto de estado del cliente @@ -155,7 +164,7 @@ export function renderClientesPage() {

${displayValue(client.name)}

- +
@@ -204,6 +213,20 @@ export function renderClientesPage() { if (e.target === modal) modal.remove(); }); + // Cerrar con Escape + const handleEscape = (e) => { + if (e.key === 'Escape') { + modal.remove(); + document.removeEventListener('keydown', handleEscape); + } + }; + document.addEventListener('keydown', handleEscape); + + // Accesibilidad + modal.querySelector('.client-modal').setAttribute('role', 'dialog'); + modal.querySelector('.client-modal').setAttribute('aria-modal', 'true'); + modal.querySelector('.client-modal').setAttribute('aria-label', `Detalles de ${displayValue(client.name)}`); + document.body.appendChild(modal); } @@ -212,11 +235,19 @@ export function renderClientesPage() { const clientsList = container.querySelector('.clients-list'); try { - clientsList.innerHTML = 'Cargando clientes...'; + clientsList.innerHTML = Array.from({length: 6}, () => ` + +
+
+
+
+
+
+
+ + `).join(''); const data = await getClients(1000, 1); - - // Manejo de respuesta allClients = Array.isArray(data) ? data : data.data || data.clients || []; filteredClients = [...allClients]; diff --git a/src/pages/CreateInvoicePage.js b/src/pages/CreateInvoicePage.js index 362412d..39c9035 100644 --- a/src/pages/CreateInvoicePage.js +++ b/src/pages/CreateInvoicePage.js @@ -1,5 +1,7 @@ import { FormChangeTracker } from '../components/ConfirmExitModal.js'; import { navigationGuards } from '../router.js'; +import { authFetch } from '../services/auth.js'; +import { showToast } from '../components/Toast.js'; export function renderCreateInvoicePage() { const container = document.createElement('div'); @@ -102,7 +104,7 @@ export function renderCreateInvoicePage() { async function loadClients() { try { const token = localStorage.getItem('token'); - const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Clients?limit=1000`, { + const response = await authFetch(`${import.meta.env.VITE_API_BASE_URL}/api/Clients?limit=1000`, { headers: { 'Authorization': `Bearer ${token}` } }); @@ -134,6 +136,19 @@ export function renderCreateInvoicePage() { expireDate.setDate(expireDate.getDate() + 30); container.querySelector('#expireDate').value = expireDate.toISOString().split('T')[0]; + // La fecha de vencimiento no puede ser anterior a la fecha de factura + const dateInput = container.querySelector('#date'); + const expireDateInput = container.querySelector('#expireDate'); + expireDateInput.min = dateInput.value; + + dateInput.addEventListener('change', () => { + expireDateInput.min = dateInput.value; + // Si la fecha de vencimiento actual es anterior a la nueva fecha, ajustarla + if (expireDateInput.value && expireDateInput.value < dateInput.value) { + expireDateInput.value = dateInput.value; + } + }); + // Event listeners para botones de calendario container.querySelectorAll('.date-picker-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -225,6 +240,11 @@ export function renderCreateInvoicePage() { taxInput.addEventListener('input', updateTotal); lineDiv.querySelector('.btn-delete-compact').addEventListener('click', () => { + const currentLines = linesContainer.querySelectorAll('.line-row-compact'); + if (currentLines.length <= 1) { + showToast('La factura debe tener al menos una línea.', 'warning'); + return; + } lineDiv.remove(); updateGrandTotal(); }); @@ -284,13 +304,13 @@ export function renderCreateInvoicePage() { const clientIdValue = parseInt(container.querySelector('#clientId').value); if (!clientIdValue || isNaN(clientIdValue) || clientIdValue < 1) { - alert('Selecciona un cliente'); + showToast('Selecciona un cliente', 'warning'); return; } const lines = container.querySelectorAll('.line-row-compact'); if (lines.length === 0) { - alert('Añade al menos una línea'); + showToast('Añade al menos una línea', 'warning'); return; } @@ -313,19 +333,19 @@ export function renderCreateInvoicePage() { } if (!isValidDate(dateValue)) { - alert('La fecha de factura no es válida. Introduce una fecha entre los años 2000 y 2100.'); + showToast('La fecha de factura no es válida. Introduce una fecha entre los años 2000 y 2100.', 'warning'); return; } if (!isValidDate(expireDateValue)) { - alert('La fecha de vencimiento no es válida. Introduce una fecha entre los años 2000 y 2100.'); + showToast('La fecha de vencimiento no es válida. Introduce una fecha entre los años 2000 y 2100.', 'warning'); return; } const dateObj = new Date(dateValue); const expireDateObj = new Date(expireDateValue); if (expireDateObj < dateObj) { - alert('La fecha de vencimiento no puede ser anterior a la fecha de factura.'); + showToast('La fecha de vencimiento no puede ser anterior a la fecha de factura.', 'warning'); return; } @@ -359,7 +379,7 @@ export function renderCreateInvoicePage() { }); if (hasEmptyLine) { - alert('Completa todos los campos de las líneas'); + showToast('Completa todos los campos de las líneas', 'warning'); return; } @@ -369,7 +389,7 @@ export function renderCreateInvoicePage() { submitBtn.textContent = 'Guardando...'; const token = localStorage.getItem('token'); - const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Invoices`, { + const response = await authFetch(`${import.meta.env.VITE_API_BASE_URL}/api/Invoices`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -391,12 +411,12 @@ export function renderCreateInvoicePage() { navigationGuards.unregister(); window.removeEventListener('beforeunload', handleBeforeUnload); - alert(`Factura creada (ID: ${invoiceId})`); + showToast(`Factura creada (ID: ${invoiceId})`, 'success'); window.location.hash = '#invoices'; } catch (error) { console.error('Error:', error); - alert(error.message || 'Error al crear la factura'); + showToast(error.message || 'Error al crear la factura', 'error'); const submitBtn = container.querySelector('.btn-submit-compact'); if (submitBtn) { diff --git a/src/pages/DashboardPage.js b/src/pages/DashboardPage.js index eca570f..bcd2ddb 100755 --- a/src/pages/DashboardPage.js +++ b/src/pages/DashboardPage.js @@ -1,4 +1,5 @@ -import { auth } from '../services/auth.js'; +import { auth, authFetch } from '../services/auth.js'; +import { escapeHtml } from '../utils/escapeHtml.js'; export function renderDashboard() { const user = auth.getUser(); @@ -7,18 +8,132 @@ export function renderDashboard() { container.innerHTML = /*html*/`
-

Bienvenido, ${user?.email || 'Usuario'}

- +
+

Bienvenido, ${escapeHtml(user?.identifier || user?.email || 'Usuario')}

+

Resumen de tu actividad en Dolibarr

+
-
-

Dashboard protegido

+ +
+
+
+ +
+
+
+ Total Facturas +
+
+
+
+ +
+
+
+ Pagadas +
+
+
+
+ +
+
+
+ Pendientes +
+
+
+
+ +
+
+
+ Clientes +
+
+
+ +
+ +
+

+ + Información +

+
+
+ Plataforma + Dolibarr ERP +
+
+ Usuario + ${escapeHtml(user?.identifier || user?.email || 'N/A')} +
+
+ Estado + Conectado +
+
+
`; - container.querySelector('#logout-button').addEventListener('click', () => { - auth.logout(); - window.location.hash = '#login'; - }); + // Cargar estadísticas + loadDashboardStats(container); return container; } + +async function loadDashboardStats(container) { + const token = localStorage.getItem('token'); + const apiUrl = import.meta.env.VITE_API_BASE_URL; + + try { + // Cargar facturas y clientes en paralelo + const [invoicesRes, clientsRes] = await Promise.allSettled([ + authFetch(`${apiUrl}/api/Invoices?limit=1000`, { + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } + }), + authFetch(`${apiUrl}/api/Clients?limit=1000`, { + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } + }) + ]); + + if (invoicesRes.status === 'fulfilled' && invoicesRes.value.ok) { + const invoices = await invoicesRes.value.json(); + const list = Array.isArray(invoices) ? invoices : []; + const paid = list.filter(i => i.status === 'paid' || i.status === '2').length; + const unpaid = list.filter(i => i.status === 'unpaid' || i.status === '1').length; + + container.querySelector('#stat-total').textContent = list.length; + container.querySelector('#stat-paid').textContent = paid; + container.querySelector('#stat-unpaid').textContent = unpaid; + } + + if (clientsRes.status === 'fulfilled' && clientsRes.value.ok) { + const clients = await clientsRes.value.json(); + container.querySelector('#stat-clients').textContent = Array.isArray(clients) ? clients.length : 0; + } + } catch (error) { + console.error('Error cargando stats del dashboard:', error); + } +} diff --git a/src/pages/Facturas.js b/src/pages/Facturas.js index bb557a1..f637ff4 100755 --- a/src/pages/Facturas.js +++ b/src/pages/Facturas.js @@ -1,5 +1,6 @@ import { InvoiceItem } from '../components/InvoiceItem.js'; import { InvoiceModal } from '../components/InvoiceModal.js'; +import { authFetch } from '../services/auth.js'; export function renderFacturasPage() { const container = document.createElement('div'); @@ -20,8 +21,8 @@ export function renderFacturasPage() {
- - +