import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js'; import { renderTicketAndPrint } from './print.js?v=1757039367'; // --- UTILITIES --- function escapeHTML(str) { if (str === null || str === undefined) { return ''; } return str.toString() .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function construirFechaCita() { const fechaPicker = document.getElementById('m-fecha-cita'); if (!fechaPicker || !fechaPicker.value) return ''; // Convertir de formato ISO (YYYY-MM-DD) a formato DD/MM/YYYY const dateParts = fechaPicker.value.split('-'); return `${dateParts[2]}/${dateParts[1]}/${dateParts[0]}`; } // Actualizar horarios disponibles basados en la fecha seleccionada function updateAvailableTimeSlots(selectedDate) { const horaSelect = document.getElementById('m-hora-cita'); if (!horaSelect || !selectedDate) return; // Horarios base disponibles const baseTimeSlots = [ { value: '10:00', label: '10:00 AM' }, { value: '10:30', label: '10:30 AM' }, { value: '11:00', label: '11:00 AM' }, { value: '11:30', label: '11:30 AM' }, { value: '12:00', label: '12:00 PM' }, { value: '12:30', label: '12:30 PM' }, { value: '13:00', label: '1:00 PM' }, { value: '13:30', label: '1:30 PM' }, { value: '14:00', label: '2:00 PM' }, { value: '14:30', label: '2:30 PM' }, { value: '15:00', label: '3:00 PM' }, { value: '15:30', label: '3:30 PM' }, { value: '16:00', label: '4:00 PM' }, { value: '16:30', label: '4:30 PM' }, { value: '17:00', label: '5:00 PM' }, { value: '17:30', label: '5:30 PM' }, { value: '18:00', label: '6:00 PM' } ]; // Convertir fecha del date picker (YYYY-MM-DD) al formato usado en la base (DD/MM/YYYY) const dateParts = selectedDate.split('-'); const formattedDate = `${dateParts[2]}/${dateParts[1]}/${dateParts[0]}`; // Obtener citas ya programadas para esta fecha const existingAppointments = movements .filter(mov => { // Comparar tanto con formato ISO como DD/MM/YYYY return mov.fechaCita === selectedDate || mov.fechaCita === formattedDate; }) .map(mov => mov.horaCita) .filter(hora => hora); // Filtrar horarios disponibles const availableSlots = baseTimeSlots.filter(slot => !existingAppointments.includes(slot.value) ); // Limpiar y repoblar selector const currentValue = horaSelect.value; horaSelect.innerHTML = ''; availableSlots.forEach(slot => { const option = document.createElement('option'); option.value = slot.value; option.textContent = slot.label; horaSelect.appendChild(option); }); // Si había un horario ocupado seleccionado, mostrarlo como no disponible if (currentValue && existingAppointments.includes(currentValue)) { const busyOption = document.createElement('option'); busyOption.value = currentValue; busyOption.textContent = `${currentValue} (Ocupado)`; busyOption.disabled = true; busyOption.style.color = '#dc3545'; horaSelect.appendChild(busyOption); horaSelect.value = currentValue; } // Mostrar contador de horarios disponibles const availableCount = availableSlots.length; const totalCount = baseTimeSlots.length; console.log(`Horarios disponibles para ${selectedDate}: ${availableCount}/${totalCount}`); // Actualizar indicador visual de disponibilidad const availabilityInfo = document.getElementById('time-availability-info'); const availabilityCount = document.getElementById('available-slots-count'); if (availabilityInfo && availabilityCount) { if (availableCount > 0) { availabilityCount.textContent = `✅ ${availableCount} de ${totalCount} horarios disponibles`; availabilityInfo.style.display = 'block'; availabilityInfo.style.backgroundColor = availableCount > totalCount * 0.5 ? '#e8f5e8' : '#fff3cd'; availabilityInfo.style.borderColor = availableCount > totalCount * 0.5 ? '#c3e6cb' : '#ffeaa7'; availabilityInfo.style.color = availableCount > totalCount * 0.5 ? '#155724' : '#856404'; } else { availabilityCount.textContent = '❌ No hay horarios disponibles para esta fecha'; availabilityInfo.style.display = 'block'; availabilityInfo.style.backgroundColor = '#f8d7da'; availabilityInfo.style.borderColor = '#f5c6cb'; availabilityInfo.style.color = '#721c24'; } } } // Sistema dinámico de productos y descuentos let selectedProducts = []; let currentSubtotal = 0; let currentDiscount = 0; function initializeDynamicSystem() { const articuloSelect = document.getElementById('m-articulo'); const categoriaSelect = document.getElementById('m-categoria'); const addProductBtn = document.getElementById('add-product-btn'); const discountType = document.getElementById('discount-type'); const discountValue = document.getElementById('discount-value'); const discountReason = document.getElementById('discount-reason'); const clienteInput = document.getElementById('m-cliente'); // Listener para cambio de categoría (servicio/curso) if (categoriaSelect) { categoriaSelect.addEventListener('change', function() { populateArticuloDropdown(this.value); }); } // Botón para agregar productos if (addProductBtn) { addProductBtn.addEventListener('click', addCurrentProduct); } // Sistema de descuentos colapsable const discountToggle = document.getElementById('discount-toggle'); const discountContainer = document.getElementById('discount-container'); const discountSymbol = document.getElementById('discount-symbol'); if (discountToggle && discountContainer) { discountToggle.addEventListener('change', function() { if (this.checked) { discountContainer.style.display = 'block'; // Habilitar campos cuando se abre la sección if (discountType.value) { discountValue.disabled = false; discountReason.disabled = false; } } else { discountContainer.style.display = 'none'; // Limpiar y deshabilitar campos cuando se cierra discountType.value = ''; discountValue.value = ''; discountReason.value = ''; discountValue.disabled = true; discountReason.disabled = true; calculateTotals(); } }); } if (discountType) { discountType.addEventListener('change', function() { const isDiscountSelected = this.value !== ''; discountValue.disabled = !isDiscountSelected; discountReason.disabled = !isDiscountSelected; // Actualizar símbolo según el tipo if (discountSymbol) { if (this.value === 'percentage') { discountSymbol.textContent = '%'; } else if (this.value === 'amount') { discountSymbol.textContent = '$'; } else if (this.value === 'warrior') { discountSymbol.textContent = '🎗️'; } else { discountSymbol.textContent = '%'; } } if (!isDiscountSelected) { discountValue.value = ''; discountReason.value = ''; } calculateTotals(); }); } if (discountValue) { discountValue.addEventListener('input', calculateTotals); } // Detección automática de pacientes oncológicos para descuento Warrior if (clienteInput) { clienteInput.addEventListener('blur', function() { const clienteNombre = this.value.trim(); if (clienteNombre) { const client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase()); if (client && client.esOncologico) { // Activar automáticamente el descuento Warrior activateWarriorDiscount(); } // Cargar anticipos disponibles del cliente loadClientAnticipos(clienteNombre); } else { // Si no hay cliente, ocultar anticipos document.getElementById('anticipos-section').style.display = 'none'; } }); } } function activateWarriorDiscount() { const discountToggle = document.getElementById('discount-toggle'); const discountContainer = document.getElementById('discount-container'); const discountType = document.getElementById('discount-type'); const discountValue = document.getElementById('discount-value'); const discountReason = document.getElementById('discount-reason'); // Activar la sección de descuentos if (discountToggle && !discountToggle.checked) { discountToggle.checked = true; if (discountContainer) { discountContainer.style.display = 'block'; } } // Seleccionar descuento Warrior if (discountType) { discountType.value = 'warrior'; discountType.dispatchEvent(new Event('change')); } // Establecer valores automáticamente if (discountValue) { discountValue.value = 100; discountValue.disabled = true; } if (discountReason) { discountReason.value = 'Paciente Oncológico'; discountReason.disabled = true; } // Calcular totales calculateTotals(); } function showDynamicSections() { // Show the product selection area and totals const selectedProducts = document.getElementById('selected-products'); const totalsSection = document.querySelector('.totals-section'); if (selectedProducts) selectedProducts.style.display = 'block'; if (totalsSection) totalsSection.style.display = 'block'; } function hideDynamicSections() { const selectedProductsEl = document.getElementById('selected-products'); const totalsSection = document.querySelector('.totals-section'); if (selectedProductsEl) selectedProductsEl.style.display = 'none'; if (totalsSection) totalsSection.style.display = 'none'; selectedProducts = []; renderSelectedProducts(); } function addCurrentProduct() { const articuloSelect = document.getElementById('m-articulo'); const categoriaSelect = document.getElementById('m-categoria'); const quantityInput = document.getElementById('product-quantity'); if (!categoriaSelect.value) { alert('Selecciona el tipo (servicio, curso o anticipo) primero'); return; } if (!articuloSelect.value) { alert('Selecciona un producto primero'); return; } const quantity = parseInt(quantityInput.value) || 1; // Manejar anticipos de forma especial if (categoriaSelect.value === 'anticipo') { handleAnticipoSelection(); return; } else { // Manejar servicios y cursos como antes const productData = products.find(p => p.name === articuloSelect.value && p.type === categoriaSelect.value); if (productData) { const existingIndex = selectedProducts.findIndex(p => p.name === productData.name); if (existingIndex >= 0) { selectedProducts[existingIndex].quantity += quantity; } else { selectedProducts.push({ id: productData.id, name: productData.name, price: parseFloat(productData.price), quantity: quantity, type: categoriaSelect.value }); } } else { alert('Producto no encontrado'); return; } } renderSelectedProducts(); calculateTotals(); quantityInput.value = 1; articuloSelect.value = ''; // Mostrar descuento inmediatamente showDiscountSection(); } function removeProduct(productName) { selectedProducts = selectedProducts.filter(p => p.name !== productName); renderSelectedProducts(); calculateTotals(); } function renderSelectedProducts() { const container = document.getElementById('selected-products'); if (!container) return; if (selectedProducts.length === 0) { container.innerHTML = '

