From 75459a90c30f3355dc05852f62d100a7677427c9 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 19:27:48 +0100 Subject: [PATCH] dejar mas bonito clientes, posiblidad de quitar lineas en lineas de factura y posiblidad de pagar en las impagadas --- src/components/BlockedNavigationToast.js | 45 ++ src/components/ClientItem.js | 91 ++++ src/components/ConfirmExitModal.js | 228 ++++++++++ src/components/InvoiceModal.js | 265 ++++++++++- src/pages/ClientesPage.js | 294 ++++++++++++ src/pages/CreateInvoicePage.js | 48 +- src/pages/pagesRegistry.js | 7 +- src/router.js | 62 ++- src/services/clients.js | 35 ++ src/services/invoices.js | 43 ++ src/style.css | 552 ++++++++++++++++++++++- src/styles/modal.css | 357 +++++++++++++++ 12 files changed, 2007 insertions(+), 20 deletions(-) create mode 100644 src/components/BlockedNavigationToast.js create mode 100644 src/components/ClientItem.js create mode 100644 src/components/ConfirmExitModal.js create mode 100644 src/pages/ClientesPage.js create mode 100644 src/services/clients.js diff --git a/src/components/BlockedNavigationToast.js b/src/components/BlockedNavigationToast.js new file mode 100644 index 0000000..3c95e52 --- /dev/null +++ b/src/components/BlockedNavigationToast.js @@ -0,0 +1,45 @@ +/** + * 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 new file mode 100644 index 0000000..c789982 --- /dev/null +++ b/src/components/ClientItem.js @@ -0,0 +1,91 @@ +// 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; +} + +export function ClientItem(client, onView) { + const fragment = document.createDocumentFragment(); + + const item = document.createElement('tr'); + item.className = 'client-item'; + + const contactsCount = client.contacts ? client.contacts.length : 0; + + // Obtener texto de estado + const getStatusText = (status) => { + const statusMap = { '0': 'Inactivo', '1': 'Activo' }; + return statusMap[status] || 'N/A'; + }; + + const getStatusClass = (status) => { + const classMap = { '0': 'status-inactive', '1': 'status-active' }; + return classMap[status] || 'status-unknown'; + }; + + // Obtener iniciales para el avatar + const getInitials = () => { + if (!client.name) return '?'; + const words = client.name.split(' '); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return client.name.substring(0, 2).toUpperCase(); + }; + + // Generar color consistente basado en el nombre + const getAvatarColor = () => { + const colors = [ + '#3b82f6', '#10b981', '#f59e0b', '#ef4444', + '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', + ]; + let hash = 0; + const name = client.name || ''; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; + }; + + item.innerHTML = /*html*/` + +
+ ${getInitials()} +
+ + +
+ ${displayValue(client.name)} + ${displayValue(client.codeClient)} +
+ + ${displayValue(client.typentCode)} + + ${getStatusText(client.status)} + + + ${displayValue(client.email)} + + ${displayValue(client.phone)} + + + + `; + + fragment.appendChild(item); + + // Event listener para el botón de ver + const viewBtn = item.querySelector('.btn-view'); + viewBtn.addEventListener('click', () => { + if (onView) { + onView(client.id); + } + }); + + return fragment; +} diff --git a/src/components/ConfirmExitModal.js b/src/components/ConfirmExitModal.js new file mode 100644 index 0000000..0fcad55 --- /dev/null +++ b/src/components/ConfirmExitModal.js @@ -0,0 +1,228 @@ +/** + * Componente de modal de confirmación para salir sin guardar cambios + * @param {Object} options - Opciones del modal + * @param {string} options.title - Título del modal (opcional) + * @param {string} options.message - Mensaje del modal (opcional) + * @param {string} options.confirmText - Texto del botón de confirmar (opcional) + * @param {string} options.cancelText - Texto del botón de cancelar (opcional) + * @param {Function} options.onConfirm - Callback al confirmar salida + * @param {Function} options.onCancel - Callback al cancelar (quedarse) + * @returns {HTMLElement} El elemento del modal + */ +export function ConfirmExitModal(options = {}) { + const { + title = 'Cambios sin guardar', + message = '¿Estás seguro de que quieres salir? Los cambios no guardados se perderán.', + confirmText = 'Salir sin guardar', + cancelText = 'Quedarse', + onConfirm = () => {}, + onCancel = () => {} + } = options; + + const modal = document.createElement('div'); + modal.className = 'confirm-exit-overlay'; + + modal.innerHTML = /*html*/` +
+
+ + + + + +
+

${title}

+

${message}

