diff --git a/README.md b/README.md index d0c4c61..c78ce05 100644 --- a/README.md +++ b/README.md @@ -94,33 +94,31 @@ El sistema está diseñado para ser desplegado fácilmente utilizando Docker y D - Los datos se mantienen entre reinicios y actualizaciones del contenedor - Para más información sobre Docker, consulta [DOCKER.md](./DOCKER.md) -## Novedades de la Versión 1.3.5 +## Novedades de la Versión 1.5.0 -### 🚀 **Nueva Interfaz de Ventas** -- **Formulario modernizado**: Diseño más intuitivo y profesional -- **Múltiples productos**: Agrega varios servicios/cursos en una sola venta -- **Sistema de cantidades**: Especifica la cantidad de cada producto +### 🎫 **Reorganización de Interface** +- **Subpestañas en Ventas**: Nueva estructura con "💰 Ventas" y "🎫 Tickets" +- **Dashboard limpio**: Movida sección de movimientos a subpestaña de Tickets +- **Navegación mejorada**: Interfaz más organizada y lógica -### 💰 **Sistema de Descuentos Avanzado** -- **Interfaz colapsable**: Sección de descuentos elegante y fácil de usar -- **Dos tipos de descuento**: Por porcentaje (%) o monto fijo ($) -- **Motivo del descuento**: Registro del motivo para auditoría -- **Preview en tiempo real**: Ve el descuento aplicado instantáneamente +### 💳 **Sistema de Anticipos Avanzado** +- **Anticipos manuales**: Aplicar anticipos no registrados con confirmación +- **Checkbox de seguridad**: Confirmación obligatoria para anticipos manuales +- **Integración completa**: Anticipos se aplican como descuentos automáticamente +- **Control de duplicación**: Sistema previene aplicar el mismo anticipo múltiples veces -### 📅 **Gestión de Citas Mejorada** -- **Campos de fecha intuitivos**: DD/MM/AAAA más fácil de usar -- **Horarios preconfigurados**: Selección rápida de horas disponibles -- **Integración con ventas**: Cita programada directamente al crear la venta +### 👥 **Gestión de Clientes Mejorada** +- **Público General**: Sistema automático para ventas sin cliente específico +- **Campo opcional**: Cliente ya no es obligatorio en ventas +- **Tickets genéricos**: Soporte para ventas a público general -### 🧾 **Tickets Optimizados** -- **Formato térmico 58mm**: Diseño específico para impresoras térmicas -- **Información completa**: Productos, cantidades, descuentos y totales -- **QR Code**: Para feedback de clientes -- **Fechas corregidas**: Formato DD/MM/YYYY HH:MM sin errores de "undefined" -- **Etiquetas en negrita**: Folio y Fecha destacados visualmente +### 🎨 **Mejoras Visuales** +- **Header sólido**: Eliminado gradiente por color sólido negro +- **Precios alineados**: Grid layout mejorado para mejor presentación +- **Orden de servicios**: Clean Girl → Elegant → Mystery → Seduction con sus retoques +- **Interfaz consistente**: Colores y estilos uniformes -### ⚡ **Mejoras Técnicas** -- **Cálculos en tiempo real**: Totales actualizados automáticamente -- **Validaciones mejoradas**: Mejor control de errores -- **Base de datos optimizada**: Persistencia de datos mejorada -- **API REST**: Migración completa de localStorage a servidor +### ⚡ **Optimizaciones Técnicas** +- **Base de datos mejorada**: Campo sort_order para control de ordenamiento +- **Subpestañas funcionales**: JavaScript para navegación entre secciones +- **Validaciones reforzadas**: Mejor control de formularios y datos diff --git a/app.js b/app.js index d37d0cb..8eeb8cf 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,28 @@ -import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js'; -import { renderTicketAndPrint } from './print.js?v=1757039801'; +import { renderTicketAndPrint } from './print.js?v=1757039803'; + +// --- GLOBAL VARIABLES --- +const defaultSettings = { + tipo: 'Vanity Brows', + precio: 5250, + nombreBusiness: 'Vanity Beauty Center', + direccion: 'Av. Ejemplo 123, Ciudad', + rfc: '', + tel: '8443555108', + leyenda: '¡Gracias por tu preferencia!', + folioPrefix: 'AP-', + folioSeq: 1 +}; + +let settings = {}; +let movements = []; +let clients = []; +let users = []; +let products = []; +let incomeChart = null; +let paymentMethodChart = null; +let currentUser = {}; +let currentClientId = null; +let cancellationRequests = []; // --- UTILITIES --- function escapeHTML(str) { @@ -180,6 +203,8 @@ function initializeDynamicSystem() { discountSymbol.textContent = '%'; } else if (this.value === 'amount') { discountSymbol.textContent = '$'; + } else if (this.value === 'anticipo') { + discountSymbol.textContent = '💰'; } else if (this.value === 'warrior') { discountSymbol.textContent = '🎗️'; } else { @@ -187,7 +212,10 @@ function initializeDynamicSystem() { } } - if (!isDiscountSelected) { + // Manejo especial para anticipos + if (this.value === 'anticipo') { + showAvailableAnticipos(); + } else if (!isDiscountSelected) { discountValue.value = ''; discountReason.value = ''; } @@ -462,6 +490,193 @@ function aplicarAnticipo(anticipoId, monto) { alert('Anticipo aplicado correctamente'); } +async function showAvailableAnticipos() { + const clienteInput = document.getElementById('m-cliente'); + const clienteNombre = clienteInput.value.trim(); + + if (!clienteNombre) { + alert('Por favor selecciona un cliente primero para ver sus anticipos disponibles.'); + document.getElementById('discount-type').value = ''; + return; + } + + try { + // Obtener anticipos disponibles del cliente + const response = await fetch('/api/movements'); + const movements = await response.json(); + + // Filtrar anticipos del cliente que no han sido aplicados + const anticipos = movements.filter(mov => { + const client = clients.find(c => c.id === mov.clienteId); + return client && + client.nombre.toLowerCase() === clienteNombre.toLowerCase() && + mov.concepto && + mov.concepto.includes('Anticipo') && + !mov.aplicado; // Asumir que agregaremos un campo 'aplicado' + }); + + if (anticipos.length === 0) { + // Permitir ingresar anticipo manualmente si no hay registrados + const manualAnticipo = confirm(`No hay anticipos registrados para "${clienteNombre}".\n\n¿La cliente dio un anticipo directamente que no está en el sistema?\n\nHaz clic en OK para ingresar el anticipo manualmente.`); + + if (!manualAnticipo) { + document.getElementById('discount-type').value = ''; + return; + } + + // Mostrar el checkbox de confirmación + mostrarConfirmacionAnticipoManual(clienteNombre); + return; + } + + // Crear lista de anticipos para mostrar al usuario + let anticiposList = 'Anticipos disponibles:\n\n'; + anticipos.forEach((anticipo, index) => { + anticiposList += `${index + 1}. $${parseFloat(anticipo.monto).toFixed(2)} - Folio: ${anticipo.folio}\n`; + }); + + const selectedIndex = prompt(anticiposList + '\nEscribe el número del anticipo que deseas aplicar:'); + + if (selectedIndex === null) { + document.getElementById('discount-type').value = ''; + return; + } + + const anticipoIndex = parseInt(selectedIndex) - 1; + + if (isNaN(anticipoIndex) || anticipoIndex < 0 || anticipoIndex >= anticipos.length) { + alert('Selección inválida'); + document.getElementById('discount-type').value = ''; + return; + } + + const selectedAnticipo = anticipos[anticipoIndex]; + + // Aplicar el anticipo como descuento + const discountValue = document.getElementById('discount-value'); + const discountReason = document.getElementById('discount-reason'); + + if (discountValue) { + discountValue.value = parseFloat(selectedAnticipo.monto).toFixed(2); + discountValue.disabled = true; + } + + if (discountReason) { + discountReason.value = `Anticipo aplicado - Folio: ${selectedAnticipo.folio}`; + discountReason.disabled = true; + } + + // Guardar referencia del anticipo para el ticket + window.appliedAnticipo = { + id: selectedAnticipo.id, + folio: selectedAnticipo.folio, + monto: selectedAnticipo.monto + }; + + calculateTotals(); + + } catch (error) { + console.error('Error loading anticipos:', error); + alert('Error al cargar los anticipos del cliente'); + document.getElementById('discount-type').value = ''; + } +} + +function aplicarAnticipoManual(monto, comentario, clienteNombre) { + // Aplicar el anticipo manual como descuento + const discountType = document.getElementById('discount-type'); + const discountValue = document.getElementById('discount-value'); + const discountReason = document.getElementById('discount-reason'); + + if (discountValue) { + discountValue.value = monto.toFixed(2); + discountValue.disabled = true; + } + + if (discountReason) { + discountReason.value = `Anticipo manual - ${comentario}`; + discountReason.disabled = true; + } + + // Guardar referencia del anticipo manual para el ticket + window.appliedAnticipo = { + id: 'manual_' + Date.now(), + folio: 'MANUAL', + monto: monto, + comentario: comentario, + cliente: clienteNombre, + manual: true + }; + + calculateTotals(); + alert(`Anticipo manual de $${monto.toFixed(2)} aplicado correctamente para ${clienteNombre}`); +} + +function mostrarConfirmacionAnticipoManual(clienteNombre) { + // Mostrar el checkbox de confirmación + const confirmationDiv = document.getElementById('anticipo-manual-confirmation'); + const checkbox = document.getElementById('confirm-anticipo-manual'); + + confirmationDiv.style.display = 'block'; + checkbox.checked = false; + + // Crear un botón temporal para proceder + const existingButton = document.getElementById('btn-proceder-anticipo-manual'); + if (existingButton) { + existingButton.remove(); + } + + const proceedButton = document.createElement('button'); + proceedButton.id = 'btn-proceder-anticipo-manual'; + proceedButton.textContent = 'Proceder con Anticipo Manual'; + proceedButton.className = 'modern-btn btn-primary'; + proceedButton.style.marginTop = '10px'; + proceedButton.disabled = true; + + // Habilitar botón solo cuando checkbox esté marcado + checkbox.addEventListener('change', function() { + proceedButton.disabled = !this.checked; + }); + + proceedButton.addEventListener('click', function() { + if (checkbox.checked) { + procederConAnticipoManual(clienteNombre); + } + }); + + confirmationDiv.appendChild(proceedButton); +} + +function procederConAnticipoManual(clienteNombre) { + // Permitir entrada manual del anticipo + const montoManual = prompt('Ingresa el monto del anticipo que dio la cliente:'); + + if (!montoManual || isNaN(parseFloat(montoManual)) || parseFloat(montoManual) <= 0) { + alert('Monto inválido. Operación cancelada.'); + ocultarConfirmacionAnticipoManual(); + document.getElementById('discount-type').value = ''; + return; + } + + const comentario = prompt('Comentario/referencia del anticipo (opcional):') || 'Anticipo manual - no registrado previamente'; + + // Ocultar la confirmación + ocultarConfirmacionAnticipoManual(); + + // Aplicar el anticipo manual + aplicarAnticipoManual(parseFloat(montoManual), comentario, clienteNombre); +} + +function ocultarConfirmacionAnticipoManual() { + const confirmationDiv = document.getElementById('anticipo-manual-confirmation'); + const existingButton = document.getElementById('btn-proceder-anticipo-manual'); + + confirmationDiv.style.display = 'none'; + if (existingButton) { + existingButton.remove(); + } +} + function calculateTotals() { currentSubtotal = selectedProducts.reduce((sum, product) => { return sum + (product.price * product.quantity); @@ -475,6 +690,8 @@ function calculateTotals() { currentDiscount = currentSubtotal * (discountValue / 100); } else if (discountType === 'amount') { currentDiscount = Math.min(discountValue, currentSubtotal); + } else if (discountType === 'anticipo') { + currentDiscount = Math.min(discountValue, currentSubtotal); } else if (discountType === 'warrior') { currentDiscount = currentSubtotal; // 100% de descuento } else { @@ -530,30 +747,6 @@ function formatDate(dateString) { const APP_VERSION = '1.4.0'; -// --- ESTADO Y DATOS --- -const DEFAULT_SETTINGS = { - negocio: 'Ale Ponce', - tagline: 'beauty expert', - calle: 'Benito Juarez 246', - colonia: 'Col. Los Pinos', - cp: '252 pinos', - rfc: '', - tel: '8443555108', - leyenda: '¡Gracias por tu preferencia!', - folioPrefix: 'AP-', - folioSeq: 1 -}; - -let settings = {}; -let movements = []; -let clients = []; -let users = []; -let products = []; -let incomeChart = null; -let paymentMethodChart = null; -let currentUser = {}; -let currentClientId = null; - // --- DOM ELEMENTS --- const formSettings = document.getElementById('formSettings'); const formMove = document.getElementById('formMove'); @@ -818,10 +1011,20 @@ function renderTable() { tr.insertCell().textContent = Number(mov.monto).toFixed(2); const actionsCell = tr.insertCell(); + + // Botón de solicitar cancelación para todos los usuarios + const cancelRequestButton = document.createElement('button'); + cancelRequestButton.className = 'action-btn btn-warning'; + cancelRequestButton.dataset.id = mov.id; + cancelRequestButton.dataset.action = 'request-cancel'; + cancelRequestButton.textContent = 'Solicitar Cancelación'; + cancelRequestButton.style.marginRight = '5px'; + actionsCell.appendChild(cancelRequestButton); + // Solo mostrar botón de eliminar para administradores if (currentUser && currentUser.role === 'admin') { const deleteButton = document.createElement('button'); - deleteButton.className = 'action-btn'; + deleteButton.className = 'action-btn btn-danger'; deleteButton.dataset.id = mov.id; deleteButton.dataset.action = 'delete'; deleteButton.textContent = 'Eliminar'; @@ -1140,6 +1343,89 @@ async function deleteProduct(id) { } } +function showCancellationRequestModal(movementId, movement) { + const client = clients.find(c => c.id === movement.clienteId); + const clientName = client ? client.nombre : 'Cliente Eliminado'; + + const modalHTML = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalHTML); + + const modal = document.getElementById('cancellation-modal'); + const closeButton = modal.querySelector('.close-button'); + const cancelButton = modal.querySelector('.modal-cancel'); + const form = modal.querySelector('#formCancellationRequest'); + + const closeModal = () => modal.remove(); + + closeButton.onclick = closeModal; + cancelButton.onclick = closeModal; + window.onclick = (event) => { + if (event.target == modal) { + closeModal(); + } + }; + + form.onsubmit = async (e) => { + e.preventDefault(); + const reason = document.getElementById('cancellation-reason').value.trim(); + + if (!reason) { + alert('Por favor ingresa un motivo para la cancelación.'); + return; + } + + try { + const response = await fetch(`/api/movements/${movementId}/cancel-request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }) + }); + + const result = await response.json(); + + if (response.ok) { + alert('Solicitud de cancelación enviada exitosamente. La venta quedará oculta hasta que el administrador revise tu solicitud.'); + closeModal(); + // Refresh movements to hide the temporarily cancelled item + const movementsResponse = await fetch('/api/movements'); + if (movementsResponse.ok) { + movements = await movementsResponse.json(); + renderTable(); + } + } else { + alert(`Error: ${result.error}`); + } + } catch (error) { + alert('Error de conexión al enviar la solicitud.'); + } + }; +} + function showAddCourseModal(clientId) { const courses = products.filter(p => p.type === 'course'); const courseOptions = courses.map(c => ``).join(''); @@ -1235,16 +1521,23 @@ async function handleNewMovement(e) { e.preventDefault(); const form = e.target; const monto = parseFloat(document.getElementById('m-monto').value || 0); - const clienteNombre = document.getElementById('m-cliente').value; + const clienteNombre = document.getElementById('m-cliente').value.trim() || 'Público General'; if (selectedProducts.length === 0) { alert('Por favor selecciona al menos un producto o servicio'); return; } - let client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase()); - if (!client) { - if (confirm(`El cliente "${clienteNombre}" no existe. ¿Deseas crearlo?`)) { + // Check if this is an anticipo (doesn't need specific client) + const isAnticipo = selectedProducts.some(p => p.type === 'anticipo'); + const isAnticipoGeneral = clienteNombre === 'Anticipo General'; + const isPublicoGeneral = clienteNombre === 'Público General'; + + let client = null; + if (!isAnticipoGeneral && !isPublicoGeneral) { + client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase()); + if (!client) { + if (confirm(`El cliente "${clienteNombre}" no existe. ¿Deseas crearlo?`)) { const newClient = { id: crypto.randomUUID(), nombre: clienteNombre, @@ -1254,14 +1547,54 @@ async function handleNewMovement(e) { }; await saveClient(newClient); client = newClient; - } else { - return; + } else { + return; + } } + } else if (isPublicoGeneral) { + // For público general, create a generic client entry + client = { + id: 'publico_general', + nombre: 'Público General', + telefono: '', + cumpleaños: '', + consentimiento: false + }; + } else { + // For anticipo general, create a generic client entry + client = { + id: 'anticipo_general', + nombre: 'Anticipo General', + telefono: '', + cumpleaños: '', + consentimiento: false + }; } // Build concept from selected products const concepto = selectedProducts.map(p => `${p.name} (${p.quantity}x)`).join(', '); + // Obtener información del descuento aplicado + const discountType = document.getElementById('discount-type')?.value; + const discountValue = parseFloat(document.getElementById('discount-value')?.value) || 0; + const discountReason = document.getElementById('discount-reason')?.value || ''; + + let discountInfo = null; + if (currentDiscount > 0) { + discountInfo = { + type: discountType, + value: discountValue, + amount: currentDiscount, + reason: discountReason, + percentage: currentSubtotal > 0 ? (currentDiscount / currentSubtotal * 100) : 0 + }; + + // Si es un anticipo aplicado, incluir información adicional + if (discountType === 'anticipo' && window.appliedAnticipo) { + discountInfo.anticipo = window.appliedAnticipo; + } + } + const newMovement = { id: crypto.randomUUID(), folio: generateFolio(), @@ -1278,7 +1611,8 @@ async function handleNewMovement(e) { horaCita: document.getElementById('m-hora-cita').value, productos: selectedProducts, // Store product details for ticket descuento: currentDiscount, - subtotal: currentSubtotal + subtotal: currentSubtotal, + discountInfo: discountInfo // Información detallada del descuento para el ticket }; await addMovement(newMovement); @@ -1293,6 +1627,11 @@ async function handleNewMovement(e) { calculateTotals(); hideDynamicSections(); + // Limpiar anticipo aplicado + if (window.appliedAnticipo) { + delete window.appliedAnticipo; + } + document.getElementById('m-cliente').focus(); } @@ -1437,7 +1776,7 @@ function handleTableClick(e) { const id = actionBtn.dataset.id; const action = actionBtn.dataset.action; - if (action === 'reprint' || action === 'delete') { + if (action === 'reprint' || action === 'delete' || action === 'request-cancel') { const movement = movements.find(m => m.id === id); if (movement) { if (action === 'reprint') { @@ -1445,6 +1784,8 @@ function handleTableClick(e) { renderTicketAndPrint({ ...movement, client }, settings); } else if (action === 'delete') { deleteMovement(id); + } else if (action === 'request-cancel') { + showCancellationRequestModal(id, movement); } } } else if (action === 'edit-user') { @@ -1504,6 +1845,36 @@ function handleClientTabChange(e) { activateClientSubTab(subTabId); } +function handleSalesTabChange(e) { + const subTabButton = e.target.closest('.sub-tab-link'); + if (!subTabButton) return; + e.preventDefault(); + const subTabId = subTabButton.dataset.subtab; + activateSalesSubTab(subTabId); +} + +function activateSalesSubTab(subTabId) { + if (!subTabId) return; + + document.querySelectorAll('#tab-movements .sub-tab-link').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('#tab-movements .sub-tab-content').forEach(content => content.classList.remove('active')); + + const tabButton = document.querySelector(`[data-subtab="${subTabId}"]`); + const tabContent = document.getElementById(subTabId); + + if (tabButton) { + tabButton.classList.add('active'); + } + if (tabContent) { + tabContent.classList.add('active'); + } + + // Si cambiamos a la pestaña de tickets, cargar los movimientos + if (subTabId === 'sub-tab-tickets') { + renderTable(); + } +} + function activateTab(tabId) { if (!tabId) return; @@ -1560,6 +1931,10 @@ function activateTab(tabId) { }); } loadDashboardData(); + } else if (tabId === 'tab-cancellation-requests') { + loadCancellationRequests(); + } else if (tabId === 'tab-movements') { + initializeModernSalesInterface(); } } @@ -1601,6 +1976,7 @@ function setupUIForRole(role) { const dashboardTab = document.querySelector('[data-tab="tab-dashboard"]'); const settingsTab = document.querySelector('[data-tab="tab-settings"]'); + const cancellationRequestsTab = document.getElementById('tab-cancellation-requests-btn'); const userManagementSection = document.getElementById('user-management-section'); const staffInput = document.getElementById('m-staff'); const dbInfoIcon = document.getElementById('db-info-icon'); @@ -1611,9 +1987,14 @@ function setupUIForRole(role) { if (role === 'admin') { if (dashboardTab) dashboardTab.style.display = 'block'; if (settingsTab) settingsTab.style.display = 'block'; + if (cancellationRequestsTab) cancellationRequestsTab.style.display = 'block'; if (userManagementSection) userManagementSection.style.display = 'block'; if (dbInfoIcon) dbInfoIcon.style.display = 'inline-block'; + // Show import products button for admins + const importProductsBtn = document.getElementById('btnImportProducts'); + if (importProductsBtn) importProductsBtn.style.display = 'inline-block'; + fetch('/api/users') .then(res => { if (!res.ok) throw new Error('Failed to fetch users list'); @@ -1635,6 +2016,9 @@ function setupUIForRole(role) { settingsTab.style.display = 'none'; console.log('Settings tab oculto'); } + if (cancellationRequestsTab) { + cancellationRequestsTab.style.display = 'none'; + } if (userManagementSection) userManagementSection.style.display = 'none'; if (dbInfoIcon) dbInfoIcon.style.display = 'none'; } @@ -1649,6 +2033,470 @@ function populateFooter() { } +// --- CANCELLATION REQUESTS FUNCTIONS --- + +async function loadCancellationRequests() { + if (currentUser.role !== 'admin') return; + + try { + const response = await fetch('/api/cancellation-requests'); + if (response.ok) { + cancellationRequests = await response.json(); + renderCancellationRequestsTable(); + } + } catch (error) { + console.error('Error loading cancellation requests:', error); + } +} + +function renderCancellationRequestsTable() { + const tableBody = document.querySelector('#tblCancellationRequests tbody'); + const noRequestsDiv = document.getElementById('no-cancellation-requests'); + + if (!tableBody) return; + + tableBody.innerHTML = ''; + + if (cancellationRequests.length === 0) { + if (noRequestsDiv) noRequestsDiv.style.display = 'block'; + return; + } + + if (noRequestsDiv) noRequestsDiv.style.display = 'none'; + + cancellationRequests.forEach(request => { + const row = tableBody.insertRow(); + + // Status styling + const statusClass = { + 'pending': 'status-pending', + 'approved': 'status-approved', + 'denied': 'status-denied' + }[request.status] || ''; + + const statusText = { + 'pending': 'Pendiente', + 'approved': 'Aprobada', + 'denied': 'Denegada' + }[request.status] || request.status; + + row.innerHTML = ` + ${escapeHTML(request.folio || 'N/A')} + ${escapeHTML(request.client_name || 'N/A')} + $${Number(request.monto || 0).toFixed(2)} + ${escapeHTML(request.requested_by_name || 'N/A')} + ${formatDate(request.created_at)} + ${escapeHTML(request.reason.substring(0, 50))}${request.reason.length > 50 ? '...' : ''} + ${statusText} + + ${request.status === 'pending' ? ` + + + ` : ` + + ${request.status === 'approved' ? 'Aprobada' : 'Denegada'} + ${request.reviewed_at ? `
${formatDate(request.reviewed_at)}` : ''} +
+ `} + + `; + }); +} + +async function processCancellationRequest(requestId, status) { + const request = cancellationRequests.find(r => r.id === requestId); + if (!request) return; + + const actionText = status === 'approved' ? 'aprobar' : 'denegar'; + const actionPast = status === 'approved' ? 'aprobada' : 'denegada'; + + let adminNotes = ''; + if (status === 'denied') { + adminNotes = prompt('Notas del administrador (opcional):', ''); + if (adminNotes === null) return; // Usuario canceló + } else { + const confirmMsg = `¿Estás seguro de que quieres ${actionText} la cancelación del folio ${request.folio}?\n\nEsto ${status === 'approved' ? 'ELIMINARÁ PERMANENTEMENTE' : 'restaurará'} la venta.`; + if (!confirm(confirmMsg)) return; + } + + try { + const response = await fetch(`/api/cancellation-requests/${requestId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, admin_notes: adminNotes }) + }); + + const result = await response.json(); + + if (response.ok) { + alert(`Solicitud ${actionPast} exitosamente.`); + await loadCancellationRequests(); + + // Refresh movements list to show/hide the movement + const movementsResponse = await fetch('/api/movements'); + if (movementsResponse.ok) { + movements = await movementsResponse.json(); + renderTable(); + } + } else { + alert(`Error: ${result.error}`); + } + } catch (error) { + alert('Error de conexión al procesar la solicitud.'); + } +} + +// --- BULK PRODUCTS IMPORT --- +async function importProductsFromJSON() { + const jsonData = { + "Servicios": { + "Pestañas": { + "Servicios": [ + { "nombre": "Extensión de Pestañas (Clean Girl)", "precio": 1570, "orden": 1 }, + { "nombre": "Extensión de Pestañas (Elegant Lashes)", "precio": 950, "orden": 2 }, + { "nombre": "Extensión de Pestañas (Mystery Lashes)", "precio": 1210, "orden": 3 }, + { "nombre": "Extensión de Pestañas (Seduction Lashes)", "precio": 1580, "orden": 4 }, + { "nombre": "Lash Lifting", "precio": 740, "orden": 5 }, + { "nombre": "Retiro de pestañas", "precio": 140, "orden": 6 }, + { "nombre": "Tinte para pestañas (Lash Lifting)", "precio": 210, "orden": 7 } + ], + "Retoques": { + "Elegant Lashes": [ + { "nombre": "Retoque (1ª Semana)", "precio": 320, "orden": 10 }, + { "nombre": "Retoque (2ª Semana)", "precio": 420, "orden": 11 }, + { "nombre": "Retoque (3ª Semana)", "precio": 530, "orden": 12 } + ], + "Mystery Lashes": [ + { "nombre": "Retoque (1ª Semana)", "precio": 330, "orden": 20 }, + { "nombre": "Retoque (2ª Semana)", "precio": 430, "orden": 21 }, + { "nombre": "Retoque (3ª Semana)", "precio": 540, "orden": 22 } + ], + "Seduction Lashes": [ + { "nombre": "Retoque (1ª Semana)", "precio": 340, "orden": 30 }, + { "nombre": "Retoque (2ª Semana)", "precio": 440, "orden": 31 }, + { "nombre": "Retoque (3ª Semana)", "precio": 550, "orden": 32 } + ] + } + }, + "Microblading": { + "Servicios": [ + { "nombre": "Retoque Vanity Brows (Microblading)", "precio": 3680 }, + { "nombre": "Vanity Lips", "precio": 5250 }, + { "nombre": "Microblading Vanity Brows", "precio": 5250 }, + { "nombre": "Powder Brows", "precio": 3680 } + ] + }, + "Uñas": { + "Servicios": [ + { "nombre": "Nail Art", "precio": null } + ] + } + } + }; + + const productsToImport = []; + + // Convert JSON structure to flat products array + Object.keys(jsonData.Servicios).forEach(mainCategory => { + const categoryData = jsonData.Servicios[mainCategory]; + + Object.keys(categoryData).forEach(subCategoryKey => { + if (subCategoryKey === 'Servicios') { + // Direct services + categoryData[subCategoryKey].forEach(service => { + productsToImport.push({ + name: service.nombre, + type: 'service', + price: service.precio, + category: mainCategory, + subcategory: 'Servicios', + custom_price: service.precio === null, + sort_order: service.orden || 0 + }); + }); + } else if (subCategoryKey === 'Retoques') { + // Retoques with sub-subcategories + Object.keys(categoryData[subCategoryKey]).forEach(retouchType => { + categoryData[subCategoryKey][retouchType].forEach(retouch => { + productsToImport.push({ + name: `${retouchType} - ${retouch.nombre}`, + type: 'service', + price: retouch.precio, + category: mainCategory, + subcategory: `Retoques - ${retouchType}`, + custom_price: false, + sort_order: retouch.orden || 0 + }); + }); + }); + } + }); + }); + + try { + const response = await fetch('/api/products/bulk-import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ products: productsToImport }) + }); + + const result = await response.json(); + + if (response.ok) { + alert(`${result.count} productos importados exitosamente.`); + // Refresh products list + const productsResponse = await fetch('/api/products'); + if (productsResponse.ok) { + products = await productsResponse.json(); + renderProductTables(); + updateUnifiedProductsAfterChange(); + } + } else { + alert(`Error: ${result.error}`); + } + } catch (error) { + alert('Error de conexión al importar productos.'); + console.error('Import error:', error); + } +} + +// --- MODERN SALES INTERFACE FUNCTIONS --- + +function initializeModernSalesInterface() { + // Initialize all categories as collapsed + const categorySections = document.querySelectorAll('.category-section'); + categorySections.forEach(section => { + const productsGrid = section.querySelector('.products-grid'); + const toggle = section.querySelector('.category-toggle'); + if (productsGrid && toggle) { + productsGrid.style.display = 'none'; + toggle.textContent = '▶'; + section.classList.add('collapsed'); + } + }); + + // Initialize category toggles + const categoryHeaders = document.querySelectorAll('.category-header'); + categoryHeaders.forEach(header => { + header.addEventListener('click', toggleCategory); + }); + + // Load products by categories + loadProductsByCategories(); + + // Update cart display + updateCartDisplay(); +} + +function toggleCategory(event) { + const categorySection = event.currentTarget.closest('.category-section'); + const toggle = categorySection.querySelector('.category-toggle'); + const productsGrid = categorySection.querySelector('.products-grid'); + + categorySection.classList.toggle('collapsed'); + + if (categorySection.classList.contains('collapsed')) { + productsGrid.style.display = 'none'; + toggle.textContent = '▶'; + } else { + productsGrid.style.display = 'block'; + toggle.textContent = '▼'; + } +} + +async function loadProductsByCategories() { + try { + const response = await fetch('/api/products'); + if (!response.ok) throw new Error('Failed to load products'); + + const allProducts = await response.json(); + + // Group products by category + const productsByCategory = {}; + allProducts.forEach(product => { + const category = product.category || 'Otros'; + if (!productsByCategory[category]) { + productsByCategory[category] = []; + } + productsByCategory[category].push(product); + }); + + // Populate each category + renderProductsInCategory('Vanity Lashes', productsByCategory['Vanity Lashes'] || []); + renderProductsInCategory('PMU Services', productsByCategory['PMU Services'] || []); + renderProductsInCategory('Uñas', productsByCategory['Uñas'] || []); + + } catch (error) { + console.error('Error loading products:', error); + } +} + +function renderProductsInCategory(categoryName, products) { + let containerId; + switch(categoryName) { + case 'Vanity Lashes': + containerId = 'pestanas-products'; + break; + case 'PMU Services': + containerId = 'microblading-products'; + break; + case 'Uñas': + containerId = 'unas-products'; + break; + default: + return; + } + + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = ''; + + if (products.length === 0) { + container.innerHTML = '

No hay productos disponibles

'; + return; + } + + products.forEach(product => { + const productCard = createProductCard(product); + container.appendChild(productCard); + }); +} + + +function createProductCard(product) { + const card = document.createElement('div'); + card.className = 'product-card'; + card.dataset.productId = product.id; + + const priceDisplay = product.custom_price + ? 'Precio personalizado' + : `$${parseFloat(product.price || 0).toFixed(2)}`; + + card.innerHTML = ` +
${escapeHTML(product.name)}
+ ${priceDisplay} +
+ + +
+ `; + + return card; +} + +function addProductToCart(productId) { + const productCard = document.querySelector(`[data-product-id="${productId}"]`); + if (!productCard) return; + + const quantityInput = productCard.querySelector('.quantity-input'); + const quantity = parseInt(quantityInput.value) || 1; + + // Find the product in the products array + const product = products.find(p => p.id === productId); + if (!product) return; + + // Handle custom price products + let price = product.price; + if (product.custom_price) { + const customPrice = prompt(`Ingresa el precio para "${product.name}":`, '0'); + if (customPrice === null) return; // User cancelled + price = parseFloat(customPrice) || 0; + } + + // Check if product is already in cart + const existingIndex = selectedProducts.findIndex(p => p.id === productId); + + if (existingIndex >= 0) { + // Update quantity + selectedProducts[existingIndex].quantity += quantity; + selectedProducts[existingIndex].price = price; // Update price in case it changed + } else { + // Add new product + selectedProducts.push({ + id: product.id, + name: product.name, + price: price, + quantity: quantity, + type: product.type, + custom_price: product.custom_price + }); + } + + // Visual feedback + productCard.classList.add('selected'); + setTimeout(() => { + productCard.classList.remove('selected'); + }, 1000); + + // Reset quantity input + quantityInput.value = 1; + + updateCartDisplay(); + calculateTotals(); +} + +function removeProductFromCart(productId) { + selectedProducts = selectedProducts.filter(p => p.id !== productId); + updateCartDisplay(); + calculateTotals(); +} + +function updateCartDisplay() { + const cartContainer = document.getElementById('selected-products-container'); + const cartCount = document.getElementById('cart-count'); + const cartTotal = document.getElementById('cart-total'); + + if (!cartContainer) return; + + // Update header counts + const totalItems = selectedProducts.reduce((sum, p) => sum + p.quantity, 0); + if (cartCount) cartCount.textContent = `${totalItems} producto${totalItems !== 1 ? 's' : ''}`; + + // Clear container + cartContainer.innerHTML = ''; + + if (selectedProducts.length === 0) { + cartContainer.innerHTML = ` +
+ 🛒 +

Selecciona servicios para comenzar

+
+ `; + return; + } + + selectedProducts.forEach(product => { + const cartItem = document.createElement('div'); + cartItem.className = 'cart-item'; + + cartItem.innerHTML = ` +
+
${escapeHTML(product.name)}
+
${product.quantity}x - ${product.custom_price ? 'Precio personalizado' : 'Precio fijo'}
+
+
$${(product.price * product.quantity).toFixed(2)}
+ + `; + + cartContainer.appendChild(cartItem); + }); +} + + +// Make functions globally accessible +window.addProductToCart = addProductToCart; +window.removeProductFromCart = removeProductFromCart; +window.processCancellationRequest = processCancellationRequest; +window.importProductsFromJSON = importProductsFromJSON; +window.addAnticipo = addAnticipo; + // Make removeProduct globally accessible window.removeProduct = removeProduct; @@ -1683,6 +2531,7 @@ async function initializeApp() { const btnCancelEditUser = document.getElementById('btnCancelEditUser'); const tipoServicioSelect = document.getElementById('m-tipo'); const clientSubTabs = document.querySelector('#tab-clients .sub-tabs'); + const salesSubTabs = document.querySelector('#tab-movements .sub-tabs'); const dbInfoIcon = document.getElementById('db-info-icon'); formSettings?.addEventListener('submit', handleSaveSettings); @@ -1699,6 +2548,7 @@ async function initializeApp() { formProduct?.addEventListener('submit', handleAddOrUpdateProduct); tabs?.addEventListener('click', handleTabChange); clientSubTabs?.addEventListener('click', handleClientTabChange); + salesSubTabs?.addEventListener('click', handleSalesTabChange); if (currentUser.role === 'admin') { formAddUser?.addEventListener('submit', handleAddOrUpdateUser); @@ -1809,7 +2659,7 @@ async function initializeApp() { } Promise.all([ - fetch('/api/settings').then(res => res.json()).catch(() => DEFAULT_SETTINGS), + fetch('/api/settings').then(res => res.json()).catch(() => defaultSettings), fetch('/api/movements').then(res => res.json()).catch(() => []), fetch('/api/clients').then(res => res.json()).catch(() => []), fetch('/api/products').then(res => res.json()).catch(() => []), @@ -1827,13 +2677,17 @@ async function initializeApp() { renderProductTables(); console.log('Updating client datalist...'); updateClientDatalist(); - populateArticuloDropdown(''); + // populateArticuloDropdown(''); // Legacy form function - not needed for modern interface if (currentUser) { console.log('Setting user info in form...'); - document.getElementById('s-name').value = currentUser.name || ''; - document.getElementById('s-username').value = currentUser.username; - document.getElementById('m-staff').value = currentUser.name || ''; + const sNameField = document.getElementById('s-name'); + const sUsernameField = document.getElementById('s-username'); + const mStaffField = document.getElementById('m-staff'); + + if (sNameField) sNameField.value = currentUser.name || ''; + if (sUsernameField) sUsernameField.value = currentUser.username; + if (mStaffField) mStaffField.value = currentUser.name || ''; } console.log('Setting up UI for role...'); @@ -2260,4 +3114,48 @@ function handleAnticipoSelection() { alert(`✅ ANTICIPO AGREGADO\n\n${anticipoName}`); } +// Modern interface anticipo function +function addAnticipo() { + const amountInput = document.getElementById('anticipo-amount'); + const commentInput = document.getElementById('anticipo-comment'); + + const amount = parseFloat(amountInput.value); + const comment = commentInput.value.trim(); + + if (!amount || amount <= 0) { + alert('Por favor ingresa una cantidad válida para el anticipo.'); + return; + } + + // Create anticipo product name + const anticipoName = comment ? `Anticipo - ${comment}` : 'Anticipo'; + + // Clear the client field since anticipo doesn't need a specific client + const clientInput = document.getElementById('m-cliente'); + if (clientInput) { + clientInput.value = 'Anticipo General'; + } + + // Add to cart as a product + selectedProducts.push({ + id: 'anticipo_' + Date.now(), + name: anticipoName, + price: amount, + quantity: 1, + type: 'anticipo', + custom_price: false + }); + + // Clear inputs + amountInput.value = ''; + commentInput.value = ''; + + // Update cart display + updateCartDisplay(); + calculateTotals(); + + // Show confirmation + alert(`✅ ANTICIPO AGREGADO\n\n${anticipoName}: $${amount.toFixed(2)}`); +} + document.addEventListener('DOMContentLoaded', initializeApp); diff --git a/index.html b/index.html index ae94bcb..05f4706 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,7 @@ - + @@ -33,6 +33,9 @@ + @@ -77,30 +80,159 @@
-
-

Nuevo Movimiento

-
- -
-
- - - +
+ + +
+ + +
+
+ +
+

💰 Nueva Venta

+
+ 0 productos + $0.00 +
+
+ +
+ +
+ +
+
+ + + +
+
+ + +
+

Selecciona tus servicios

+ + +
+
+ 👁️ +

Vanity Lashes

+ +
+
+ +
+
+ + +
+
+ ✏️ +

PMU Services

+ +
+
+ +
+
+ + +
+
+ 💅 +

Nail Art

+ +
+
+ +
+
+ + +
+
+ 💰 +

Anticipos

+ +
+
+
+
+ + + +
+
+
+
- -
-

Datos de la Cita

-
-
- - + +
+ +
+

🛒 Carrito de Compras

+
+
+ 🛒 +

Selecciona servicios para comenzar

+
-
- - + +
+ +
+ + + + + +
+

📅 Datos de la Cita

+
+ + - +
+ + +
+
+
+ Subtotal: + $0.00 +
+ +
+ TOTAL: + $0.00
-
- -
-

Venta

-
-
- - - - -
-
-
-
- - -
-
- - -
- + + +
+
+

🎫 Historial de Tickets y Movimientos

+ +
+ + + + + + + + + + + + + +
FolioFechaCitaClienteServicioMontoAcciones
- - -
-
- - -
-
- - -
-
- -
- - -
- - -
-
- Subtotal: - $0.00 -
- -
- Total: - $0.00 -
-
- - - - - - -
- - -
- -
- -
-

Movimientos Recientes

- -
- - - - - - - - - - - - - -
FolioFechaCitaClienteServicioMontoAcciones
-
+
+
@@ -438,6 +521,9 @@
+
@@ -528,6 +614,38 @@
+ +
+
+

Solicitudes de Cancelación

+

Aquí puedes revisar y gestionar las solicitudes de cancelación de ventas enviadas por los usuarios.

+ +
+ + + + + + + + + + + + + + +
FolioClienteMontoSolicitado porFecha SolicitudMotivoEstadoAcciones
+
+ + +
+
+
@@ -629,6 +747,6 @@
- + \ No newline at end of file diff --git a/package.json b/package.json index 2c452b5..e51a8e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ap-pos", - "version": "1.4.1", + "version": "1.5.0", "main": "app.js", "scripts": { "start": "node server.js" diff --git a/print.js b/print.js index 99b6a16..ad8159c 100644 --- a/print.js +++ b/print.js @@ -97,7 +97,41 @@ function templateTicket(mov, settings) { // DESCUENTOS SI EXISTEN if (mov.descuento && mov.descuento > 0) { - lines.push(`
Descuento ${mov.motivoDescuento ? '(' + esc(mov.motivoDescuento) + ')' : ''}:-$${Number(mov.descuento).toFixed(2)}
`); + if (mov.discountInfo) { + // Mostrar información detallada del descuento + const discountInfo = mov.discountInfo; + let descriptionText = ''; + let amountText = `-$${Number(mov.descuento).toFixed(2)}`; + + if (discountInfo.type === 'anticipo') { + // Distinguir entre anticipo registrado y manual + if (discountInfo.anticipo && discountInfo.anticipo.manual) { + descriptionText = `Anticipo manual aplicado (${discountInfo.anticipo.comentario})`; + } else { + descriptionText = `Anticipo aplicado ${discountInfo.anticipo ? `(${discountInfo.anticipo.folio})` : ''}`; + } + amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`; + } else if (discountInfo.type === 'warrior') { + descriptionText = 'Descuento Vanity (100%)'; + amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`; + } else if (discountInfo.type === 'percentage') { + descriptionText = `Descuento (${discountInfo.value}%)`; + amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`; + } else if (discountInfo.type === 'amount') { + descriptionText = `Descuento ($${discountInfo.value})`; + amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`; + } + + lines.push(`
${descriptionText}:${amountText}
`); + + // Mostrar comentario del descuento si existe + if (discountInfo.reason && discountInfo.reason.trim()) { + lines.push(`
Motivo: ${esc(discountInfo.reason)}
`); + } + } else { + // Fallback para formato anterior + lines.push(`
Descuento ${mov.motivoDescuento ? '(' + esc(mov.motivoDescuento) + ')' : ''}:-$${Number(mov.descuento).toFixed(2)}
`); + } } if (mov.staff) lines.push(`
Te atendió: ${esc(mov.staff)}
`); diff --git a/server.js b/server.js index 6a72ede..0bf8aa0 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const sqlite3 = require('sqlite3').verbose(); const cors = require('cors'); const path = require('path'); const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); const app = express(); const port = 3111; @@ -135,6 +136,55 @@ function initializeApplication() { FOREIGN KEY (course_id) REFERENCES products (id) ON DELETE CASCADE )`); + // --- Tabla de Solicitudes de Cancelación --- + db.run(`CREATE TABLE IF NOT EXISTS cancellation_requests ( + id TEXT PRIMARY KEY, + movement_id TEXT NOT NULL, + requested_by TEXT NOT NULL, -- user id + reason TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- 'pending', 'approved', 'denied' + created_at TEXT NOT NULL, + reviewed_by TEXT, + reviewed_at TEXT, + admin_notes TEXT, + FOREIGN KEY (movement_id) REFERENCES movements (id), + FOREIGN KEY (requested_by) REFERENCES users (id), + FOREIGN KEY (reviewed_by) REFERENCES users (id) + )`); + + // Agregar columna de estado temporal a movements + db.run("ALTER TABLE movements ADD COLUMN temp_cancelled INTEGER DEFAULT 0", (err) => { + if (err && !err.message.includes('duplicate column name')) { + console.error("Error adding temp_cancelled column:", err.message); + } + }); + + // Agregar columnas para categorización de productos + db.run("ALTER TABLE products ADD COLUMN category TEXT", (err) => { + if (err && !err.message.includes('duplicate column name')) { + console.error("Error adding category column:", err.message); + } + }); + + db.run("ALTER TABLE products ADD COLUMN subcategory TEXT", (err) => { + if (err && !err.message.includes('duplicate column name')) { + console.error("Error adding subcategory column:", err.message); + } + }); + + db.run("ALTER TABLE products ADD COLUMN custom_price INTEGER DEFAULT 0", (err) => { + if (err && !err.message.includes('duplicate column name')) { + console.error("Error adding custom_price column:", err.message); + } + }); + + // Agregar campo de orden para control de secuencia + db.run("ALTER TABLE products ADD COLUMN sort_order INTEGER DEFAULT 0", (err) => { + if (err && !err.message.includes('duplicate column name')) { + console.error("Error adding sort_order column:", err.message); + } + }); + // Una vez completada toda la inicialización de la DB, iniciar el servidor startServer(); }); @@ -318,7 +368,7 @@ function startServer() { // --- Movements --- apiRouter.get('/movements', (req, res) => { - db.all("SELECT * FROM movements ORDER BY fechaISO DESC", [], (err, rows) => { + db.all("SELECT * FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL ORDER BY fechaISO DESC", [], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows); }); @@ -333,13 +383,107 @@ function startServer() { }); }); - apiRouter.delete('/movements/:id', (req, res) => { + apiRouter.delete('/movements/:id', isAdmin, (req, res) => { db.run(`DELETE FROM movements WHERE id = ?`, req.params.id, function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: 'Movement deleted' }); }); }); + // --- Cancellation Requests --- + apiRouter.post('/movements/:id/cancel-request', (req, res) => { + const { reason } = req.body; + const movementId = req.params.id; + + if (!reason || reason.trim().length === 0) { + return res.status(400).json({ error: 'Reason is required' }); + } + + const requestId = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + + // First, check if movement exists and is not already cancelled + db.get("SELECT * FROM movements WHERE id = ?", [movementId], (err, movement) => { + if (err) return res.status(500).json({ error: err.message }); + if (!movement) return res.status(404).json({ error: 'Movement not found' }); + if (movement.temp_cancelled) return res.status(400).json({ error: 'Movement already has a pending cancellation request' }); + + // Check if there's already a pending request + db.get("SELECT * FROM cancellation_requests WHERE movement_id = ? AND status = 'pending'", [movementId], (err, existingRequest) => { + if (err) return res.status(500).json({ error: err.message }); + if (existingRequest) return res.status(400).json({ error: 'There is already a pending cancellation request for this sale' }); + + // Create cancellation request + db.run(`INSERT INTO cancellation_requests (id, movement_id, requested_by, reason, status, created_at) + VALUES (?, ?, ?, ?, 'pending', ?)`, + [requestId, movementId, req.session.userId, reason.trim(), createdAt], function(err) { + if (err) return res.status(500).json({ error: err.message }); + + // Mark movement as temporarily cancelled + db.run("UPDATE movements SET temp_cancelled = 1 WHERE id = ?", [movementId], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.status(201).json({ + message: 'Cancellation request created successfully', + requestId: requestId + }); + }); + }); + }); + }); + }); + + apiRouter.get('/cancellation-requests', isAdmin, (req, res) => { + const sql = ` + SELECT cr.*, u.name as requested_by_name, m.folio, m.monto, m.concepto, c.nombre as client_name + FROM cancellation_requests cr + LEFT JOIN users u ON cr.requested_by = u.id + LEFT JOIN movements m ON cr.movement_id = m.id + LEFT JOIN clients c ON m.clienteId = c.id + ORDER BY cr.created_at DESC + `; + db.all(sql, [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows); + }); + }); + + apiRouter.put('/cancellation-requests/:id', isAdmin, (req, res) => { + const { status, admin_notes } = req.body; + const requestId = req.params.id; + + if (!status || !['approved', 'denied'].includes(status)) { + return res.status(400).json({ error: 'Invalid status. Must be approved or denied' }); + } + + const reviewedAt = new Date().toISOString(); + + db.get("SELECT * FROM cancellation_requests WHERE id = ?", [requestId], (err, request) => { + if (err) return res.status(500).json({ error: err.message }); + if (!request) return res.status(404).json({ error: 'Cancellation request not found' }); + if (request.status !== 'pending') return res.status(400).json({ error: 'Request already processed' }); + + // Update request status + db.run(`UPDATE cancellation_requests SET status = ?, reviewed_by = ?, reviewed_at = ?, admin_notes = ? WHERE id = ?`, + [status, req.session.userId, reviewedAt, admin_notes || '', requestId], function(err) { + if (err) return res.status(500).json({ error: err.message }); + + if (status === 'approved') { + // If approved, delete the movement + db.run("DELETE FROM movements WHERE id = ?", [request.movement_id], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ message: 'Cancellation approved and sale deleted' }); + }); + } else { + // If denied, remove temporary cancellation + db.run("UPDATE movements SET temp_cancelled = 0 WHERE id = ?", [request.movement_id], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ message: 'Cancellation request denied and sale restored' }); + }); + } + }); + }); + }); + // --- Client History --- apiRouter.get('/clients/:id/history', (req, res) => { db.all("SELECT * FROM movements WHERE clienteId = ? ORDER BY fechaISO DESC", [req.params.id], (err, rows) => { @@ -350,32 +494,69 @@ function startServer() { // --- Product/Course Management --- apiRouter.get('/products', (req, res) => { - db.all("SELECT * FROM products ORDER BY type, name", [], (err, rows) => { + db.all("SELECT * FROM products ORDER BY type, sort_order, name", [], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); res.json(rows); }); }); - apiRouter.post('/products', isAdmin, (req, res) => { - const { name, type, price } = req.body; + apiRouter.post('/products', (req, res) => { + const { name, type, price, category, subcategory, custom_price } = req.body; if (!name || !type) return res.status(400).json({ error: 'Name and type are required' }); - db.run(`INSERT INTO products (name, type, price) VALUES (?, ?, ?)`, - [name, type, price || 0], function(err) { + db.run(`INSERT INTO products (name, type, price, category, subcategory, custom_price, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [name, type, price || 0, category, subcategory, custom_price ? 1 : 0, 0], function(err) { if (err) return res.status(500).json({ error: err.message }); - res.status(201).json({ id: this.lastID, name, type, price }); + res.status(201).json({ id: this.lastID, name, type, price, category, subcategory, custom_price, sort_order: 0 }); }); }); - apiRouter.put('/products/:id', isAdmin, (req, res) => { - const { name, type, price } = req.body; + apiRouter.put('/products/:id', (req, res) => { + const { name, type, price, category, subcategory, custom_price } = req.body; if (!name || !type) return res.status(400).json({ error: 'Name and type are required' }); - db.run(`UPDATE products SET name = ?, type = ?, price = ? WHERE id = ?`, - [name, type, price || 0, req.params.id], function(err) { + db.run(`UPDATE products SET name = ?, type = ?, price = ?, category = ?, subcategory = ?, custom_price = ? WHERE id = ?`, + [name, type, price || 0, category, subcategory, custom_price ? 1 : 0, req.params.id], function(err) { if (err) return res.status(500).json({ error: err.message }); res.json({ message: 'Product updated' }); }); }); + // Bulk update products from JSON + apiRouter.post('/products/bulk-import', isAdmin, (req, res) => { + const { products } = req.body; + if (!products || !Array.isArray(products)) { + return res.status(400).json({ error: 'Products array is required' }); + } + + db.serialize(() => { + db.run("BEGIN TRANSACTION"); + + const stmt = db.prepare(`INSERT OR REPLACE INTO products + (name, type, price, category, subcategory, custom_price, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`); + + products.forEach(product => { + stmt.run([ + product.name, + product.type || 'service', + product.price || null, + product.category || null, + product.subcategory || null, + product.custom_price ? 1 : 0, + product.sort_order || 0 + ]); + }); + + stmt.finalize(); + + db.run("COMMIT", function(err) { + if (err) { + db.run("ROLLBACK"); + return res.status(500).json({ error: err.message }); + } + res.json({ message: 'Products imported successfully', count: products.length }); + }); + }); + }); + apiRouter.delete('/products/:id', isAdmin, (req, res) => { db.run(`DELETE FROM products WHERE id = ?`, req.params.id, function(err) { if (err) return res.status(500).json({ error: err.message }); @@ -490,15 +671,15 @@ function startServer() { // --- Dashboard Route (Authenticated Users) --- apiRouter.get('/dashboard', isAuthenticated, (req, res) => { const queries = { - totalIncome: "SELECT SUM(monto) as total FROM movements", - totalMovements: "SELECT COUNT(*) as total FROM movements", - incomeByService: "SELECT tipo, SUM(monto) as total FROM movements GROUP BY tipo", - incomeByPaymentMethod: "SELECT metodo, SUM(monto) as total FROM movements WHERE metodo IS NOT NULL AND metodo != '''' GROUP BY metodo", + totalIncome: "SELECT SUM(monto) as total FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL", + totalMovements: "SELECT COUNT(*) as total FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL", + incomeByService: "SELECT tipo, SUM(monto) as total FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL GROUP BY tipo", + incomeByPaymentMethod: "SELECT metodo, SUM(monto) as total FROM movements WHERE (temp_cancelled = 0 OR temp_cancelled IS NULL) AND metodo IS NOT NULL AND metodo != '''' GROUP BY metodo", upcomingAppointments: ` SELECT m.id, m.folio, m.fechaCita, m.horaCita, c.nombre as clienteNombre FROM movements m JOIN clients c ON m.clienteId = c.id - WHERE m.fechaCita IS NOT NULL AND m.fechaCita >= date('now') + WHERE (m.temp_cancelled = 0 OR m.temp_cancelled IS NULL) AND m.fechaCita IS NOT NULL AND m.fechaCita >= date('now') ORDER BY m.fechaCita ASC, m.horaCita ASC LIMIT 5` }; diff --git a/styles.css b/styles.css index dba6e35..4349f7c 100644 --- a/styles.css +++ b/styles.css @@ -1460,4 +1460,795 @@ table tbody tr:hover { .btn-aplicar-anticipo:hover { background: #218838; +} + +/* --- Cancellation Request Styles --- */ +.modal { + display: flex; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + justify-content: center; + align-items: center; +} + +.modal-content { + background-color: #fefefe; + margin: 15% auto; + padding: 20px; + border-radius: 8px; + width: 80%; + max-width: 600px; + position: relative; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-height: 80vh; + overflow-y: auto; +} + +.close-button { + color: #aaa; + position: absolute; + top: 10px; + right: 20px; + font-size: 28px; + font-weight: bold; + cursor: pointer; +} + +.close-button:hover, +.close-button:focus { + color: #000; + text-decoration: none; +} + +.cancellation-info { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 15px; + margin: 15px 0; +} + +.cancellation-info p { + margin: 8px 0; +} + +.status-badge { + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; +} + +.status-pending { + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeaa7; +} + +.status-approved { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status-denied { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.btn-warning { + background-color: #ffc107; + color: #212529; + border: 1px solid #ffc107; +} + +.btn-warning:hover { + background-color: #e0a800; + border-color: #d39e00; +} + +.btn-success { + background-color: #28a745; + color: white; + border: 1px solid #28a745; + margin-right: 5px; +} + +.btn-success:hover { + background-color: #218838; + border-color: #1e7e34; +} + +.reason-cell { + max-width: 200px; + word-wrap: break-word; + cursor: help; +} + +.processed-info { + font-style: italic; + color: #6c757d; +} + +.processed-info small { + font-size: 10px; + color: #868e96; +} + +.section-description { + color: #6c757d; + margin-bottom: 20px; + font-style: italic; +} + +/* --- Modern Sales Interface --- */ +.sales-container { + padding: 0; + max-width: none; + background: transparent; +} + +.sales-header { + background: #000000 !important; + background-image: none !important; + color: white; + padding: 20px 30px; + border-radius: 12px; + margin-bottom: 25px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 4px 15px rgba(44, 62, 80, 0.2); +} + +.sales-header h2 { + margin: 0; + font-size: 28px; + font-weight: 600; + border: none; + padding: 0; + color: white !important; +} + +.sales-summary { + display: flex; + gap: 20px; + align-items: center; +} + +.cart-count, .cart-total { + background: rgba(255, 255, 255, 0.2); + padding: 8px 16px; + border-radius: 20px; + font-weight: 600; + backdrop-filter: blur(10px); +} + +.sales-layout { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 30px; + align-items: start; +} + +/* --- Products Panel --- */ +.products-panel { + background: #fff; + border-radius: 16px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + border: 1px solid #f0f0f0; +} + +.client-selector { + margin-bottom: 25px; +} + +.modern-input { + width: 100%; + padding: 14px 20px; + border: 2px solid #e1e5e9; + border-radius: 12px; + font-size: 16px; + transition: all 0.3s ease; + background: #fafbfc; +} + +.modern-input:focus { + outline: none; + border-color: #667eea; + background: white; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.modern-input::placeholder { + color: #a0a8b0; +} + +/* --- Categories --- */ +.categories-container h3 { + color: #2c3e50; + margin-bottom: 20px; + font-size: 20px; +} + +.category-section { + margin-bottom: 20px; + border: 2px solid #f5f6fa; + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; +} + +.category-section:hover { + border-color: #e9ecef; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.category-header { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + padding: 15px 20px; + display: flex; + align-items: center; + cursor: pointer; + transition: background 0.3s ease; +} + +.category-header:hover { + background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%); +} + +.category-icon { + font-size: 24px; + margin-right: 12px; +} + +.category-header h4 { + margin: 0; + flex: 1; + font-size: 18px; + color: #2c3e50; + font-weight: 600; +} + +.category-toggle { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: #6c757d; + transition: transform 0.3s ease; +} + +.category-section.collapsed .category-toggle { + transform: rotate(-90deg); +} + +.products-grid { + padding: 20px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 15px; + background: white; +} + +.product-card { + display: grid; + grid-template-columns: 1fr auto auto auto; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid #e0e0e0; + cursor: pointer; + transition: background-color 0.2s ease; + background: white; +} + +.product-card:nth-child(even) { + background: #f8f9fa; +} + +.product-card:hover { + background: #e3f2fd; +} + +.product-card.selected { + background: #e8f5e9; + border-left: 3px solid #4caf50; +} + +.product-info { + display: flex; + flex-direction: column; + flex: 1; +} + +.product-name { + font-weight: 500; + color: #333; + font-size: 14px; + margin: 0; +} + +.product-price { + color: #2c3e50; + font-size: 14px; + font-weight: 600; + margin: 0; + text-align: right; + min-width: 90px; + display: block; + justify-self: end; +} + +.product-price.custom { + color: #e74c3c; + font-style: italic; +} + +.product-actions { + display: flex; + gap: 8px; + align-items: center; + justify-self: end; +} + +.btn-select-product { + background: #2c3e50; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.btn-select-product:hover { + background: #34495e; +} + +/* Anticipos section */ +.anticipos-grid { + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + margin-top: 8px; +} + +.anticipo-form { + background: white; + padding: 16px; + border-radius: 6px; + border: 1px solid #e0e0e0; +} + +.anticipo-input-group { + display: flex; + gap: 8px; + align-items: center; +} + +.anticipo-input-group input { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +#anticipo-amount { + width: 100px; +} + +#anticipo-comment { + flex: 1; +} + +.btn-add-anticipo { + background: #28a745; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + white-space: nowrap; +} + +.btn-add-anticipo:hover { + background: #218838; +} + +/* Estilos para checkbox de confirmación de anticipo manual */ +.checkbox-container { + display: flex; + align-items: center; + cursor: pointer; + margin: 10px 0; + padding: 8px; + border-radius: 6px; + background: #fff8dc; + border: 1px solid #f0ad4e; +} + +.checkbox-container input[type="checkbox"] { + margin-right: 8px; + transform: scale(1.2); +} + +.checkbox-text { + font-size: 14px; + color: #856404; + font-weight: 500; +} + +.checkmark { + margin-right: 5px; +} + +.quantity-input { + width: 60px; + padding: 8px; + border: 2px solid #e1e5e9; + border-radius: 6px; + text-align: center; + font-size: 14px; +} + +/* --- Checkout Panel --- */ +.checkout-panel { + background: #fff; + border-radius: 16px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + border: 1px solid #f0f0f0; + position: sticky; + top: 20px; + height: fit-content; +} + +.modern-card { + background: #fafbfc; + border: 1px solid #e1e5e9; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + transition: all 0.3s ease; +} + +.modern-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + border-color: #d1d9e0; +} + +.modern-card h3, .modern-card h4 { + margin: 0 0 15px 0; + color: #2c3e50; + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +/* --- Cart Section --- */ +.cart-section h3 { + border-bottom: 2px solid #e9ecef; + padding-bottom: 10px; + margin-bottom: 20px; +} + +.cart-items { + max-height: 300px; + overflow-y: auto; +} + +.empty-cart { + text-align: center; + padding: 40px 20px; + color: #6c757d; +} + +.empty-icon { + font-size: 48px; + opacity: 0.5; + display: block; + margin-bottom: 10px; +} + +.cart-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid #f1f3f4; +} + +.cart-item:last-child { + border-bottom: none; +} + +.item-info { + flex: 1; +} + +.item-name { + font-weight: 600; + color: #2c3e50; + margin-bottom: 4px; +} + +.item-details { + font-size: 12px; + color: #6c757d; +} + +.item-price { + font-weight: 600; + color: #667eea; + margin-left: 10px; +} + +.btn-remove-item { + background: #ff6b6b; + color: white; + border: none; + width: 24px; + height: 24px; + border-radius: 50%; + cursor: pointer; + font-size: 12px; + margin-left: 8px; +} + +/* --- Discount Section --- */ +.modern-checkbox { + display: none; +} + +.discount-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-weight: 600; + color: #2c3e50; +} + +.discount-icon { + font-size: 20px; +} + +.modern-checkbox:checked + .discount-label .discount-icon { + color: #ffc107; +} + +.discount-options { + display: flex; + gap: 10px; + margin-bottom: 10px; +} + +.modern-select { + flex: 1; + padding: 10px 12px; + border: 2px solid #e1e5e9; + border-radius: 8px; + background: white; + font-size: 14px; +} + +.discount-input-group { + display: flex; + align-items: center; + background: white; + border: 2px solid #e1e5e9; + border-radius: 8px; + padding: 2px; + width: 120px; +} + +.discount-input-group input { + border: none; + background: none; + padding: 8px 10px; + font-size: 14px; + width: 80px; +} + +.discount-input-group .input-symbol { + background: #f8f9fa; + padding: 8px; + font-size: 12px; + font-weight: 600; + color: #667eea; + border-radius: 6px; + margin-right: 2px; +} + +/* --- Payment Section --- */ +.payment-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.payment-option { + cursor: pointer; +} + +.payment-option input { + display: none; +} + +.payment-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 15px 10px; + border: 2px solid #e1e5e9; + border-radius: 12px; + transition: all 0.3s ease; + background: white; +} + +.payment-option input:checked + .payment-card { + border-color: #28a745; + background: linear-gradient(135deg, #f8fff9 0%, #e8f5e8 100%); +} + +.payment-icon { + font-size: 24px; + margin-bottom: 8px; +} + +/* --- Totals Section --- */ +.totals-breakdown { + background: white; + border-radius: 8px; + padding: 20px; +} + +.total-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #f1f3f4; +} + +.total-row:last-child { + border-bottom: none; +} + +.final-total { + border-top: 2px solid #e9ecef; + margin-top: 10px; + padding-top: 15px; + font-size: 18px; +} + +.final-amount { + color: #28a745; + font-size: 20px; +} + +.discount-amount { + color: #ff6b6b; +} + +/* --- Action Buttons --- */ +.action-buttons { + display: flex; + gap: 15px; + margin-top: 20px; +} + +.btn-checkout { + background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + color: white; + border: none; + padding: 15px 25px; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.3s ease; +} + +.btn-checkout:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(40, 167, 69, 0.3); +} + +.btn-clear { + background: #6c757d; + color: white; + border: none; + padding: 15px 20px; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.3s ease; +} + +.btn-clear:hover { + background: #5a6268; + transform: translateY(-1px); +} + +.btn-icon { + font-size: 18px; +} + +/* --- Notes Section --- */ +.modern-textarea { + width: 100%; + padding: 12px 16px; + border: 2px solid #e1e5e9; + border-radius: 8px; + font-size: 14px; + font-family: inherit; + resize: vertical; + min-height: 80px; + background: white; +} + +.modern-textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* --- Responsive Design --- */ +@media (max-width: 1200px) { + .sales-layout { + grid-template-columns: 1fr; + gap: 20px; + } + + .checkout-panel { + position: static; + } +} + +@media (max-width: 768px) { + .sales-header { + flex-direction: column; + gap: 15px; + text-align: center; + } + + .products-grid { + grid-template-columns: 1fr; + } + + .payment-grid { + grid-template-columns: 1fr; + } + + .discount-options { + flex-direction: column; + } + + .action-buttons { + flex-direction: column; + } } \ No newline at end of file