diff --git a/README.md b/README.md index 52bfe82..8e2e9cb 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,66 @@ Este es un sistema de Punto de Venta (POS) simple y eficiente, diseñado para ge ## Características Principales -- **Dashboard (Solo Admin):** Visualización rápida de estadísticas clave como ingresos totales, número de servicios y gráficos de rendimiento. -- **Gestión de Ventas:** Creación de nuevos movimientos (ventas), generación de recibos para impresión y exportación de historial de ventas a formato CSV. -- **Gestión de Clientes:** Registro y consulta de clientes, con la posibilidad de ver su expediente completo, incluyendo historial de servicios y cursos. -- **Gestión de Productos:** Permite añadir, editar y eliminar tanto servicios como cursos ofrecidos por el negocio. -- **Configuración (Solo Admin):** - - Ajuste de los datos del negocio para los recibos. - - Gestión de credenciales de usuario. - - Administración de múltiples usuarios (crear, editar, eliminar). +- **Dashboard:** Visualización rápida de estadísticas clave como ingresos totales, número de servicios y gráficos de rendimiento. +- **Gestión de Ventas Avanzada:** + - **Múltiples productos por venta**: Agregue varios servicios/cursos en una sola transacción + - **Sistema de descuentos**: Descuentos por porcentaje o monto fijo con motivo + - **Cálculo automático de totales**: Subtotal, descuento y total final en tiempo real + - **Programación de citas**: Fecha y hora integradas en el flujo de ventas + - **Generación de tickets**: Recibos optimizados para impresión térmica de 58mm + - **Exportación a CSV**: Historial completo de ventas exportable +- **Gestión de Clientes:** Registro y consulta de clientes, con expediente completo incluyendo historial de servicios y cursos. +- **Gestión de Productos:** Administración completa de servicios y cursos con precios actualizables. +- **Configuración:** + - Ajuste de los datos del negocio para los recibos + - Gestión de credenciales de usuario + - Administración de múltiples usuarios (crear, editar, eliminar) - **Autenticación:** Sistema de inicio de sesión seguro para proteger el acceso a la información. -- **Roles de Usuario:** Perfiles de Administrador (acceso total) y Usuario (acceso limitado a ventas y clientes). +- **Roles de Usuario:** Perfiles de Administrador (acceso total) y Usuario (acceso limitado). -## Despliegue con Docker +## Instalación y Despliegue + +### Opción 1: Instalación Local (Desarrollo) + +#### Prerrequisitos +- Node.js v18 o superior +- npm o yarn + +#### Pasos +1. **Clonar el repositorio**: + ```bash + git clone + cd ap_pos + ``` + +2. **Instalar dependencias**: + ```bash + npm install + ``` + +3. **Ejecutar la aplicación**: + ```bash + npm start + ``` + +4. **Acceder a la aplicación**: + - URL: `http://localhost:3111` + - En la primera ejecución serás redirigido a `/setup.html` para crear el usuario administrador + +#### Base de datos +- Se crea automáticamente un archivo SQLite (`ap-pos.db`) en el directorio raíz +- Los datos se mantienen localmente en este archivo + +### Opción 2: Despliegue con Docker El sistema está diseñado para ser desplegado fácilmente utilizando Docker y Docker Compose, asegurando un entorno consistente y aislado. -### Prerrequisitos +#### Prerrequisitos - Tener instalado [Docker](https://docs.docker.com/get-docker/) - Tener instalado [Docker Compose](https://docs.docker.com/compose/install/) -### Pasos para el despliegue +#### Pasos para el despliegue 1. **Clona o descarga** este repositorio en tu máquina local. @@ -49,8 +88,38 @@ El sistema está diseñado para ser desplegado fácilmente utilizando Docker y D - URL: `http://localhost:3111` - En la primera ejecución serás redirigido a `/setup.html` para crear el usuario administrador -### Persistencia de datos +#### Persistencia de datos - La base de datos SQLite se almacena en un volumen Docker persistente - 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 + +### 🚀 **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 + +### 💰 **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 + +### 📅 **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 + +### 🧾 **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 de fecha y hora preciso + +### ⚡ **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 diff --git a/app.js b/app.js index 5542217..5f60890 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,5 @@ import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js'; -import { renderTicketAndPrint } from './print.js'; +import { renderTicketAndPrint } from './print.js?v=1.8'; // --- UTILITIES --- function escapeHTML(str) { @@ -14,6 +14,448 @@ function escapeHTML(str) { .replace(/'/g, '''); } +function construirFechaCita() { + const dia = document.getElementById('m-cita-dia').value; + const mes = document.getElementById('m-cita-mes').value; + const año = document.getElementById('m-cita-año').value; + + if (!dia || !mes || !año) { + return ''; + } + + // Formatear con ceros a la izquierda + const diaStr = dia.padStart(2, '0'); + const mesStr = mes.padStart(2, '0'); + + // Retornar en formato YYYY-MM-DD para compatibilidad + return `${año}-${mesStr}-${diaStr}`; +} + +// 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') { + let anticipoAmount = prompt('Ingresa 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; + } + + const clienteInput = document.getElementById('m-cliente'); + const clienteName = clienteInput.value.trim(); + let anticipoName = 'Anticipo'; + if (clienteName) { + anticipoName = `Anticipo - ${clienteName}`; + } + + const existingIndex = selectedProducts.findIndex(p => p.name === anticipoName); + + if (existingIndex >= 0) { + selectedProducts[existingIndex].quantity += quantity; + selectedProducts[existingIndex].price += anticipoAmount; // Acumular el monto + } else { + selectedProducts.push({ + id: 'anticipo-' + Date.now(), + name: anticipoName, + price: anticipoAmount, + quantity: quantity, + type: 'anticipo' + }); + } + } 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 => ` +
+ ${escapeHTML(product.name)} (${product.type === 'service' ? 'Servicio' : 'Curso'}) + ${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); @@ -73,7 +515,7 @@ let isDashboardLoading = false; // --- LÓGICA DE NEGOCIO --- async function loadDashboardData() { - if (currentUser.role !== 'admin' || isDashboardLoading) { + if (isDashboardLoading) { return; } isDashboardLoading = true; @@ -144,16 +586,38 @@ function generateFolio() { } async function addMovement(mov) { - await save('movements', { movement: mov }); - movements.unshift(mov); - renderTable(); + 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?')) { - await remove(KEY_DATA, id); - movements = movements.filter(m => m.id !== id); - renderTable(); + 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'); + } } } @@ -174,7 +638,7 @@ async function saveClient(clientData) { genero: document.getElementById('c-genero').value, cumpleaños: document.getElementById('c-cumple').value, consentimiento: document.getElementById('c-consent').checked, - esOncologico: document.getElementById('c-esOncologico').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, @@ -183,7 +647,20 @@ async function saveClient(clientData) { }; } - await save('clients', { client: clientToSave }); + 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); @@ -204,11 +681,20 @@ async function saveClient(clientData) { async function deleteClient(id) { if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) { - await remove(KEY_CLIENTS, id); - clients = clients.filter(c => c.id !== id); - renderClientsTable(); - updateClientDatalist(); - clearClientRecord(); + 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'); + } } } @@ -367,14 +853,34 @@ function updateClientDatalist() { function populateArticuloDropdown(category) { const articuloSelect = document.getElementById('m-articulo'); if (!articuloSelect) return; - articuloSelect.innerHTML = ''; - const items = products.filter(p => p.type === category); - items.forEach(i => { - const option = document.createElement('option'); - option.value = i.name; - option.textContent = i.name; - articuloSelect.appendChild(option); - }); + + // 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, permitir búsqueda automática o ingreso manual + const option = document.createElement('option'); + option.value = 'Anticipo'; + option.textContent = 'Anticipo - $0.00 (Ingreso manual)'; + 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 --- @@ -393,8 +899,22 @@ async function handleSaveSettings(e) { settings.tel = document.getElementById('s-tel').value; settings.leyenda = document.getElementById('s-leyenda').value; settings.folioPrefix = document.getElementById('s-folioPrefix').value; - await save(KEY_SETTINGS, { settings }); - alert('Configuración guardada.'); + + 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) { @@ -651,6 +1171,11 @@ async function handleNewMovement(e) { 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?`)) { @@ -668,27 +1193,41 @@ async function handleNewMovement(e) { } } + // 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: document.getElementById('m-categoria').value, + tipo: selectedProducts.length > 0 ? selectedProducts[0].type : 'service', subtipo: '', monto: Number(monto.toFixed(2)), metodo: document.getElementById('m-metodo').value, - concepto: document.getElementById('m-articulo').value, + concepto: concepto, staff: currentUser.name, notas: document.getElementById('m-notas').value, - fechaCita: document.getElementById('m-fecha-cita').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(); - subtipoContainer.classList.add('hidden'); } function exportClientHistoryCSV(client, history) { @@ -915,7 +1454,7 @@ function activateTab(tabId) { tabContent.classList.add('active'); } - if (tabId === 'tab-dashboard' && currentUser.role === 'admin') { + if (tabId === 'tab-dashboard') { if (!incomeChart) { const ctx = document.getElementById('incomeChart').getContext('2d'); incomeChart = new Chart(ctx, { @@ -1015,8 +1554,8 @@ function setupUIForRole(role) { }) .catch(err => console.error(err)); } else { - if (dashboardTab) dashboardTab.style.display = 'none'; - if (settingsTab) settingsTab.style.display = 'none'; + if (dashboardTab) dashboardTab.style.display = 'block'; + if (settingsTab) settingsTab.style.display = 'block'; if (userManagementSection) userManagementSection.style.display = 'none'; if (dbInfoIcon) dbInfoIcon.style.display = 'none'; } @@ -1027,18 +1566,13 @@ function setupUIForRole(role) { } function populateFooter() { - const dateElement = document.getElementById('footer-date'); - const versionElement = document.getElementById('footer-version'); - - if (dateElement) { - dateElement.textContent = formatDate(new Date().toISOString()); - } - if (versionElement) { - versionElement.textContent = `Versión ${APP_VERSION}`; - } + // Footer elements removed - no longer needed } +// Make removeProduct globally accessible +window.removeProduct = removeProduct; + // --- INICIALIZACIÓN --- async function initializeApp() { @@ -1106,9 +1640,12 @@ async function initializeApp() { document.getElementById('oncologico-fields').classList.add('hidden'); }); - document.getElementById('c-esOncologico')?.addEventListener('change', (e) => { + document.getElementById('c-pacienteOncologico')?.addEventListener('change', (e) => { const oncologicoFields = document.getElementById('oncologico-fields'); - oncologicoFields.classList.toggle('hidden', !e.target.checked); + if (oncologicoFields) { + oncologicoFields.classList.toggle('hidden', !e.target.checked); + oncologicoFields.classList.toggle('active', e.target.checked); + } }); btnCancelEditUser?.addEventListener('click', (e) => { @@ -1150,10 +1687,15 @@ async function initializeApp() { document.getElementById('c-cumple').value = client.cumpleaños; document.getElementById('c-consent').checked = client.consentimiento; - const esOncologicoCheckbox = document.getElementById('c-esOncologico'); + const esOncologicoCheckbox = document.getElementById('c-pacienteOncologico'); const oncologicoFields = document.getElementById('oncologico-fields'); - esOncologicoCheckbox.checked = client.esOncologico; - oncologicoFields.classList.toggle('hidden', !client.esOncologico); + 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 || ''; @@ -1180,10 +1722,10 @@ async function initializeApp() { }); Promise.all([ - load(KEY_SETTINGS, DEFAULT_SETTINGS), - load(KEY_DATA, []), - load(KEY_CLIENTS, []), - fetch('/api/products').then(res => res.json()), + 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; @@ -1198,7 +1740,7 @@ async function initializeApp() { renderProductTables(); console.log('Updating client datalist...'); updateClientDatalist(); - populateArticuloDropdown(document.getElementById('m-categoria').value); + populateArticuloDropdown(''); if (currentUser) { console.log('Setting user info in form...'); @@ -1211,11 +1753,7 @@ async function initializeApp() { setupUIForRole(currentUser.role); console.log('Activating initial tab...'); - if (currentUser.role === 'admin') { - activateTab('tab-dashboard'); - } else { - activateTab('tab-movements'); - } + activateTab('tab-dashboard'); console.log('Activating client sub-tab...'); activateClientSubTab('sub-tab-register'); @@ -1223,6 +1761,8 @@ async function initializeApp() { clearClientRecord(); console.log('Populating footer...'); populateFooter(); + console.log('Initializing dynamic system...'); + initializeDynamicSystem(); console.log('Initialization complete.'); }).catch(error => { diff --git a/docker-compose.yml b/docker-compose.yml index ee3b2f0..4914a18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: ap-pos: - image: marcogll/ap_pos:latest + image: coderk/ap_pos:1.3.5 container_name: ap-pos restart: unless-stopped ports: @@ -10,7 +10,7 @@ services: SESSION_SECRET: ${SESSION_SECRET:-your-very-secret-key-change-it-in-production} DB_PATH: /app/data/ap-pos.db volumes: - - ap_pos_data:/app/data + - ./data:/app/data healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3111/login.html"] interval: 30s @@ -18,10 +18,4 @@ services: retries: 3 start_period: 40s -volumes: - ap_pos_data: - driver: local - driver_opts: - type: none - o: bind - device: ./data +# volumes section no longer needed - using direct bind mount diff --git a/index.html b/index.html index d347eb4..d86455c 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,7 @@ - + @@ -79,49 +79,176 @@

Nuevo Movimiento

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

Venta

+
+
+ + + + +
+
+
+
+ + +
+
+ + +
+ +
+ + + + + +
+
+ +
- - - - - - - - - - +
+ + +
-
- - + +
+ + +
+ + +
+
+ Subtotal: + $0.00 +
+ +
+ Total: + $0.00 +
+
+ + + + + + +
+ +
@@ -184,14 +311,14 @@
- - + +