+
+ + +
+
+ `; + + const handleClose = (confirmed) => { + modal.classList.add('closing'); + setTimeout(() => { + modal.remove(); + if (confirmed) { + onConfirm(); + } else { + onCancel(); + } + }, 200); + }; + + // Botón quedarse + modal.querySelector('.btn-stay').addEventListener('click', () => { + handleClose(false); + }); + + // Botón salir + modal.querySelector('.btn-exit').addEventListener('click', () => { + handleClose(true); + }); + + // Cerrar con Escape + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + handleClose(false); + document.removeEventListener('keydown', handleKeyDown); + } + }; + document.addEventListener('keydown', handleKeyDown); + + // Click fuera del modal (quedarse) + modal.addEventListener('click', (e) => { + if (e.target === modal) { + handleClose(false); + } + }); + + return modal; +} + +/** + * Muestra el modal de confirmación de salida + * @param {Object} options - Opciones del modal + * @returns {Promise} true si confirma salir, false si cancela + */ +export function showConfirmExitModal(options = {}) { + return new Promise((resolve) => { + const modal = ConfirmExitModal({ + ...options, + onConfirm: () => resolve(true), + onCancel: () => resolve(false) + }); + document.body.appendChild(modal); + }); +} + +/** + * Clase para gestionar el seguimiento de cambios en formularios + */ +export class FormChangeTracker { + constructor() { + this.initialState = null; + this.hasChanges = false; + this.listeners = []; + } + + /** + * Captura el estado inicial del formulario + * @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario + */ + captureInitialState(container) { + this.initialState = this.getFormState(container); + this.hasChanges = false; + } + + /** + * Obtiene el estado actual del formulario + * @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario + * @returns {Object} Estado del formulario + */ + getFormState(container) { + const state = {}; + + // Inputs de texto, date, number, etc. + container.querySelectorAll('input:not([type="button"]):not([type="submit"])').forEach(input => { + const key = input.name || input.id || input.className; + if (key) { + state[`input_${key}`] = input.value; + } + }); + + // Textareas + container.querySelectorAll('textarea').forEach(textarea => { + const key = textarea.name || textarea.id || textarea.className; + if (key) { + state[`textarea_${key}`] = textarea.value; + } + }); + + // Selects + container.querySelectorAll('select').forEach(select => { + const key = select.name || select.id || select.className; + if (key) { + state[`select_${key}`] = select.value; + } + }); + + // Contar líneas de factura si existen + const lines = container.querySelectorAll('.line-row-compact, .invoice-lines tr'); + state['_lineCount'] = lines.length; + + return state; + } + + /** + * Verifica si hay cambios comparando con el estado inicial + * @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario + * @returns {boolean} true si hay cambios + */ + checkForChanges(container) { + if (!this.initialState) return false; + + const currentState = this.getFormState(container); + + // Comparar estados + const initialKeys = Object.keys(this.initialState); + const currentKeys = Object.keys(currentState); + + // Si cambiaron las claves, hay cambios + if (initialKeys.length !== currentKeys.length) { + this.hasChanges = true; + return true; + } + + // Comparar valores + for (const key of initialKeys) { + if (this.initialState[key] !== currentState[key]) { + this.hasChanges = true; + return true; + } + } + + this.hasChanges = false; + return false; + } + + /** + * Configura listeners automáticos para detectar cambios + * @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario + */ + setupAutoTracking(container) { + const checkChanges = () => { + this.checkForChanges(container); + }; + + // Escuchar cambios en inputs + container.addEventListener('input', checkChanges); + container.addEventListener('change', checkChanges); + + // Observar cambios en el DOM (líneas añadidas/eliminadas) + const observer = new MutationObserver(checkChanges); + observer.observe(container, { childList: true, subtree: true }); + + this.listeners.push({ container, checkChanges, observer }); + } + + /** + * Marca que se han guardado los cambios + */ + markAsSaved() { + this.hasChanges = false; + } + + /** + * Limpia los listeners + */ + cleanup() { + this.listeners.forEach(({ container, checkChanges, observer }) => { + container.removeEventListener('input', checkChanges); + container.removeEventListener('change', checkChanges); + observer.disconnect(); + }); + this.listeners = []; + } +} diff --git a/src/components/InvoiceModal.js b/src/components/InvoiceModal.js index 42f1bb2..1f5565d 100644 --- a/src/components/InvoiceModal.js +++ b/src/components/InvoiceModal.js @@ -1,11 +1,17 @@ -import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine } from '../services/invoices.js'; +import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments } from '../services/invoices.js'; +import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js'; export function InvoiceModal(invoiceId, onClose, onUpdate) { const modal = document.createElement('div'); modal.className = 'modal-overlay'; let invoice = null; + let payments = []; let isLoading = true; + + // Tracker de cambios + const changeTracker = new FormChangeTracker(); + let savedSuccessfully = false; // Formatear fecha para input type="date" const formatDateForInput = (dateString) => { @@ -197,20 +203,31 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { Precio Unitario IVA % Total + ${isDraft ? '' : ''} ${invoice.lines && invoice.lines.length > 0 ? invoice.lines.map(line => ` - + ${line.description || ''} ${line.quantity || 0} ${formatCurrency(line.unitPrice)} ${line.taxRate || 0}% ${formatCurrency(line.total)} + ${isDraft ? ` + + + + ` : ''} `).join('') - : 'No hay líneas en esta factura' + : `No hay líneas en esta factura` } @@ -222,12 +239,81 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { Total: ${formatCurrency(invoice.total)} +
Pendiente: ${formatCurrency(invoice.remainToPay)}
+ + ${payments.length > 0 ? ` + +
+

Historial de Pagos

+
+ + + + + + + + + + + + ${payments.map(p => ` + + + + + + + + `).join('')} + +
FechaImporteReferenciaTipoNº Transacción
${formatDate(p.paymentDate)}${formatCurrency(p.amount)}${p.ref || '-'}${p.type || '-'}${p.transactionNum || '-'}
+
+
+ ` : ''} + + ${(invoice.status === 'validated' || invoice.status === 'unpaid') && invoice.remainToPay > 0 ? ` + +
+

Registrar Pago