No hay productos seleccionados

'; return; } const html = selectedProducts.map(product => { // Para anticipos no agregar el tipo en small porque ya está en el nombre const typeLabel = product.type === 'anticipo' ? '' : ` (${product.type === 'service' ? 'Servicio' : 'Curso'})`; return `
${escapeHTML(product.name)}${typeLabel} ${product.quantity}x $${(product.price * product.quantity).toFixed(2)}
`; }).join(''); container.innerHTML = html; } function showDiscountSection() { const discountSection = document.querySelector('.discount-section'); if (discountSection && selectedProducts.length > 0) { discountSection.style.display = 'block'; discountSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } async function loadClientAnticipos(clienteNombre) { if (!clienteNombre) { document.getElementById('anticipos-section').style.display = 'none'; return; } try { // Buscar anticipos en el historial de movimientos 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 => mov.concepto && mov.concepto.includes('Anticipo') && mov.client && mov.client.nombre.toLowerCase() === clienteNombre.toLowerCase() && !mov.aplicado // Assuming we'll add an 'aplicado' field to track used anticipos ); const anticiposSection = document.getElementById('anticipos-section'); const anticiposContainer = document.getElementById('anticipos-disponibles'); if (anticipos.length > 0) { anticiposSection.style.display = 'block'; anticiposContainer.innerHTML = ''; anticipos.forEach(anticipo => { const anticipoItem = document.createElement('div'); anticipoItem.className = 'anticipo-item'; anticipoItem.innerHTML = `
$${parseFloat(anticipo.monto).toFixed(2)}
Fecha: ${new Date(anticipo.fecha).toLocaleDateString()}
Folio: ${anticipo.folio}
`; anticiposContainer.appendChild(anticipoItem); }); } else { anticiposSection.style.display = 'none'; } } catch (error) { console.error('Error loading anticipos:', error); document.getElementById('anticipos-section').style.display = 'none'; } } function aplicarAnticipo(anticipoId, monto) { // Agregar el anticipo como un "descuento" o crédito const discountToggle = document.getElementById('discount-toggle'); const discountContainer = document.getElementById('discount-container'); const discountType = document.getElementById('discount-type'); const discountValue = document.getElementById('discount-value'); const discountReason = document.getElementById('discount-reason'); // Activar la sección de descuentos if (discountToggle && !discountToggle.checked) { discountToggle.checked = true; if (discountContainer) { discountContainer.style.display = 'block'; } } // Configurar como descuento de cantidad fija if (discountType) { discountType.value = 'amount'; discountType.dispatchEvent(new Event('change')); } // Establecer el monto del anticipo if (discountValue) { discountValue.value = parseFloat(monto).toFixed(2); discountValue.disabled = true; } if (discountReason) { discountReason.value = `Anticipo aplicado (ID: ${anticipoId})`; discountReason.disabled = true; } // Calcular totales calculateTotals(); // Ocultar la sección de anticipos para evitar aplicar múltiples document.getElementById('anticipos-section').style.display = 'none'; alert('Anticipo aplicado correctamente'); } function calculateTotals() { currentSubtotal = selectedProducts.reduce((sum, product) => { return sum + (product.price * product.quantity); }, 0); // Calcular descuento const discountType = document.getElementById('discount-type')?.value; const discountValue = parseFloat(document.getElementById('discount-value')?.value) || 0; if (discountType === 'percentage') { currentDiscount = currentSubtotal * (discountValue / 100); } else if (discountType === 'amount') { currentDiscount = Math.min(discountValue, currentSubtotal); } else if (discountType === 'warrior') { currentDiscount = currentSubtotal; // 100% de descuento } else { currentDiscount = 0; } const total = currentSubtotal - currentDiscount; // Actualizar displays principales const subtotalDisplay = document.getElementById('subtotal-display'); const discountDisplay = document.getElementById('discount-display'); const discountAmountDisplay = document.getElementById('discount-amount-display'); const totalDisplay = document.getElementById('total-display'); if (subtotalDisplay) subtotalDisplay.textContent = `$${currentSubtotal.toFixed(2)}`; if (totalDisplay) totalDisplay.textContent = `$${total.toFixed(2)}`; if (currentDiscount > 0) { if (discountDisplay) discountDisplay.style.display = 'flex'; if (discountAmountDisplay) discountAmountDisplay.textContent = `-$${currentDiscount.toFixed(2)}`; } else { if (discountDisplay) discountDisplay.style.display = 'none'; } // Actualizar preview del descuento en la sección colapsable const discountPreview = document.getElementById('discount-preview'); const discountPreviewAmount = document.getElementById('discount-preview-amount'); if (discountPreview && discountPreviewAmount) { if (currentDiscount > 0) { discountPreview.style.display = 'block'; discountPreviewAmount.textContent = `-$${currentDiscount.toFixed(2)}`; } else { discountPreview.style.display = 'none'; } } // Actualizar el campo de monto original const montoInput = document.getElementById('m-monto'); if (montoInput) montoInput.value = total.toFixed(2); } function formatDate(dateString) { if (!dateString) return ''; const date = new Date(dateString); const userTimezoneOffset = date.getTimezoneOffset() * 60000; const adjustedDate = new Date(date.getTime() + userTimezoneOffset); const day = String(adjustedDate.getDate()).padStart(2, '0'); const month = String(adjustedDate.getMonth() + 1).padStart(2, '0'); const year = adjustedDate.getFullYear(); return `${day}/${month}/${year}`; } 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'); const tblMovesBody = document.getElementById('tblMoves')?.querySelector('tbody'); const btnExport = document.getElementById('btnExport'); const btnTestTicket = document.getElementById('btnTestTicket'); const formClient = document.getElementById('formClient'); const tblClientsBody = document.getElementById('tblClients')?.querySelector('tbody'); const clientDatalist = document.getElementById('client-list'); const formCredentials = document.getElementById('formCredentials'); const formAddUser = document.getElementById('formAddUser'); const tblUsersBody = document.getElementById('tblUsers')?.querySelector('tbody'); const tblServicesBody = document.getElementById('tblServices')?.querySelector('tbody'); const tblCoursesBody = document.getElementById('tblCourses')?.querySelector('tbody'); const formProduct = document.getElementById('formProduct'); const appointmentsList = document.getElementById('upcoming-appointments-list'); let isDashboardLoading = false; // --- LÓGICA DE NEGOCIO --- async function loadDashboardData() { if (isDashboardLoading) { return; } isDashboardLoading = true; try { const response = await fetch('/api/dashboard'); if (!response.ok) { if (response.status === 403) { console.warn('Acceso al dashboard denegado.'); } else { throw new Error('Falló la carga de datos del dashboard'); } return; } const data = await response.json(); const dashboardTab = document.getElementById('tab-dashboard'); if (!dashboardTab.classList.contains('active')) { return; } document.getElementById('stat-total-income').textContent = `${Number(data.totalIncome || 0).toFixed(2)}`; document.getElementById('stat-total-movements').textContent = data.totalMovements || 0; if (incomeChart) { incomeChart.data.labels = data.incomeByService.map(item => item.tipo); incomeChart.data.datasets[0].data = data.incomeByService.map(item => item.total); incomeChart.update('none'); } if (paymentMethodChart) { paymentMethodChart.data.labels = data.incomeByPaymentMethod.map(item => item.metodo); paymentMethodChart.data.datasets[0].data = data.incomeByPaymentMethod.map(item => item.total); paymentMethodChart.update('none'); } if (appointmentsList) { appointmentsList.innerHTML = ''; if (data.upcomingAppointments.length > 0) { data.upcomingAppointments.forEach(appt => { const item = document.createElement('div'); item.className = 'appointment-item'; item.innerHTML = ` ${appt.clienteNombre} ${formatDate(appt.fechaCita)} - ${appt.horaCita} `; appointmentsList.appendChild(item); }); } else { appointmentsList.innerHTML = '

No hay citas próximas.

'; } } } catch (error) { console.error('Error al cargar el dashboard:', error); } finally { isDashboardLoading = false; } } function generateFolio() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 5; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } async function addMovement(mov) { try { const response = await fetch('/api/movements', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ movement: mov }) }); if (response.ok) { movements.unshift(mov); renderTable(); } else { throw new Error('Failed to save movement'); } } catch (error) { console.error('Error saving movement:', error); alert('Error al guardar el movimiento'); } } async function deleteMovement(id) { if (confirm('¿Estás seguro de que quieres eliminar este movimiento?')) { try { const response = await fetch(`/api/movements/${id}`, { method: 'DELETE' }); if (response.ok) { movements = movements.filter(m => m.id !== id); renderTable(); } else { throw new Error('Failed to delete movement'); } } catch (error) { console.error('Error deleting movement:', error); alert('Error al eliminar el movimiento'); } } } async function saveClient(clientData) { let clientToSave; let isUpdate = false; if (clientData) { clientToSave = clientData; } else { isUpdate = !!document.getElementById('c-id').value; const id = isUpdate ? document.getElementById('c-id').value : crypto.randomUUID(); clientToSave = { id: id, nombre: document.getElementById('c-nombre').value, telefono: document.getElementById('c-telefono').value, genero: document.getElementById('c-genero').value, cumpleaños: document.getElementById('c-cumple').value, consentimiento: document.getElementById('c-consent').checked, esOncologico: document.getElementById('c-pacienteOncologico').checked, oncologoAprueba: document.getElementById('c-oncologoAprueba').checked, nombreMedico: document.getElementById('c-nombreMedico').value, telefonoMedico: document.getElementById('c-telefonoMedico').value, cedulaMedico: document.getElementById('c-cedulaMedico').value, pruebaAprobacion: document.getElementById('c-pruebaAprobacion').checked, }; } try { const response = await fetch('/api/clients', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client: clientToSave }) }); if (!response.ok) { throw new Error('Failed to save client'); } } catch (error) { console.error('Error saving client:', error); alert('Error al guardar el cliente'); return; } if (isUpdate) { const index = clients.findIndex(c => c.id === clientToSave.id); if (index > -1) clients[index] = clientToSave; } else { clients.unshift(clientToSave); } renderClientsTable(); updateClientDatalist(); if (!clientData) { document.getElementById('formClient').reset(); document.getElementById('c-id').value = ''; document.getElementById('oncologico-fields').classList.add('hidden'); } } async function deleteClient(id) { if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) { try { const response = await fetch(`/api/clients/${id}`, { method: 'DELETE' }); if (response.ok) { clients = clients.filter(c => c.id !== id); renderClientsTable(); updateClientDatalist(); clearClientRecord(); } else { throw new Error('Failed to delete client'); } } catch (error) { console.error('Error deleting client:', error); alert('Error al eliminar el cliente'); } } } function exportCSV() { const headers = 'folio,fechaISO,cliente,tipo,monto,metodo,concepto,staff,notas,fechaCita,horaCita'; const rows = movements.map(m => { const client = clients.find(c => c.id === m.clienteId); return [ m.folio, m.fechaISO, client ? client.nombre : 'N/A', m.tipo, m.monto, m.metodo || '', m.concepto || '', m.staff || '', m.notas || '', m.fechaCita || '', m.horaCita || '' ].map(val => `"${String(val).replace(/"/g, '""')}"`).join(','); }); const csvContent = `data:text/csv;charset=utf-8,${headers}\n${rows.join('\n')}`; const encodedUri = encodeURI(csvContent); const link = document.createElement('a'); link.setAttribute('href', encodedUri); link.setAttribute('download', 'movimientos.csv'); document.body.appendChild(link); link.click(); document.body.removeChild(link); } // --- RENDERIZADO --- function renderSettings() { document.getElementById('s-negocio').value = settings.negocio || ''; document.getElementById('s-tagline').value = settings.tagline || ''; document.getElementById('s-calle').value = settings.calle || ''; document.getElementById('s-colonia-cp').value = settings.colonia && settings.cp ? `${settings.colonia}, ${settings.cp}` : ''; document.getElementById('s-rfc').value = settings.rfc || ''; document.getElementById('s-tel').value = settings.tel || ''; document.getElementById('s-leyenda').value = settings.leyenda || ''; document.getElementById('s-folioPrefix').value = settings.folioPrefix || ''; } function renderTable() { if (!tblMovesBody) return; tblMovesBody.innerHTML = ''; movements.forEach(mov => { const client = clients.find(c => c.id === mov.clienteId); const tr = tblMovesBody.insertRow(); const tipoServicio = mov.subtipo ? `${escapeHTML(mov.tipo)} (${escapeHTML(mov.subtipo)})` : escapeHTML(mov.tipo); const folioCell = tr.insertCell(); const folioLink = document.createElement('a'); folioLink.href = '#'; folioLink.className = 'action-btn'; folioLink.dataset.id = mov.id; folioLink.dataset.action = 'reprint'; folioLink.textContent = mov.folio; folioCell.appendChild(folioLink); tr.insertCell().textContent = formatDate(mov.fechaISO); tr.insertCell().textContent = `${formatDate(mov.fechaCita)} ${mov.horaCita || ''}`.trim(); tr.insertCell().textContent = client ? escapeHTML(client.nombre) : 'Cliente Eliminado'; tr.insertCell().textContent = tipoServicio; tr.insertCell().textContent = Number(mov.monto).toFixed(2); const actionsCell = tr.insertCell(); // Solo mostrar botón de eliminar para administradores if (currentUser && currentUser.role === 'admin') { const deleteButton = document.createElement('button'); deleteButton.className = 'action-btn'; deleteButton.dataset.id = mov.id; deleteButton.dataset.action = 'delete'; deleteButton.textContent = 'Eliminar'; actionsCell.appendChild(deleteButton); } }); } function renderClientsTable(clientList = clients) { if (!tblClientsBody) return; tblClientsBody.innerHTML = ''; clientList.forEach(c => { const tr = tblClientsBody.insertRow(); tr.dataset.id = c.id; if (c.id === currentClientId) { tr.classList.add('selected'); } tr.insertCell().textContent = escapeHTML(c.nombre); tr.insertCell().textContent = escapeHTML(c.telefono || ''); }); } function renderUsersTable() { if (!tblUsersBody) return; tblUsersBody.innerHTML = ''; users.forEach(u => { const tr = tblUsersBody.insertRow(); tr.insertCell().textContent = escapeHTML(u.name); tr.insertCell().textContent = escapeHTML(u.username); tr.insertCell().textContent = u.role === 'admin' ? 'Administrador' : 'Usuario'; const actionsCell = tr.insertCell(); const editButton = document.createElement('button'); editButton.className = 'action-btn'; editButton.dataset.id = u.id; editButton.dataset.action = 'edit-user'; editButton.textContent = 'Editar'; actionsCell.appendChild(editButton); if (u.id !== currentUser.id) { const deleteButton = document.createElement('button'); deleteButton.className = 'action-btn'; deleteButton.dataset.id = u.id; deleteButton.dataset.action = 'delete-user'; deleteButton.textContent = 'Eliminar'; actionsCell.appendChild(deleteButton); } }); } function renderProductTables() { const tblServicesBody = document.getElementById('tblServices')?.querySelector('tbody'); const tblCoursesBody = document.getElementById('tblCourses')?.querySelector('tbody'); if (!tblServicesBody || !tblCoursesBody) return; tblServicesBody.innerHTML = ''; tblCoursesBody.innerHTML = ''; products.forEach(p => { const tableBody = p.type === 'service' ? tblServicesBody : tblCoursesBody; const tr = tableBody.insertRow(); tr.insertCell().textContent = escapeHTML(p.name); tr.insertCell().textContent = Number(p.price || 0).toFixed(2); const actionsCell = tr.insertCell(); const editButton = document.createElement('button'); editButton.className = 'action-btn'; editButton.dataset.id = p.id; editButton.dataset.action = 'edit-product'; editButton.textContent = 'Editar'; actionsCell.appendChild(editButton); const deleteButton = document.createElement('button'); deleteButton.className = 'action-btn'; deleteButton.dataset.id = p.id; deleteButton.dataset.action = 'delete-product'; deleteButton.textContent = 'Eliminar'; actionsCell.appendChild(deleteButton); }); } function updateClientDatalist() { if (!clientDatalist) return; clientDatalist.innerHTML = ''; clients.forEach(c => { const option = document.createElement('option'); option.value = c.nombre; clientDatalist.appendChild(option); }); } function populateArticuloDropdown(category) { const articuloSelect = document.getElementById('m-articulo'); if (!articuloSelect) return; // Clear existing options except the first default option if (category) { let placeholder = ''; if (category === 'service') placeholder = 'servicio'; else if (category === 'course') placeholder = 'curso'; else if (category === 'anticipo') placeholder = 'anticipo'; articuloSelect.innerHTML = ``; if (category === 'anticipo') { // Para anticipos, solo una opción para ingresar monto const option = document.createElement('option'); option.value = 'Anticipo'; option.textContent = 'Anticipo (Monto personalizado)'; articuloSelect.appendChild(option); } else { const items = products.filter(p => p.type === category); items.forEach(i => { const option = document.createElement('option'); option.value = i.name; option.textContent = `${i.name} - $${parseFloat(i.price).toFixed(2)}`; articuloSelect.appendChild(option); }); } } else { articuloSelect.innerHTML = ''; } } // --- MANEJADORES DE EVENTOS --- async function handleSaveSettings(e) { e.preventDefault(); settings.negocio = document.getElementById('s-negocio').value; settings.tagline = document.getElementById('s-tagline').value; settings.calle = document.getElementById('s-calle').value; const coloniaCp = document.getElementById('s-colonia-cp').value.split(','); settings.colonia = coloniaCp[0]?.trim() || ''; settings.cp = coloniaCp[1]?.trim() || ''; settings.rfc = document.getElementById('s-rfc').value; settings.tel = document.getElementById('s-tel').value; settings.leyenda = document.getElementById('s-leyenda').value; settings.folioPrefix = document.getElementById('s-folioPrefix').value; try { const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings }) }); if (response.ok) { alert('Configuración guardada.'); } else { throw new Error('Failed to save settings'); } } catch (error) { console.error('Error saving settings:', error); alert('Error al guardar la configuración'); } } async function handleSaveCredentials(e) { e.preventDefault(); const name = document.getElementById('s-name').value; const username = document.getElementById('s-username').value; const password = document.getElementById('s-password').value; const body = { username, name }; if (password) { body.password = password; } try { const response = await fetch('/api/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (response.ok) { alert('Credenciales actualizadas.'); currentUser.name = name; currentUser.username = username; document.getElementById('s-password').value = ''; } else { const error = await response.json(); alert(`Error: ${error.error}`); } } catch (error) { alert('Error de conexión al guardar credenciales.'); } } async function handleAddOrUpdateUser(e) { e.preventDefault(); const id = document.getElementById('u-id').value; const name = document.getElementById('u-name').value; const username = document.getElementById('u-username').value; const password = document.getElementById('u-password').value; const role = document.getElementById('u-role').value; const isUpdate = !!id; const url = isUpdate ? `/api/users/${id}` : '/api/users'; const method = isUpdate ? 'PUT' : 'POST'; const body = { name, username, role }; if (password || !isUpdate) { if (!password && !isUpdate) { alert('La contraseña es obligatoria para nuevos usuarios.'); return; } body.password = password; } try { const response = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const result = await response.json(); if (response.ok) { alert(`Usuario ${isUpdate ? 'actualizado' : 'creado'} exitosamente.`); if (isUpdate) { const index = users.findIndex(u => u.id === parseInt(id)); if (index > -1) { users[index] = { ...users[index], name, username, role }; } } else { users.push(result); } renderUsersTable(); formAddUser.reset(); document.getElementById('u-id').value = ''; } else { alert(`Error: ${result.error}`); } } catch (error) { alert('Error de conexión al guardar el usuario.'); } } async function deleteUser(id) { if (confirm('¿Estás seguro de que quieres eliminar este usuario?')) { try { const response = await fetch(`/api/users/${id}`, { method: 'DELETE' }); if (response.ok) { users = users.filter(u => u.id !== id); renderUsersTable(); } else { const error = await response.json(); alert(`Error: ${error.error}`); } } catch (error) { alert('Error de conexión al eliminar usuario.'); } } } async function handleAddOrUpdateProduct(e) { e.preventDefault(); const id = document.getElementById('p-id').value; const name = document.getElementById('p-name').value; const type = document.getElementById('p-type').value; const price = document.getElementById('p-price').value; const isUpdate = !!id; const url = isUpdate ? `/api/products/${id}` : '/api/products'; const method = isUpdate ? 'PUT' : 'POST'; const body = { name, type, price }; try { const response = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const result = await response.json(); if (response.ok) { alert(`Producto ${isUpdate ? 'actualizado' : 'creado'} exitosamente.`); if (isUpdate) { const index = products.findIndex(p => p.id === parseInt(id)); if (index > -1) { products[index] = { ...products[index], name, type, price }; } } else { products.push(result); } renderProductTables(); updateUnifiedProductsAfterChange(); // Actualizar tabla unificada formProduct.reset(); document.getElementById('p-id').value = ''; } else { alert(`Error: ${result.error}`); } } catch (error) { alert('Error de conexión al guardar el producto.'); } } async function deleteProduct(id) { if (confirm('¿Estás seguro de que quieres eliminar este producto?')) { try { const response = await fetch(`/api/products/${id}`, { method: 'DELETE' }); if (response.ok) { products = products.filter(p => p.id !== id); renderProductTables(); updateUnifiedProductsAfterChange(); // Actualizar tabla unificada } } catch (error) { alert('Error de conexión al eliminar el producto.'); } } } function showAddCourseModal(clientId) { const courses = products.filter(p => p.type === 'course'); const courseOptions = courses.map(c => ``).join(''); const modalHTML = ` `; document.body.insertAdjacentHTML('beforeend', modalHTML); const modal = document.getElementById('course-modal'); const closeButton = modal.querySelector('.close-button'); const form = modal.querySelector('#formAddCourseToClient'); const closeModal = () => modal.remove(); closeButton.onclick = closeModal; window.onclick = (event) => { if (event.target == modal) { closeModal(); } }; form.onsubmit = async (e) => { e.preventDefault(); const courseData = { course_id: document.getElementById('course-id').value, fecha_curso: document.getElementById('course-date').value, score_general: document.getElementById('course-score').value, completo_presencial: document.getElementById('course-presencial').checked ? 1 : 0, completo_online: document.getElementById('course-online').checked ? 1 : 0, realizo_practicas: document.getElementById('course-practicas').checked ? 1 : 0, obtuvo_certificacion: document.getElementById('course-certificacion').checked ? 1 : 0, }; try { const response = await fetch(`/api/clients/${clientId}/courses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(courseData) }); if (response.ok) { alert('Curso registrado exitosamente.'); closeModal(); showClientRecord(clientId); } else { const error = await response.json(); alert(`Error: ${error.error}`); } } catch (error) { alert('Error de conexión al registrar el curso.'); } }; } 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; 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?`)) { const newClient = { id: crypto.randomUUID(), nombre: clienteNombre, telefono: '', cumpleaños: '', consentimiento: false }; await saveClient(newClient); client = newClient; } else { return; } } // Build concept from selected products const concepto = selectedProducts.map(p => `${p.name} (${p.quantity}x)`).join(', '); const newMovement = { id: crypto.randomUUID(), folio: generateFolio(), fechaISO: new Date().toISOString(), clienteId: client.id, tipo: selectedProducts.length > 0 ? selectedProducts[0].type : 'service', subtipo: '', monto: Number(monto.toFixed(2)), metodo: document.getElementById('m-metodo').value, concepto: concepto, staff: currentUser.name, notas: document.getElementById('m-notas').value, fechaCita: construirFechaCita(), horaCita: document.getElementById('m-hora-cita').value, productos: selectedProducts, // Store product details for ticket descuento: currentDiscount, subtotal: currentSubtotal }; await addMovement(newMovement); renderTicketAndPrint({ ...newMovement, client }, settings); // Reset form and clear products form.reset(); selectedProducts = []; currentSubtotal = 0; currentDiscount = 0; renderSelectedProducts(); calculateTotals(); hideDynamicSections(); document.getElementById('m-cliente').focus(); } function exportClientHistoryCSV(client, history) { const headers = 'Folio,Fecha,Servicio,Monto'; const rows = history.map(mov => { const servicio = mov.subtipo ? `${mov.tipo} (${mov.subtipo})` : mov.tipo; return [ mov.folio, formatDate(mov.fechaISO), `"${servicio}"`, Number(mov.monto).toFixed(2) ].join(','); }); const csvContent = `data:text/csv;charset=utf-8,${headers}\n${rows.join('\n')}`; const encodedUri = encodeURI(csvContent); const link = document.createElement('a'); link.setAttribute('href', encodedUri); link.setAttribute('download', `historial-${client.nombre.replace(/\s+/g, '_')}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); } async function showClientRecord(clientId) { currentClientId = clientId; const client = clients.find(c => c.id === clientId); if (!client) { clearClientRecord(); return; } renderClientsTable(clients.filter(c => c.nombre.toLowerCase().includes(document.getElementById('search-client').value.toLowerCase()))); const clientRecordContent = document.getElementById('client-record-content'); const clientRecordPlaceholder = document.getElementById('client-record-placeholder'); const clientDetails = document.getElementById('client-details'); const clientHistoryTableBody = document.getElementById('client-history-table').querySelector('tbody'); const clientCoursesContainer = document.getElementById('client-courses-history-container'); clientDetails.innerHTML = `