+
+
+
+ + + Máximo: ${formatCurrency(invoice.remainToPay)}. Vacío o 0 = pago completo. +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+
+ ` : ''} @@ -243,15 +329,24 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { `; attachEventListeners(); + + // Capturar estado inicial después de renderizar + setTimeout(() => { + const modalContent = modal.querySelector('.modal-content'); + if (modalContent && !isLoading) { + changeTracker.captureInitialState(modalContent); + changeTracker.setupAutoTracking(modalContent); + } + }, 100); }; // Adjuntar event listeners const attachEventListeners = () => { - // Botón cerrar + // Botón cerrar (X) - muestra modal si hay cambios const closeBtn = modal.querySelector('.btn-close'); closeBtn?.addEventListener('click', handleClose); - // Botón cancelar modal + // Botón cancelar modal - muestra modal si hay cambios const cancelBtn = modal.querySelector('.btn-cancel-modal'); cancelBtn?.addEventListener('click', handleClose); @@ -278,7 +373,20 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { if (form) form.style.display = 'none'; }); - // Click fuera del modal + // Botones eliminar línea + const deleteLineBtns = modal.querySelectorAll('.btn-delete-line'); + deleteLineBtns.forEach(btn => { + btn.addEventListener('click', () => { + const lineId = parseInt(btn.dataset.lineId); + handleDeleteLine(lineId); + }); + }); + + // Botón registrar pago + const payBtn = modal.querySelector('#btn-pay'); + payBtn?.addEventListener('click', handlePayment); + + // Click fuera del modal - muestra modal si hay cambios modal.addEventListener('click', (e) => { if (e.target === modal) { handleClose(); @@ -287,7 +395,36 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { }; // Manejar cierre - const handleClose = () => { + const handleClose = async () => { + // Si ya se guardó exitosamente, cerrar directamente + if (savedSuccessfully) { + changeTracker.cleanup(); + modal.remove(); + if (onClose) onClose(); + return; + } + + // Verificar si hay cambios sin guardar + const modalContent = modal.querySelector('.modal-content'); + if (modalContent) { + changeTracker.checkForChanges(modalContent); + } + + if (changeTracker.hasChanges) { + // Mostrar modal de confirmación + const confirmed = await showConfirmExitModal({ + title: 'Cambios sin guardar', + message: 'Tienes cambios sin guardar en la factura. ¿Seguro que quieres cerrar?', + confirmText: 'Cerrar sin guardar', + cancelText: 'Seguir editando' + }); + + if (!confirmed) { + return; // No cerrar el modal + } + } + + changeTracker.cleanup(); modal.remove(); if (onClose) onClose(); }; @@ -319,6 +456,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { await updateInvoice(invoiceId, data); + // Marcar como guardado exitosamente + savedSuccessfully = true; + changeTracker.markAsSaved(); + alert('Factura actualizada correctamente'); if (onUpdate) onUpdate(); handleClose(); @@ -347,11 +488,18 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { await validateInvoice(invoiceId); + // Marcar como guardado para no mostrar el modal de confirmación + savedSuccessfully = true; + changeTracker.markAsSaved(); + alert('Factura validada correctamente'); if (onUpdate) onUpdate(); // Recargar la factura para mostrar el nuevo estado await loadInvoice(); + + // Recapturar estado inicial después de recargar + savedSuccessfully = false; } catch (error) { console.error('Error al validar:', error); alert('Error al validar la factura: ' + error.message); @@ -422,7 +570,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { alert('Línea añadida correctamente'); toggleAddLineForm(); + + // Recargar y recapturar estado inicial await loadInvoice(); + changeTracker.markAsSaved(); } catch (error) { console.error('Error al añadir línea:', error); alert('Error al añadir línea: ' + error.message); @@ -435,6 +586,101 @@ 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?')) { + return; + } + + try { + // Deshabilitar el botón mientras se elimina + const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`); + if (btn) { + btn.disabled = true; + btn.innerHTML = ''; + } + + await deleteInvoiceLine(invoiceId, lineId); + + if (onUpdate) onUpdate(); + await loadInvoice(); + changeTracker.markAsSaved(); + } catch (error) { + console.error('Error al eliminar línea:', error); + alert('Error al eliminar línea: ' + error.message); + + const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`); + if (btn) { + btn.disabled = false; + btn.innerHTML = ''; + } + } + }; + + // Manejar pago de factura + const handlePayment = async () => { + const amountInput = modal.querySelector('#payment-amount'); + const dateInput = modal.querySelector('#payment-date'); + const refInput = modal.querySelector('#payment-ref'); + + if (!amountInput || !dateInput) return; + + const rawAmount = parseFloat(amountInput.value); + const remainToPay = invoice.remainToPay; + + // Si vacío o 0, pago total (enviar null) + let amount = null; + if (!isNaN(rawAmount) && rawAmount > 0) { + if (rawAmount > remainToPay) { + alert(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`); + return; + } + amount = rawAmount; + } + + const paymentDate = dateInput.value; + if (!paymentDate) { + alert('La fecha de pago es obligatoria'); + return; + } + + const paymentRef = refInput?.value?.trim() || undefined; + + try { + const payBtn = modal.querySelector('#btn-pay'); + payBtn.disabled = true; + payBtn.textContent = 'Procesando...'; + + await addPayment(invoiceId, { + amount: amount, + paymentDate: paymentDate, + paymentModeId: 4, + closePaidInvoices: "yes", + accountId: 1, + numPayment: paymentRef + }); + + savedSuccessfully = true; + changeTracker.markAsSaved(); + + const displayAmount = amount ? `${amount.toFixed(2)} €` : `${remainToPay.toFixed(2)} € (total)`; + alert(`Pago de ${displayAmount} registrado correctamente`); + + if (onUpdate) onUpdate(); + await loadInvoice(); + savedSuccessfully = false; + } catch (error) { + console.error('Error al registrar pago:', error); + alert('Error al registrar el pago: ' + error.message); + } finally { + const payBtn = modal.querySelector('#btn-pay'); + if (payBtn) { + payBtn.disabled = false; + payBtn.innerHTML = 'Registrar Pago'; + } + } + }; + // Cargar factura const loadInvoice = async () => { try { @@ -442,6 +688,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { renderContent(); invoice = await getInvoiceById(invoiceId); + try { + payments = await getPayments(invoiceId); + } catch (e) { + payments = []; + } isLoading = false; renderContent(); } catch (error) { diff --git a/src/pages/ClientesPage.js b/src/pages/ClientesPage.js new file mode 100644 index 0000000..87cf624 --- /dev/null +++ b/src/pages/ClientesPage.js @@ -0,0 +1,294 @@ +import { ClientItem } from '../components/ClientItem.js'; +import { getClients } from '../services/clients.js'; + +export function renderClientesPage() { + const container = document.createElement('div'); + container.className = 'clientes-page'; + + // Estado de paginación + let currentPage = 1; + let totalPages = 1; + const pageSize = 20; + let allClients = []; + let filteredClients = []; + let searchTerm = ''; + + container.innerHTML = /*html*/` +
+

Clientes

+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + +
Nombre / CódigoTipoEstadoEmailTeléfonoAcciones
Cargando clientes...
+
+ + + `; + + // Función para aplicar filtros + function applyFilters() { + if (!searchTerm) { + filteredClients = [...allClients]; + } else { + const term = searchTerm.toLowerCase(); + filteredClients = allClients.filter(client => { + const nameMatch = client.name?.toLowerCase().includes(term); + const codeMatch = client.codeClient?.toLowerCase().includes(term); + const emailMatch = client.email?.toLowerCase().includes(term); + const phoneMatch = client.phone?.toLowerCase().includes(term); + const contactMatch = client.contacts?.some(c => + c.firstname?.toLowerCase().includes(term) || + c.lastname?.toLowerCase().includes(term) || + c.email?.toLowerCase().includes(term) + ); + return nameMatch || codeMatch || emailMatch || phoneMatch || contactMatch; + }); + } + renderPage(1); + } + + // Función para renderizar la página actual + function renderPage(page = 1) { + const clientsList = container.querySelector('.clients-list'); + + currentPage = page; + totalPages = Math.ceil(filteredClients.length / pageSize); + + // Calcular índices para la página + const startIndex = (page - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, filteredClients.length); + const clients = filteredClients.slice(startIndex, endIndex); + + console.log(`Página ${currentPage}/${totalPages}, Clientes en esta página: ${clients.length}, Total filtrado: ${filteredClients.length}`); + + // Actualizar UI de paginación + updatePaginationUI(); + + // Limpiar lista + clientsList.innerHTML = ''; + + // Si no hay clientes + if (clients.length === 0) { + clientsList.innerHTML = 'No hay clientes disponibles'; + return; + } + + // Renderizar cada cliente + clients.forEach(client => { + const clientItem = ClientItem(client, handleViewClient); + clientsList.appendChild(clientItem); + }); + } + + // Manejar ver cliente + function handleViewClient(clientId) { + const client = allClients.find(c => c.id === clientId); + if (client) { + showClientModal(client); + } + } + + // 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; + } + + // Obtener texto de estado del cliente + function getClientStatusText(status) { + const statusMap = { + '0': 'Inactivo', + '1': 'Activo' + }; + return statusMap[status] || 'N/A'; + } + + // Modal simple para ver detalles del cliente + function showClientModal(client) { + const modal = document.createElement('div'); + modal.className = 'client-modal-overlay'; + + const contactsHtml = client.contacts && client.contacts.length > 0 + ? client.contacts.map(contact => ` +
+
+ ${displayValue(contact.firstname)} ${displayValue(contact.lastname)} +
+
+
📧 Email: ${displayValue(contact.email)}
+
📱 Móvil: ${displayValue(contact.phoneMobile)}
+
☎️ Profesional: ${displayValue(contact.phonePro)}
+
📞 Personal: ${displayValue(contact.phonePerso)}
+
+
+ `).join('') + : '

No hay contactos registrados

'; + + modal.innerHTML = /*html*/` +
+
+

${displayValue(client.name)}

+ +
+
+
+

Información del Cliente

+
+
+ ID + ${client.id} +
+
+ Código + ${displayValue(client.codeClient)} +
+
+ Tipo + ${displayValue(client.typentCode)} +
+
+ Estado + ${getClientStatusText(client.status)} +
+
+ Email + ${displayValue(client.email)} +
+
+ Teléfono + ${displayValue(client.phone)} +
+
+
+
+

Contactos (${client.contacts?.length || 0})