Nombre: ${escapeHTML(client.nombre)}

Teléfono: ${escapeHTML(client.telefono || 'N/A')}

Cumpleaños: ${escapeHTML(formatDate(client.cumpleaños) || 'N/A')}

Género: ${escapeHTML(client.genero || 'N/A')}

Oncológico: ${client.esOncologico ? 'Sí' : 'No'}

`; try { const [historyResponse, coursesResponse] = await Promise.all([ fetch(`/api/clients/${client.id}/history`), fetch(`/api/clients/${client.id}/courses`) ]); const history = await historyResponse.json(); const courses = await coursesResponse.json(); clientHistoryTableBody.innerHTML = ''; if (history.length > 0) { history.forEach(mov => { const tr = clientHistoryTableBody.insertRow(); const servicio = mov.subtipo ? `${escapeHTML(mov.tipo)} (${escapeHTML(mov.subtipo)})` : escapeHTML(mov.tipo); tr.insertCell().textContent = mov.folio; tr.insertCell().textContent = formatDate(mov.fechaISO); tr.insertCell().textContent = servicio; tr.insertCell().textContent = Number(mov.monto).toFixed(2); }); } else { clientHistoryTableBody.innerHTML = 'No hay historial de servicios.'; } clientCoursesContainer.innerHTML = ''; if (courses.length > 0) { const coursesTable = document.createElement('table'); coursesTable.innerHTML = ` Curso Fecha Score Presencial Online Prácticas Certificación ${courses.map(course => ` ${escapeHTML(course.course_name)} ${escapeHTML(formatDate(course.fecha_curso))} ${escapeHTML(course.score_general)} ${course.completo_presencial ? 'Sí' : 'No'} ${course.completo_online ? 'Sí' : 'No'} ${course.realizo_practicas ? 'Sí' : 'No'} ${course.obtuvo_certificacion ? 'Sí' : 'No'} `).join('')} `; clientCoursesContainer.appendChild(coursesTable); } else { clientCoursesContainer.innerHTML = '

No hay cursos registrados para este cliente.

'; } } catch (error) { console.error('Error al cargar el historial del cliente:', error); clientHistoryTableBody.innerHTML = 'Error al cargar historial.'; clientCoursesContainer.innerHTML = '

Error al cargar historial de cursos.

'; } clientRecordContent.classList.remove('hidden'); clientRecordPlaceholder.classList.add('hidden'); } function clearClientRecord() { currentClientId = null; const clientRecordContent = document.getElementById('client-record-content'); const clientRecordPlaceholder = document.getElementById('client-record-placeholder'); clientRecordContent.classList.add('hidden'); clientRecordPlaceholder.classList.remove('hidden'); renderClientsTable(); } function handleTableClick(e) { const target = e.target; const row = target.closest('tr'); if (!row) return; const tableId = row.closest('table')?.id; if (tableId === 'tblClients') { const clientId = row.dataset.id; showClientRecord(clientId); return; } const actionBtn = target.closest('.action-btn'); if (actionBtn) { e.preventDefault(); const id = actionBtn.dataset.id; const action = actionBtn.dataset.action; if (action === 'reprint' || action === 'delete') { const movement = movements.find(m => m.id === id); if (movement) { if (action === 'reprint') { const client = clients.find(c => c.id === movement.clienteId); renderTicketAndPrint({ ...movement, client }, settings); } else if (action === 'delete') { deleteMovement(id); } } } else if (action === 'edit-user') { const user = users.find(u => u.id === parseInt(id)); if (user) { document.getElementById('u-id').value = user.id; document.getElementById('u-name').value = user.name; document.getElementById('u-username').value = user.username; document.getElementById('u-role').value = user.role; document.getElementById('u-password').value = ''; document.getElementById('u-password').placeholder = 'Dejar en blanco para no cambiar'; } } else if (action === 'delete-user') { deleteUser(parseInt(id, 10)); } else if (action === 'edit-product') { const product = products.find(p => p.id === parseInt(id)); if (product) { document.getElementById('p-id').value = product.id; document.getElementById('p-name').value = product.name; document.getElementById('p-type').value = product.type; document.getElementById('p-price').value = product.price; } } else if (action === 'delete-product') { deleteProduct(parseInt(id, 10)); } } } async function handleClientForm(e) { e.preventDefault(); await saveClient(); activateClientSubTab('sub-tab-consult'); } function activateClientSubTab(subTabId) { if (!subTabId) return; document.querySelectorAll('#tab-clients .sub-tab-link').forEach(tab => tab.classList.remove('active')); document.querySelectorAll('#tab-clients .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'); } } function handleClientTabChange(e) { const subTabButton = e.target.closest('.sub-tab-link'); if (!subTabButton) return; e.preventDefault(); const subTabId = subTabButton.dataset.subtab; activateClientSubTab(subTabId); } function activateTab(tabId) { if (!tabId) return; document.querySelectorAll('.tab-link').forEach(tab => tab.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); const tabButton = document.querySelector(`[data-tab="${tabId}"]`); const tabContent = document.getElementById(tabId); if (tabButton) { tabButton.classList.add('active'); } if (tabContent) { tabContent.classList.add('active'); } if (tabId === 'tab-dashboard') { if (!incomeChart) { const ctx = document.getElementById('incomeChart').getContext('2d'); incomeChart = new Chart(ctx, { type: 'pie', data: { labels: [], datasets: [{ label: 'Ingresos por Servicio', data: [], backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40'], }] }, options: { responsive: true, maintainAspectRatio: false, animation: false } }); } if (!paymentMethodChart) { const ctx = document.getElementById('paymentMethodChart').getContext('2d'); paymentMethodChart = new Chart(ctx, { type: 'doughnut', data: { labels: [], datasets: [{ label: 'Ingresos por Método de Pago', data: [], backgroundColor: ['#4BC0C0', '#9966FF', '#FF9F40', '#FF6384', '#36A2EB', '#FFCE56'], }] }, options: { responsive: true, maintainAspectRatio: false, animation: false } }); } loadDashboardData(); } } function handleTabChange(e) { const tabButton = e.target.closest('.tab-link'); if (!tabButton) return; if (tabButton.dataset.tab) { e.preventDefault(); const tabId = tabButton.dataset.tab; activateTab(tabId); } } function handleTestTicket() { const demoMovement = { id: 'demo', folio: 'DEMO-000001', fechaISO: new Date().toISOString(), client: { nombre: 'Cliente de Prueba', esOncologico: true, nombreMedico: 'Dr. Juan Pérez', telefonoMedico: '5512345678', cedulaMedico: '1234567' }, tipo: 'Pago', monto: 123.45, metodo: 'Efectivo', concepto: 'Producto de demostración', staff: 'Admin', notas: 'Esta es una impresión de prueba.' }; renderTicketAndPrint(demoMovement, settings); } function setupUIForRole(role) { console.log('SETUP UI FOR ROLE:', role); const dashboardTab = document.querySelector('[data-tab="tab-dashboard"]'); const settingsTab = document.querySelector('[data-tab="tab-settings"]'); const userManagementSection = document.getElementById('user-management-section'); const staffInput = document.getElementById('m-staff'); const dbInfoIcon = document.getElementById('db-info-icon'); console.log('Dashboard tab found:', !!dashboardTab); console.log('Settings tab found:', !!settingsTab); if (role === 'admin') { if (dashboardTab) dashboardTab.style.display = 'block'; if (settingsTab) settingsTab.style.display = 'block'; if (userManagementSection) userManagementSection.style.display = 'block'; if (dbInfoIcon) dbInfoIcon.style.display = 'inline-block'; fetch('/api/users') .then(res => { if (!res.ok) throw new Error('Failed to fetch users list'); return res.json(); }) .then(data => { users = data; renderUsersTable(); }) .catch(err => console.error(err)); } else { // Usuario regular: NO acceso a Dashboard y Configuración console.log('CONFIGURANDO PARA USER REGULAR - OCULTANDO TABS'); if (dashboardTab) { dashboardTab.style.display = 'none'; console.log('Dashboard tab oculto'); } if (settingsTab) { settingsTab.style.display = 'none'; console.log('Settings tab oculto'); } if (userManagementSection) userManagementSection.style.display = 'none'; if (dbInfoIcon) dbInfoIcon.style.display = 'none'; } if (staffInput) { staffInput.disabled = true; } } function populateFooter() { // Footer elements removed - no longer needed } // Make removeProduct globally accessible window.removeProduct = removeProduct; // --- INICIALIZACIÓN --- async function initializeApp() { let userResponse; try { userResponse = await fetch('/api/user'); if (!userResponse.ok) { window.location.href = '/login.html'; return; } const contentType = userResponse.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { console.error('La respuesta del servidor no es JSON. Redirigiendo al login.'); window.location.href = '/login.html'; return; } currentUser = await userResponse.json(); } catch (error) { console.error('Error de conexión al verificar la autenticación. Redirigiendo al login.', error); window.location.href = '/login.html'; return; } const tabs = document.querySelector('.tabs'); const btnLogout = document.getElementById('btnLogout'); const btnCancelEditUser = document.getElementById('btnCancelEditUser'); const tipoServicioSelect = document.getElementById('m-tipo'); const clientSubTabs = document.querySelector('#tab-clients .sub-tabs'); const dbInfoIcon = document.getElementById('db-info-icon'); formSettings?.addEventListener('submit', handleSaveSettings); formCredentials?.addEventListener('submit', handleSaveCredentials); formMove?.addEventListener('submit', handleNewMovement); tblMovesBody?.addEventListener('click', handleTableClick); tblClientsBody?.addEventListener('click', handleTableClick); tblServicesBody?.addEventListener('click', handleTableClick); tblCoursesBody?.addEventListener('click', handleTableClick); appointmentsList?.addEventListener('click', handleTableClick); btnExport?.addEventListener('click', exportCSV); btnTestTicket?.addEventListener('click', handleTestTicket); formClient?.addEventListener('submit', handleClientForm); formProduct?.addEventListener('submit', handleAddOrUpdateProduct); tabs?.addEventListener('click', handleTabChange); clientSubTabs?.addEventListener('click', handleClientTabChange); if (currentUser.role === 'admin') { formAddUser?.addEventListener('submit', handleAddOrUpdateUser); tblUsersBody?.addEventListener('click', handleTableClick); dbInfoIcon?.addEventListener('click', () => { document.getElementById('db-instructions').classList.toggle('hidden'); }); } btnLogout?.addEventListener('click', async () => { await fetch('/api/logout', { method: 'POST' }); window.location.href = '/login.html'; }); document.getElementById('btnCancelEditClient')?.addEventListener('click', () => { formClient.reset(); document.getElementById('c-id').value = ''; document.getElementById('oncologico-fields').classList.add('hidden'); }); document.getElementById('c-pacienteOncologico')?.addEventListener('change', (e) => { const oncologicoFields = document.getElementById('oncologico-fields'); if (oncologicoFields) { oncologicoFields.classList.toggle('hidden', !e.target.checked); oncologicoFields.classList.toggle('active', e.target.checked); } }); btnCancelEditUser?.addEventListener('click', (e) => { e.preventDefault(); formAddUser.reset(); document.getElementById('u-id').value = ''; document.getElementById('u-password').placeholder = 'Contraseña'; }); const searchClientInput = document.getElementById('search-client'); searchClientInput?.addEventListener('input', (e) => { const searchTerm = e.target.value.toLowerCase(); const filteredClients = clients.filter(c => c.nombre.toLowerCase().includes(searchTerm) || c.telefono?.toLowerCase().includes(searchTerm) ); renderClientsTable(filteredClients); }); const categoriaSelect = document.getElementById('m-categoria'); categoriaSelect?.addEventListener('change', (e) => { populateArticuloDropdown(e.target.value); }); tipoServicioSelect?.addEventListener('change', (e) => { const subtipoContainer = document.getElementById('m-subtipo-container'); const servicesWithSubtype = ['Microblading', 'Lashes', 'Nail Art', 'Lash Lifting']; subtipoContainer.classList.toggle('hidden', !servicesWithSubtype.includes(e.target.value)); }); document.getElementById('btn-edit-client')?.addEventListener('click', () => { if (!currentClientId) return; const client = clients.find(c => c.id === currentClientId); if (client) { document.getElementById('c-id').value = client.id; document.getElementById('c-nombre').value = client.nombre; document.getElementById('c-telefono').value = client.telefono || ''; document.getElementById('c-genero').value = client.genero || ''; document.getElementById('c-cumple').value = client.cumpleaños; document.getElementById('c-consent').checked = client.consentimiento; const esOncologicoCheckbox = document.getElementById('c-pacienteOncologico'); const oncologicoFields = document.getElementById('oncologico-fields'); if (esOncologicoCheckbox) { esOncologicoCheckbox.checked = client.esOncologico; } if (oncologicoFields) { oncologicoFields.classList.toggle('hidden', !client.esOncologico); oncologicoFields.classList.toggle('active', client.esOncologico); } document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba; document.getElementById('c-nombreMedico').value = client.nombreMedico || ''; document.getElementById('c-telefonoMedico').value = client.telefonoMedico || ''; document.getElementById('c-cedulaMedico').value = client.cedulaMedico || ''; document.getElementById('c-pruebaAprobacion').checked = client.pruebaAprobacion; activateClientSubTab('sub-tab-register'); } }); document.getElementById('btn-delete-client')?.addEventListener('click', () => { if (!currentClientId) return; deleteClient(currentClientId); }); document.getElementById('btnAddCourseToClient')?.addEventListener('click', () => { const clientId = document.getElementById('c-id').value; if (!clientId) { alert('Por favor, primero guarda el cliente antes de añadir un curso.'); return; } showAddCourseModal(clientId); }); // Event listener para el date picker de citas const fechaCitaPicker = document.getElementById('m-fecha-cita'); if (fechaCitaPicker) { fechaCitaPicker.addEventListener('change', function() { updateAvailableTimeSlots(this.value); }); } Promise.all([ fetch('/api/settings').then(res => res.json()).catch(() => DEFAULT_SETTINGS), fetch('/api/movements').then(res => res.json()).catch(() => []), fetch('/api/clients').then(res => res.json()).catch(() => []), fetch('/api/products').then(res => res.json()).catch(() => []), ]).then(values => { console.log('Initial data loaded:', values); [settings, movements, clients, products] = values; console.log('Rendering settings...'); renderSettings(); console.log('Rendering movements table...'); renderTable(); console.log('Rendering clients table...'); renderClientsTable(); console.log('Rendering products table...'); renderProductTables(); console.log('Updating client datalist...'); updateClientDatalist(); populateArticuloDropdown(''); 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 || ''; } console.log('Setting up UI for role...'); setupUIForRole(currentUser.role); console.log('Activating initial tab...'); // Usuario regular va a ventas, admin va a dashboard const initialTab = currentUser.role === 'admin' ? 'tab-dashboard' : 'tab-movements'; activateTab(initialTab); console.log('Activating client sub-tab...'); activateClientSubTab('sub-tab-register'); console.log('Clearing client record...'); clearClientRecord(); console.log('Populating footer...'); populateFooter(); console.log('Initializing dynamic system...'); initializeDynamicSystem(); console.log('Initializing unified products table...'); initializeUnifiedTable(); console.log('Initialization complete.'); }).catch(error => { console.error('CRITICAL: Failed to load initial data.', error); alert('Error Crítico: No se pudieron cargar los datos del servidor.'); }); } // --- NUEVA IMPLEMENTACIÓN: TABLA UNIFICADA DE PRODUCTOS --- // Estado global para la tabla unificada let allProductsData = []; let currentSortField = 'descripcion'; let currentSortDirection = 'asc'; // Generar folio único para productos function generateProductFolio(type) { const folioPrefix = settings.folioPrefix || 'PRD'; const timestamp = Date.now().toString().slice(-6); const typeCode = { 'service': 'SRV', 'course': 'CRS' }; return `${folioPrefix}-${typeCode[type]}-${timestamp}`; } // Los anticipos ya no son productos - se manejan solo en ventas // Los anticipos usan prompts nativos mejorados con emojis // Renderizar tabla unificada function renderUnifiedProductsTable() { const tableBody = document.querySelector('#tblAllProducts tbody'); if (!tableBody) return; // Limpiar tabla tableBody.innerHTML = ''; // Aplicar filtros let filteredData = [...allProductsData]; // Filtro por descripción const filterDescription = document.getElementById('filter-description')?.value?.toLowerCase(); if (filterDescription) { filteredData = filteredData.filter(item => item.descripcion.toLowerCase().includes(filterDescription) ); } // Filtro por tipo de producto (servicios y cursos únicamente) const filterCategory = document.getElementById('filter-category')?.value; if (filterCategory) { filteredData = filteredData.filter(item => item.categoria === filterCategory); } // Filtro por rango de fechas const filterDateFrom = document.getElementById('filter-date-from')?.value; const filterDateTo = document.getElementById('filter-date-to')?.value; if (filterDateFrom || filterDateTo) { filteredData = filteredData.filter(item => { if (!item.fecha || item.fecha === 'N/A') return true; // Convertir fecha del item a formato comparable const itemDate = new Date(item.fecha.split('/').reverse().join('-')); if (filterDateFrom) { const fromDate = new Date(filterDateFrom); if (itemDate < fromDate) return false; } if (filterDateTo) { const toDate = new Date(filterDateTo); if (itemDate > toDate) return false; } return true; }); } // Filtro por rango de precios const filterPriceMin = document.getElementById('filter-price-min')?.value; const filterPriceMax = document.getElementById('filter-price-max')?.value; if (filterPriceMin || filterPriceMax) { filteredData = filteredData.filter(item => { const price = parseFloat(item.precio) || 0; if (filterPriceMin && price < parseFloat(filterPriceMin)) return false; if (filterPriceMax && price > parseFloat(filterPriceMax)) return false; return true; }); } // Ordenar datos filteredData.sort((a, b) => { let aValue = a[currentSortField] || ''; let bValue = b[currentSortField] || ''; if (typeof aValue === 'string') { aValue = aValue.toLowerCase(); bValue = bValue.toLowerCase(); } if (currentSortDirection === 'asc') { return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; } else { return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; } }); // Renderizar filas filteredData.forEach(item => { const row = document.createElement('tr'); const statusClass = item.status === 'cancelled' ? 'status-cancelled' : 'status-active'; const categoryClass = `category-${item.categoria}`; row.innerHTML = ` ${item.folio} ${item.fecha} ${item.cita || 'N/A'} ${escapeHTML(item.descripcion)} ${getCategoryName(item.categoria)} $${parseFloat(item.precio || 0).toFixed(2)}
`; tableBody.appendChild(row); }); } // Obtener nombre amigable de categoría function getCategoryName(categoria) { const names = { 'service': 'Servicio', 'course': 'Curso' }; return names[categoria] || categoria; } // Cargar datos unificados function loadUnifiedProductsData() { allProductsData = []; // Agregar solo productos existentes (servicios y cursos) // Los anticipos NO se incluyen aquí - solo se manejan en ventas/notas products.forEach(product => { allProductsData.push({ id: product.id, folio: product.folio || generateProductFolio(product.type), fecha: product.created_at ? new Date(product.created_at).toLocaleDateString('es-ES') : new Date().toLocaleDateString('es-ES'), cita: '', // Los servicios y cursos no tienen cita predefinida descripcion: product.name, categoria: product.type, precio: product.price || 0, status: product.status || 'active' }); }); } // Ordenar tabla function sortTable(field) { if (currentSortField === field) { currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; } else { currentSortField = field; currentSortDirection = 'asc'; } // Actualizar iconos de ordenamiento document.querySelectorAll('.sort-icon').forEach(icon => { icon.textContent = '↕'; }); const currentIcon = document.querySelector(`th[data-field="${field}"] .sort-icon`); if (currentIcon) { currentIcon.textContent = currentSortDirection === 'asc' ? '↑' : '↓'; } renderUnifiedProductsTable(); } // Inicializar filter modal function initializeFilterModal() { const filterToggleBtn = document.getElementById('filter-toggle-btn'); const filterModal = document.getElementById('filter-modal'); const filterModalClose = document.getElementById('filter-modal-close'); if (filterToggleBtn && filterModal) { filterToggleBtn.addEventListener('click', () => { filterModal.style.display = 'flex'; }); } if (filterModalClose && filterModal) { filterModalClose.addEventListener('click', () => { filterModal.style.display = 'none'; }); } // Cerrar modal al hacer click fuera de él if (filterModal) { filterModal.addEventListener('click', (e) => { if (e.target === filterModal) { filterModal.style.display = 'none'; } }); } // Aplicar filtros desde el modal const filterInputs = filterModal?.querySelectorAll('input, select'); if (filterInputs) { filterInputs.forEach(input => { input.addEventListener('change', applyFiltersFromModal); input.addEventListener('input', applyFiltersFromModal); }); } } // Aplicar filtros desde el modal function applyFiltersFromModal() { renderUnifiedProductsTable(); } // Editar producto unificado function editUnifiedProduct(id) { // Solo editar productos reales (servicios y cursos) const product = products.find(p => p.id == id); if (product) { document.getElementById('p-id').value = product.id; document.getElementById('p-name').value = product.name; document.getElementById('p-type').value = product.type; document.getElementById('p-price').value = product.price || ''; // Ya no hay campos de anticipo que mostrar } } // Cambiar estado del producto async function toggleProductStatus(id) { const product = products.find(p => p.id == id); if (!product) return; const newStatus = product.status === 'cancelled' ? 'active' : 'cancelled'; const actionText = newStatus === 'cancelled' ? 'cancelar' : 'reactivar'; if (confirm(`¿Estás seguro de que quieres ${actionText} este producto?`)) { try { const response = await fetch(`/api/products/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...product, status: newStatus }) }); if (response.ok) { product.status = newStatus; loadUnifiedProductsData(); renderUnifiedProductsTable(); } } catch (error) { alert('Error de conexión al actualizar el producto.'); } } } // Eliminar producto unificado async function deleteUnifiedProduct(id) { if (confirm('¿Estás seguro de que quieres eliminar este producto permanentemente?')) { try { const response = await fetch(`/api/products/${id}`, { method: 'DELETE' }); if (response.ok) { products = products.filter(p => p.id != id); loadUnifiedProductsData(); renderUnifiedProductsTable(); renderProductTables(); // Actualizar también las tablas originales } } catch (error) { alert('Error de conexión al eliminar el producto.'); } } } // Los campos de anticipo fueron removidos - ya no son productos // Función para actualizar tabla unificada después de cambios function updateUnifiedProductsAfterChange() { loadUnifiedProductsData(); renderUnifiedProductsTable(); } // Inicializar controles de la tabla unificada function initializeUnifiedTable() { // Inicializar filter modal initializeFilterModal(); // Inicializar sorting para columnas document.querySelectorAll('.sortable').forEach(header => { header.addEventListener('click', () => { const field = header.getAttribute('data-field'); if (field) { sortTable(field); } }); }); // Verificar que los elementos existan antes de agregar listeners const productTypeSelect = document.getElementById('p-type'); // Ya no se necesita manejar campos de anticipo en productos // Solo cargar si hay datos disponibles if (typeof products !== 'undefined' && typeof movements !== 'undefined') { loadUnifiedProductsData(); renderUnifiedProductsTable(); } } // Exponer funciones globalmente para uso en onclick window.sortTable = sortTable; window.initializeFilterModal = initializeFilterModal; window.editUnifiedProduct = editUnifiedProduct; window.toggleProductStatus = toggleProductStatus; window.deleteUnifiedProduct = deleteUnifiedProduct; function handleAnticipoSelection() { // 1. Primero pedir el monto del anticipo let anticipoAmount = prompt('💰 ANTICIPO\n\nIngresa el monto del anticipo:', ''); if (anticipoAmount === null) return; // Usuario canceló anticipoAmount = parseFloat(anticipoAmount); if (isNaN(anticipoAmount) || anticipoAmount <= 0) { alert('⚠️ Por favor ingresa un monto válido para el anticipo'); return; } // 2. Preguntar tipo con confirm para hacer más fácil la selección const esServicio = confirm('🎯 TIPO DE ANTICIPO\n\n¿Es para un SERVICIO?\n\n✅ Aceptar = Servicio\n❌ Cancelar = Curso'); const productType = esServicio ? 'service' : 'course'; const tipoTexto = esServicio ? 'servicio' : 'curso'; // 3. Obtener productos según el tipo const availableProducts = products.filter(p => p.type === productType); if (availableProducts.length === 0) { alert(`❌ No hay ${tipoTexto}s disponibles`); return; } // 4. Crear lista numerada para selección (solo nombres, sin precios) let productOptions = `🛍️ SELECCIONAR ${tipoTexto.toUpperCase()}\n\n`; availableProducts.forEach((product, index) => { productOptions += `${index + 1}. ${product.name}\n`; }); const selectedIndex = prompt(productOptions + '\n📝 Escribe el número de tu elección:'); if (selectedIndex === null) return; // Usuario canceló const productIndex = parseInt(selectedIndex) - 1; if (isNaN(productIndex) || productIndex < 0 || productIndex >= availableProducts.length) { alert('❌ Selección inválida. Intenta de nuevo.'); return; } const selectedProduct = availableProducts[productIndex]; const typeLabel = productType === 'course' ? 'Curso' : 'Servicio'; // 5. Crear nombre del anticipo para el ticket (tipo completo en paréntesis) const anticipoName = `Anticipo ${selectedProduct.name} $${parseFloat(anticipoAmount).toFixed(2)} (${typeLabel})`; // 6. Agregar a productos seleccionados selectedProducts.push({ id: 'anticipo-' + Date.now(), name: anticipoName, price: anticipoAmount, quantity: 1, // Los anticipos siempre son cantidad 1 type: 'anticipo', productName: selectedProduct.name, productType: productType // No incluir originalPrice para evitar confusión en dashboard }); renderSelectedProducts(); calculateTotals(); showDynamicSections(); // Mensaje de confirmación alert(`✅ ANTICIPO AGREGADO\n\n${anticipoName}`); } document.addEventListener('DOMContentLoaded', initializeApp);