+
+ ${contactsHtml} +
+
+
+
+ `; + + // Cerrar modal + const closeBtn = modal.querySelector('.btn-close-modal'); + closeBtn.addEventListener('click', () => modal.remove()); + modal.addEventListener('click', (e) => { + if (e.target === modal) modal.remove(); + }); + + document.body.appendChild(modal); + } + + // Función para cargar todos los clientes + async function loadAllClients() { + const clientsList = container.querySelector('.clients-list'); + + try { + clientsList.innerHTML = 'Cargando clientes...'; + + const data = await getClients(1000, 1); + + // Manejo de respuesta + allClients = Array.isArray(data) ? data : data.data || data.clients || []; + filteredClients = [...allClients]; + + console.log(`Total de clientes cargados: ${allClients.length}`); + + // Renderizar la primera página + renderPage(1); + + } catch (error) { + console.error('Error al cargar clientes:', error); + clientsList.innerHTML = 'Error al cargar los clientes. Por favor, intenta de nuevo.'; + } + } + + // Función para actualizar la UI de paginación + function updatePaginationUI() { + const paginationDiv = container.querySelector('.pagination'); + const currentPageSpan = container.querySelector('.current-page'); + const totalPagesSpan = container.querySelector('.total-pages'); + const btnPrev = container.querySelector('.btn-prev'); + const btnNext = container.querySelector('.btn-next'); + + currentPageSpan.textContent = currentPage; + totalPagesSpan.textContent = totalPages; + + // Mostrar u ocultar la paginación según el número de páginas + if (totalPages <= 1) { + paginationDiv.style.display = 'none'; + } else { + paginationDiv.style.display = 'flex'; + } + + // Deshabilitar botones según corresponda + btnPrev.disabled = currentPage === 1; + btnNext.disabled = currentPage === totalPages; + } + + // Event listeners + const searchInput = container.querySelector('.search-input'); + searchInput.addEventListener('input', (e) => { + clearTimeout(searchInput.debounceTimer); + searchInput.debounceTimer = setTimeout(() => { + searchTerm = e.target.value.trim(); + applyFilters(); + }, 300); + }); + + // Event listeners de paginación + const btnPrev = container.querySelector('.btn-prev'); + const btnNext = container.querySelector('.btn-next'); + + btnPrev.addEventListener('click', () => { + if (currentPage > 1) { + renderPage(currentPage - 1); + scrollToTop(); + } + }); + + btnNext.addEventListener('click', () => { + if (currentPage < totalPages) { + renderPage(currentPage + 1); + scrollToTop(); + } + }); + + // Función para scroll al inicio + function scrollToTop() { + container.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + // Cargar todos los clientes al montar el componente + loadAllClients(); + + return container; +} diff --git a/src/pages/CreateInvoicePage.js b/src/pages/CreateInvoicePage.js index 8b5ca82..4e9fe46 100644 --- a/src/pages/CreateInvoicePage.js +++ b/src/pages/CreateInvoicePage.js @@ -1,7 +1,14 @@ +import { FormChangeTracker } from '../components/ConfirmExitModal.js'; +import { navigationGuards } from '../router.js'; + export function renderCreateInvoicePage() { const container = document.createElement('div'); container.className = 'create-invoice-page'; + // Tracker de cambios + const changeTracker = new FormChangeTracker(); + let savedSuccessfully = false; + container.innerHTML = /*html*/`

Nueva Factura

@@ -143,14 +150,42 @@ export function renderCreateInvoicePage() { linesContainer.appendChild(createInvoiceLine()); }); + // Capturar estado inicial después de cargar clientes y crear líneas iniciales + setTimeout(() => { + changeTracker.captureInitialState(container); + changeTracker.setupAutoTracking(container); + + // Registrar guard en el router - bloquea navegación si hay cambios + navigationGuards.register(() => { + if (savedSuccessfully) return false; + changeTracker.checkForChanges(container); + return changeTracker.hasChanges; + }); + }, 500); + + // Interceptar beforeunload (cerrar pestaña/navegador) + const handleBeforeUnload = (e) => { + if (savedSuccessfully) return; + + changeTracker.checkForChanges(container); + + if (changeTracker.hasChanges) { + e.preventDefault(); + e.returnValue = ''; + return ''; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + // Botón volver - el router se encarga del modal container.querySelector('#back-btn').addEventListener('click', () => { window.location.hash = '#invoices'; }); + // Botón cancelar - el router se encarga del modal container.querySelector('#cancel-btn').addEventListener('click', () => { - if (confirm('¿Cancelar? Se perderán los cambios.')) { - window.location.hash = '#invoices'; - } + window.location.hash = '#invoices'; }); container.querySelector('#invoice-form').addEventListener('submit', async (e) => { @@ -224,6 +259,13 @@ export function renderCreateInvoicePage() { } const invoiceId = await response.json(); + + // Marcar como guardado exitosamente para evitar el modal de confirmación + savedSuccessfully = true; + changeTracker.cleanup(); + navigationGuards.unregister(); + window.removeEventListener('beforeunload', handleBeforeUnload); + alert(`Factura creada (ID: ${invoiceId})`); window.location.hash = '#invoices'; diff --git a/src/pages/pagesRegistry.js b/src/pages/pagesRegistry.js index b1cb087..aeb8e18 100755 --- a/src/pages/pagesRegistry.js +++ b/src/pages/pagesRegistry.js @@ -1,6 +1,7 @@ import { renderDashboard } from './DashboardPage.js'; import { renderFacturasPage } from './Facturas.js'; import { renderCreateInvoicePage } from './CreateInvoicePage.js'; +import { renderClientesPage } from './ClientesPage.js'; // Registry of all pages available in the application export const pagesRegistry = [ @@ -34,11 +35,7 @@ export const pagesRegistry = [ icon: '', requiresAuth: true, showInSidebar: true, - render: () => { - const div = document.createElement('div'); - div.innerHTML = '

Clientes

Página en construcción...

'; - return div; - } + render: renderClientesPage }, { route: 'settings', diff --git a/src/router.js b/src/router.js index a5f7380..89ac757 100755 --- a/src/router.js +++ b/src/router.js @@ -2,16 +2,69 @@ import { auth } from './services/auth.js'; import { renderLoginPage } from './pages/LoginPage.js'; import { createSidebar } from './components/Sidebar.js'; import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js'; +import { showConfirmExitModal } from './components/ConfirmExitModal.js'; // 🔧 Modo DEV: cambiar a false para activar login const DEV_MODE = false; +// Sistema de guards para bloquear navegación +const navigationGuards = { + currentGuard: null, + + // Registrar un guard (función que devuelve true si hay cambios sin guardar) + register(checkFn) { + this.currentGuard = checkFn; + }, + + // Eliminar el guard actual + unregister() { + this.currentGuard = null; + }, + + // Verificar si hay cambios pendientes + hasUnsavedChanges() { + return this.currentGuard ? this.currentGuard() : false; + } +}; + +// Exportar para uso en páginas +export { navigationGuards }; + export function initRouter() { const app = document.querySelector('#app'); + let isNavigating = false; + let pendingHash = null; - function navigate() { + async function navigate(forceNavigate = false) { const hash = window.location.hash || (DEV_MODE ? '#dashboard' : '#login'); - const route = hash.substring(1); // Remove the # symbol + const route = hash.substring(1); + + // Si hay un guard activo y no estamos forzando navegación + if (!forceNavigate && navigationGuards.hasUnsavedChanges()) { + // Guardar el hash al que se quiere ir + pendingHash = hash; + + // Volver al hash anterior temporalmente + const previousHash = '#' + (document.querySelector('.main-content')?.dataset?.currentRoute || 'dashboard'); + history.pushState(null, '', previousHash); + + // Mostrar modal de confirmación + const confirmed = await showConfirmExitModal({ + title: 'Cambios sin guardar', + message: 'Tienes cambios sin guardar. ¿Seguro que quieres salir?', + confirmText: 'Salir sin guardar', + cancelText: 'Seguir editando' + }); + + if (confirmed) { + // Usuario confirmó salir - limpiar guard y navegar + navigationGuards.unregister(); + window.location.hash = pendingHash; + } + // Si no confirmó, ya estamos en la página correcta + pendingHash = null; + return; + } if (!DEV_MODE && !auth.checkAuth() && hash !== '#login') { window.location.hash = '#login'; @@ -33,6 +86,7 @@ export function initRouter() { // Create main content area const mainContent = document.createElement('main'); mainContent.className = 'main-content'; + mainContent.dataset.currentRoute = route; // Guardar ruta actual // Get page from registry const page = getPageByRoute(route); @@ -69,6 +123,6 @@ export function initRouter() { } } - window.addEventListener('hashchange', navigate); - navigate(); + window.addEventListener('hashchange', () => navigate(false)); + navigate(true); // Primera navegación sin guard } diff --git a/src/services/clients.js b/src/services/clients.js new file mode 100644 index 0000000..859a037 --- /dev/null +++ b/src/services/clients.js @@ -0,0 +1,35 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +function getAuthHeaders() { + const token = localStorage.getItem('token'); + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }; +} + +export async function getClients(limit = 50, page = 1) { + const response = await fetch(`${API_BASE_URL}/api/Clients?limit=${limit}&page=${page}`, { + method: 'GET', + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error('Error al obtener los clientes'); + } + + return await response.json(); +} + +export async function getClientById(id) { + const response = await fetch(`${API_BASE_URL}/api/Clients/${id}`, { + method: 'GET', + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error('Error al obtener el cliente'); + } + + return await response.json(); +} diff --git a/src/services/invoices.js b/src/services/invoices.js index bcfc386..11c8f6f 100644 --- a/src/services/invoices.js +++ b/src/services/invoices.js @@ -80,6 +80,49 @@ export async function addInvoiceLine(id, lineData) { return await response.json(); } +export async function deleteInvoiceLine(invoiceId, lineId) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/lines/${lineId}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Error al eliminar línea'); + } + + return true; +} + +export async function addPayment(invoiceId, paymentData) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(paymentData) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Error al registrar el pago'); + } + + return await response.json(); +} + +export async function getPayments(invoiceId) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, { + method: 'GET', + headers: getAuthHeaders() + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Error al obtener los pagos'); + } + + return await response.json(); +} + export async function deleteInvoice(id) { // Nota: No hay endpoint DELETE en el OpenAPI spec, // pero podríamos usar el endpoint de cambio de estado a "canceled" diff --git a/src/style.css b/src/style.css index d7b47c7..5ad7f13 100755 --- a/src/style.css +++ b/src/style.css @@ -1099,4 +1099,554 @@ button:focus-visible { .btn-delete-compact { grid-column: 2; } -} \ No newline at end of file +} + +/* ========================================== + CLIENTES PAGE STYLES + ========================================== */ + +.clientes-page { + padding: 2rem; + max-width: 1600px; + margin: 0 auto; +} + +.clientes-header { + margin-bottom: 2rem; +} + +.clientes-header h1 { + color: var(--text-primary); + font-size: 2rem; + margin: 0; +} + +.clientes-filters { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + align-items: center; +} + +.clientes-filters .search-input { + flex: 1; + max-width: 500px; +} + +/* Clients Table */ +.clients-table-container { + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.clients-table { + width: 100%; + border-collapse: collapse; +} + +.clients-table thead { + background: #f8fafc; + border-bottom: 2px solid var(--border-color); +} + +.clients-table th { + font-weight: 600; + color: var(--text-secondary); + font-size: 0.85rem; + text-transform: uppercase; + padding: 1rem 1.5rem; + text-align: left; +} + +.clients-table tbody tr { + border-bottom: 1px solid var(--border-color); + transition: background-color 0.2s; +} + +.clients-table tbody tr:hover { + background-color: #f8fafc; +} + +.clients-table tbody tr:last-child { + border-bottom: none; +} + +.clients-table td { + padding: 1rem 1.5rem; + vertical-align: middle; +} + +.client-avatar-col { + width: 60px; +} + +.client-avatar-cell { + width: 60px; +} + +.client-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 0.9rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.client-name-wrapper { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.client-company-name { + font-weight: 600; + color: var(--text-primary); +} + +.client-code { + font-size: 0.8rem; + color: var(--text-secondary); + font-family: monospace; +} + +.contact-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.contact-name { + color: var(--text-primary); +} + +.contacts-count { + font-size: 0.75rem; + color: var(--primary); + background: rgba(37, 99, 235, 0.1); + padding: 0.15rem 0.5rem; + border-radius: 10px; + display: inline-block; + width: fit-content; +} + +.client-email .email-text { + color: var(--text-secondary); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; +} + +.client-phone { + color: var(--text-primary); + font-family: monospace; +} + +.client-actions { + text-align: center; +} + +.client-actions .btn-action { + margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.clients-table .client-actions-col { + width: 80px; + text-align: center; +} + +.clients-table td.client-actions { + text-align: center; + vertical-align: middle; +} + +.clients-table td.client-actions .btn-view { + margin: 0 auto; +} + +/* Status badge for clients */ +.status-badge-client { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + white-space: nowrap; +} + +.status-badge-client.status-active { + background: rgba(16, 185, 129, 0.1); + color: #059669; +} + +.status-badge-client.status-inactive { + background: rgba(239, 68, 68, 0.1); + color: #dc2626; +} + +.status-badge-client.status-unknown { + background: rgba(100, 116, 139, 0.1); + color: #64748b; +} + +/* Contacts button */ +.btn-contacts { + position: relative; + background: rgba(37, 99, 235, 0.08); + border: 1px solid rgba(37, 99, 235, 0.2); + color: var(--primary); + border-radius: 6px; + padding: 0.35rem 0.5rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.15rem; +} + +.btn-contacts:hover { + background: rgba(37, 99, 235, 0.15); + border-color: var(--primary); +} + +.btn-contacts.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +.contacts-badge { + font-size: 0.7rem; + font-weight: 700; + min-width: 16px; + text-align: center; +} + +/* Expandable contacts row */ +.contacts-expand-row td { + padding: 0 !important; + background: #f8fafc; +} + +.contacts-expand-content { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + border-bottom: 2px solid var(--primary); + animation: expandDown 0.2s ease-out; +} + +@keyframes expandDown { + from { + opacity: 0; + max-height: 0; + } + to { + opacity: 1; + max-height: 500px; + } +} + +.contacts-expand-header h4 { + margin: 0 0 0.75rem 0; + font-size: 0.85rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.contacts-expand-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.75rem; +} + +.contact-expand-card { + background: white; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.75rem 1rem; +} + +.contact-expand-name { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.contact-expand-name svg { + color: var(--primary); + flex-shrink: 0; +} + +.contact-expand-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.3rem 1rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.contact-expand-details span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.no-clients { + padding: 3rem; + text-align: center; + color: var(--text-secondary); + font-size: 1.1rem; +} + +/* Client Modal */ +.client-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.client-modal { + background: var(--card-bg); + border-radius: 12px; + width: 100%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); +} + +.client-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + background: #f8fafc; +} + +.client-modal-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--text-primary); +} + +.btn-close-modal { + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.2s; +} + +.btn-close-modal:hover { + background: var(--border-color); + color: var(--text-primary); +} + +.client-modal-body { + padding: 1.5rem; + overflow-y: auto; +} + +.client-info-section, +.contacts-section { + margin-bottom: 1.5rem; +} + +.client-info-section h3, +.contacts-section h3 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.info-label { + font-size: 0.8rem; + color: var(--text-secondary); + text-transform: uppercase; +} + +.info-value { + font-weight: 600; + color: var(--text-primary); +} + +.contacts-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.contact-card { + background: #f8fafc; + border-radius: 8px; + padding: 1rem; + border: 1px solid var(--border-color); +} + +.contact-header { + margin-bottom: 0.75rem; +} + +.contact-full-name { + font-weight: 600; + color: var(--text-primary); + font-size: 1.05rem; +} + +.contact-details { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.contact-row { + display: flex; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.contact-row .label { + min-width: 120px; +} + +.no-contacts { + color: var(--text-secondary); + text-align: center; + padding: 2rem; + background: #f8fafc; + border-radius: 8px; +} + +/* Responsive Clients */ +@media (max-width: 1024px) { + .clientes-page { + padding: 1rem; + } + + .clients-table th, + .clients-table td { + padding: 0.75rem 1rem; + } + + .client-email-col, + .client-email, + .client-type-col, + .client-type { + display: none; + } +} + +@media (max-width: 768px) { + .clientes-filters { + flex-direction: column; + } + + .clientes-filters .search-input { + max-width: 100%; + } + + .clients-table thead { + display: none; + } + + .clients-table tbody tr.client-item { + display: flex; + flex-wrap: wrap; + padding: 1rem; + gap: 0.5rem; + position: relative; + } + + .clients-table tbody tr.contacts-expand-row { + display: block; + } + + .clients-table td { + padding: 0; + } + + .client-avatar-cell { + width: auto; + margin-right: 1rem; + } + + .client-name { + flex: 1; + } + + .client-status, + .client-phone { + width: 100%; + padding-left: 58px; + } + + .client-type-col, + .client-type { + display: none; + } + + .client-actions { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + } + + .contacts-expand-grid { + grid-template-columns: 1fr; + } + + .contact-expand-details { + grid-template-columns: 1fr; + } + + .info-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/styles/modal.css b/src/styles/modal.css index d4a72fe..5869979 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -245,6 +245,38 @@ font-style: italic; } +/* Delete line button */ +.line-actions-col { + width: 50px; +} + +.line-actions { + text-align: center; +} + +.btn-delete-line { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + padding: 0.35rem; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.btn-delete-line:hover { + color: var(--danger, #ef4444); + background: rgba(239, 68, 68, 0.1); +} + +.btn-delete-line:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Invoice Totals */ .invoice-totals { margin-top: 1.5rem; @@ -265,6 +297,10 @@ font-size: 0.9375rem; } +.total-row.paid { + color: var(--success, #22c55e); +} + .total-row.pending { font-size: 1.125rem; color: var(--primary); @@ -272,6 +308,89 @@ border-top: 2px solid var(--border-color); } +/* Payment Section */ +.payment-section { + border-top: 2px solid var(--border-color); + padding-top: 1.25rem; + margin-top: 1rem; +} + +.payment-section h3 { + margin: 0 0 1rem; + font-size: 1.05rem; + color: var(--text-primary); +} + +.payment-form .form-row { + display: flex; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.payment-form .form-group { + flex: 1; +} + +.payment-form label { + display: block; + font-size: 0.8125rem; + font-weight: 500; + margin-bottom: 0.35rem; + color: var(--text-secondary); +} + +.payment-form input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 0.875rem; + background: var(--card-bg); + color: var(--text-primary); + box-sizing: border-box; +} + +.payment-form input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.payment-hint { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.btn-pay { + padding: 0.5rem 1.25rem; + font-size: 0.875rem; + white-space: nowrap; +} + +.btn-pay:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Payment History */ +.payments-history-section { + border-top: 2px solid var(--border-color); + padding-top: 1.25rem; + margin-top: 1rem; +} + +.payments-history-section h3 { + margin: 0 0 0.75rem; + font-size: 1.05rem; + color: var(--text-primary); +} + +.payment-amount-cell { + color: var(--success, #22c55e); +} + /* Button Styles */ .btn-small { padding: 0.5rem 1rem; @@ -390,4 +509,242 @@ .footer-actions-right button { flex: 1; } +} + +/* ============================================ + Confirm Exit Modal Styles + ============================================ */ +.confirm-exit-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + padding: 1rem; + animation: confirmFadeIn 0.2s ease-out; +} + +.confirm-exit-overlay.closing { + animation: confirmFadeOut 0.2s ease-out forwards; +} + +@keyframes confirmFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes confirmFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.confirm-exit-modal { + background: var(--card-bg, #ffffff); + border-radius: 16px; + padding: 2rem; + max-width: 400px; + width: 100%; + text-align: center; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + animation: confirmSlideUp 0.3s ease-out; +} + +.confirm-exit-overlay.closing .confirm-exit-modal { + animation: confirmSlideDown 0.2s ease-out forwards; +} + +@keyframes confirmSlideUp { + from { + transform: translateY(20px) scale(0.95); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +@keyframes confirmSlideDown { + from { + transform: translateY(0) scale(1); + opacity: 1; + } + to { + transform: translateY(20px) scale(0.95); + opacity: 0; + } +} + +.confirm-exit-icon { + width: 72px; + height: 72px; + margin: 0 auto 1.25rem; + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.confirm-exit-icon svg { + color: #d97706; +} + +.confirm-exit-title { + margin: 0 0 0.75rem; + font-size: 1.375rem; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.confirm-exit-message { + margin: 0 0 1.75rem; + font-size: 0.9375rem; + color: var(--text-secondary, #64748b); + line-height: 1.5; +} + +.confirm-exit-actions { + display: flex; + gap: 0.75rem; +} + +.confirm-exit-actions button { + flex: 1; + padding: 0.75rem 1.25rem; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.confirm-exit-actions .btn-stay { + background-color: var(--primary, #2563eb); + color: white; +} + +.confirm-exit-actions .btn-stay:hover { + background-color: #1d4ed8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); +} + +.confirm-exit-actions .btn-exit { + background-color: #f1f5f9; + color: var(--text-primary, #1e293b); + border: 1px solid var(--border-color, #e2e8f0); +} + +.confirm-exit-actions .btn-exit:hover { + background-color: #e2e8f0; + border-color: #cbd5e1; +} + +/* Responsive Confirm Modal */ +@media (max-width: 480px) { + .confirm-exit-modal { + padding: 1.5rem; + margin: 1rem; + } + + .confirm-exit-icon { + width: 60px; + height: 60px; + } + + .confirm-exit-icon svg { + width: 36px; + height: 36px; + } + + .confirm-exit-title { + font-size: 1.25rem; + } + + .confirm-exit-actions { + flex-direction: column-reverse; + } +} + +/* ============================================ + Blocked Navigation Toast Styles + ============================================ */ +.blocked-nav-toast { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%) translateY(-100px); + z-index: 3000; + opacity: 0; + transition: all 0.3s ease-out; + pointer-events: none; +} + +.blocked-nav-toast.visible { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.blocked-nav-toast.hiding { + transform: translateX(-50%) translateY(-20px); + opacity: 0; +} + +.blocked-nav-toast-content { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1.25rem; + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border: 1px solid #fbbf24; + border-radius: 10px; + box-shadow: 0 10px 25px -5px rgba(251, 191, 36, 0.3), + 0 4px 6px -2px rgba(0, 0, 0, 0.1); +} + +.blocked-nav-icon { + color: #d97706; + flex-shrink: 0; +} + +.blocked-nav-message { + font-size: 0.9375rem; + font-weight: 500; + color: #92400e; + white-space: nowrap; +} + +@media (max-width: 480px) { + .blocked-nav-toast { + left: 1rem; + right: 1rem; + transform: translateX(0) translateY(-100px); + } + + .blocked-nav-toast.visible { + transform: translateX(0) translateY(0); + } + + .blocked-nav-toast.hiding { + transform: translateX(0) translateY(-20px); + } + + .blocked-nav-message { + white-space: normal; + } } \ No newline at end of file