feat: Release version 1.3.5 with coderk Docker image preparation

- Updated README.md version references from 1.8 to 1.3.5
- Changed Docker image from marcogll/ap_pos:latest to coderk/ap_pos:1.3.5
- Prepared Docker configuration for coderk deployment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marco Gallegos
2025-09-02 14:30:10 -06:00
parent 1e92398891
commit 541d2f8883
7 changed files with 1431 additions and 181 deletions

View File

@@ -4,27 +4,66 @@ Este es un sistema de Punto de Venta (POS) simple y eficiente, diseñado para ge
## Características Principales ## 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. - **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:** 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 Ventas Avanzada:**
- **Gestión de Clientes:** Registro y consulta de clientes, con la posibilidad de ver su expediente completo, incluyendo historial de servicios y cursos. - **Múltiples productos por venta**: Agregue varios servicios/cursos en una sola transacción
- **Gestión de Productos:** Permite añadir, editar y eliminar tanto servicios como cursos ofrecidos por el negocio. - **Sistema de descuentos**: Descuentos por porcentaje o monto fijo con motivo
- **Configuración (Solo Admin):** - **Cálculo automático de totales**: Subtotal, descuento y total final en tiempo real
- Ajuste de los datos del negocio para los recibos. - **Programación de citas**: Fecha y hora integradas en el flujo de ventas
- Gestión de credenciales de usuario. - **Generación de tickets**: Recibos optimizados para impresión térmica de 58mm
- Administración de múltiples usuarios (crear, editar, eliminar). - **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. - **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 <url-del-repositorio>
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. 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](https://docs.docker.com/get-docker/)
- Tener instalado [Docker Compose](https://docs.docker.com/compose/install/) - 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. 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` - URL: `http://localhost:3111`
- En la primera ejecución serás redirigido a `/setup.html` para crear el usuario administrador - 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 - La base de datos SQLite se almacena en un volumen Docker persistente
- Los datos se mantienen entre reinicios y actualizaciones del contenedor - Los datos se mantienen entre reinicios y actualizaciones del contenedor
- Para más información sobre Docker, consulta [DOCKER.md](./DOCKER.md) - 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

614
app.js
View File

@@ -1,5 +1,5 @@
import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js'; 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 --- // --- UTILITIES ---
function escapeHTML(str) { function escapeHTML(str) {
@@ -14,6 +14,448 @@ function escapeHTML(str) {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
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 = '<p style="color: #6c757d; font-style: italic;">No hay productos seleccionados</p>';
return;
}
const html = selectedProducts.map(product => `
<div class="product-item">
<span class="product-item-name">${escapeHTML(product.name)} <small>(${product.type === 'service' ? 'Servicio' : 'Curso'})</small></span>
<span class="product-item-quantity">${product.quantity}x</span>
<span class="product-item-price">$${(product.price * product.quantity).toFixed(2)}</span>
<button type="button" class="btn-remove" onclick="removeProduct('${escapeHTML(product.name)}')">×</button>
</div>
`).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 = `
<div class="anticipo-info">
<div class="anticipo-monto">$${parseFloat(anticipo.monto).toFixed(2)}</div>
<div class="anticipo-fecha">Fecha: ${new Date(anticipo.fecha).toLocaleDateString()}</div>
<div class="anticipo-folio">Folio: ${anticipo.folio}</div>
</div>
<div class="anticipo-actions">
<button class="btn-aplicar-anticipo" onclick="aplicarAnticipo('${anticipo.id}', ${anticipo.monto})">
Aplicar
</button>
</div>
`;
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) { function formatDate(dateString) {
if (!dateString) return ''; if (!dateString) return '';
const date = new Date(dateString); const date = new Date(dateString);
@@ -73,7 +515,7 @@ let isDashboardLoading = false;
// --- LÓGICA DE NEGOCIO --- // --- LÓGICA DE NEGOCIO ---
async function loadDashboardData() { async function loadDashboardData() {
if (currentUser.role !== 'admin' || isDashboardLoading) { if (isDashboardLoading) {
return; return;
} }
isDashboardLoading = true; isDashboardLoading = true;
@@ -144,16 +586,38 @@ function generateFolio() {
} }
async function addMovement(mov) { async function addMovement(mov) {
await save('movements', { movement: 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); movements.unshift(mov);
renderTable(); 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) { async function deleteMovement(id) {
if (confirm('¿Estás seguro de que quieres eliminar este movimiento?')) { if (confirm('¿Estás seguro de que quieres eliminar este movimiento?')) {
await remove(KEY_DATA, id); try {
const response = await fetch(`/api/movements/${id}`, { method: 'DELETE' });
if (response.ok) {
movements = movements.filter(m => m.id !== id); movements = movements.filter(m => m.id !== id);
renderTable(); 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, genero: document.getElementById('c-genero').value,
cumpleaños: document.getElementById('c-cumple').value, cumpleaños: document.getElementById('c-cumple').value,
consentimiento: document.getElementById('c-consent').checked, consentimiento: document.getElementById('c-consent').checked,
esOncologico: document.getElementById('c-esOncologico').checked, esOncologico: document.getElementById('c-pacienteOncologico').checked,
oncologoAprueba: document.getElementById('c-oncologoAprueba').checked, oncologoAprueba: document.getElementById('c-oncologoAprueba').checked,
nombreMedico: document.getElementById('c-nombreMedico').value, nombreMedico: document.getElementById('c-nombreMedico').value,
telefonoMedico: document.getElementById('c-telefonoMedico').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) { if (isUpdate) {
const index = clients.findIndex(c => c.id === clientToSave.id); const index = clients.findIndex(c => c.id === clientToSave.id);
@@ -204,11 +681,20 @@ async function saveClient(clientData) {
async function deleteClient(id) { async function deleteClient(id) {
if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) { if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) {
await remove(KEY_CLIENTS, id); try {
const response = await fetch(`/api/clients/${id}`, { method: 'DELETE' });
if (response.ok) {
clients = clients.filter(c => c.id !== id); clients = clients.filter(c => c.id !== id);
renderClientsTable(); renderClientsTable();
updateClientDatalist(); updateClientDatalist();
clearClientRecord(); 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) { function populateArticuloDropdown(category) {
const articuloSelect = document.getElementById('m-articulo'); const articuloSelect = document.getElementById('m-articulo');
if (!articuloSelect) return; if (!articuloSelect) return;
articuloSelect.innerHTML = '';
// 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 = `<option value="">-- Seleccionar ${placeholder} --</option>`;
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); const items = products.filter(p => p.type === category);
items.forEach(i => { items.forEach(i => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = i.name; option.value = i.name;
option.textContent = i.name; option.textContent = `${i.name} - $${parseFloat(i.price).toFixed(2)}`;
articuloSelect.appendChild(option); articuloSelect.appendChild(option);
}); });
}
} else {
articuloSelect.innerHTML = '<option value="">-- Primero seleccione tipo --</option>';
}
} }
// --- MANEJADORES DE EVENTOS --- // --- MANEJADORES DE EVENTOS ---
@@ -393,8 +899,22 @@ async function handleSaveSettings(e) {
settings.tel = document.getElementById('s-tel').value; settings.tel = document.getElementById('s-tel').value;
settings.leyenda = document.getElementById('s-leyenda').value; settings.leyenda = document.getElementById('s-leyenda').value;
settings.folioPrefix = document.getElementById('s-folioPrefix').value; settings.folioPrefix = document.getElementById('s-folioPrefix').value;
await save(KEY_SETTINGS, { settings });
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.'); 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) { async function handleSaveCredentials(e) {
@@ -651,6 +1171,11 @@ async function handleNewMovement(e) {
const monto = parseFloat(document.getElementById('m-monto').value || 0); const monto = parseFloat(document.getElementById('m-monto').value || 0);
const clienteNombre = document.getElementById('m-cliente').value; 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()); let client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase());
if (!client) { if (!client) {
if (confirm(`El cliente "${clienteNombre}" no existe. ¿Deseas crearlo?`)) { 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 = { const newMovement = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
folio: generateFolio(), folio: generateFolio(),
fechaISO: new Date().toISOString(), fechaISO: new Date().toISOString(),
clienteId: client.id, clienteId: client.id,
tipo: document.getElementById('m-categoria').value, tipo: selectedProducts.length > 0 ? selectedProducts[0].type : 'service',
subtipo: '', subtipo: '',
monto: Number(monto.toFixed(2)), monto: Number(monto.toFixed(2)),
metodo: document.getElementById('m-metodo').value, metodo: document.getElementById('m-metodo').value,
concepto: document.getElementById('m-articulo').value, concepto: concepto,
staff: currentUser.name, staff: currentUser.name,
notas: document.getElementById('m-notas').value, notas: document.getElementById('m-notas').value,
fechaCita: document.getElementById('m-fecha-cita').value, fechaCita: construirFechaCita(),
horaCita: document.getElementById('m-hora-cita').value, horaCita: document.getElementById('m-hora-cita').value,
productos: selectedProducts, // Store product details for ticket
descuento: currentDiscount,
subtotal: currentSubtotal
}; };
await addMovement(newMovement); await addMovement(newMovement);
renderTicketAndPrint({ ...newMovement, client }, settings); renderTicketAndPrint({ ...newMovement, client }, settings);
// Reset form and clear products
form.reset(); form.reset();
selectedProducts = [];
currentSubtotal = 0;
currentDiscount = 0;
renderSelectedProducts();
calculateTotals();
hideDynamicSections();
document.getElementById('m-cliente').focus(); document.getElementById('m-cliente').focus();
subtipoContainer.classList.add('hidden');
} }
function exportClientHistoryCSV(client, history) { function exportClientHistoryCSV(client, history) {
@@ -915,7 +1454,7 @@ function activateTab(tabId) {
tabContent.classList.add('active'); tabContent.classList.add('active');
} }
if (tabId === 'tab-dashboard' && currentUser.role === 'admin') { if (tabId === 'tab-dashboard') {
if (!incomeChart) { if (!incomeChart) {
const ctx = document.getElementById('incomeChart').getContext('2d'); const ctx = document.getElementById('incomeChart').getContext('2d');
incomeChart = new Chart(ctx, { incomeChart = new Chart(ctx, {
@@ -1015,8 +1554,8 @@ function setupUIForRole(role) {
}) })
.catch(err => console.error(err)); .catch(err => console.error(err));
} else { } else {
if (dashboardTab) dashboardTab.style.display = 'none'; if (dashboardTab) dashboardTab.style.display = 'block';
if (settingsTab) settingsTab.style.display = 'none'; if (settingsTab) settingsTab.style.display = 'block';
if (userManagementSection) userManagementSection.style.display = 'none'; if (userManagementSection) userManagementSection.style.display = 'none';
if (dbInfoIcon) dbInfoIcon.style.display = 'none'; if (dbInfoIcon) dbInfoIcon.style.display = 'none';
} }
@@ -1027,18 +1566,13 @@ function setupUIForRole(role) {
} }
function populateFooter() { function populateFooter() {
const dateElement = document.getElementById('footer-date'); // Footer elements removed - no longer needed
const versionElement = document.getElementById('footer-version');
if (dateElement) {
dateElement.textContent = formatDate(new Date().toISOString());
}
if (versionElement) {
versionElement.textContent = `Versión ${APP_VERSION}`;
}
} }
// Make removeProduct globally accessible
window.removeProduct = removeProduct;
// --- INICIALIZACIÓN --- // --- INICIALIZACIÓN ---
async function initializeApp() { async function initializeApp() {
@@ -1106,9 +1640,12 @@ async function initializeApp() {
document.getElementById('oncologico-fields').classList.add('hidden'); 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'); const oncologicoFields = document.getElementById('oncologico-fields');
if (oncologicoFields) {
oncologicoFields.classList.toggle('hidden', !e.target.checked); oncologicoFields.classList.toggle('hidden', !e.target.checked);
oncologicoFields.classList.toggle('active', e.target.checked);
}
}); });
btnCancelEditUser?.addEventListener('click', (e) => { btnCancelEditUser?.addEventListener('click', (e) => {
@@ -1150,10 +1687,15 @@ async function initializeApp() {
document.getElementById('c-cumple').value = client.cumpleaños; document.getElementById('c-cumple').value = client.cumpleaños;
document.getElementById('c-consent').checked = client.consentimiento; 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'); const oncologicoFields = document.getElementById('oncologico-fields');
if (esOncologicoCheckbox) {
esOncologicoCheckbox.checked = client.esOncologico; esOncologicoCheckbox.checked = client.esOncologico;
}
if (oncologicoFields) {
oncologicoFields.classList.toggle('hidden', !client.esOncologico); oncologicoFields.classList.toggle('hidden', !client.esOncologico);
oncologicoFields.classList.toggle('active', client.esOncologico);
}
document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba; document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba;
document.getElementById('c-nombreMedico').value = client.nombreMedico || ''; document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
@@ -1180,10 +1722,10 @@ async function initializeApp() {
}); });
Promise.all([ Promise.all([
load(KEY_SETTINGS, DEFAULT_SETTINGS), fetch('/api/settings').then(res => res.json()).catch(() => DEFAULT_SETTINGS),
load(KEY_DATA, []), fetch('/api/movements').then(res => res.json()).catch(() => []),
load(KEY_CLIENTS, []), fetch('/api/clients').then(res => res.json()).catch(() => []),
fetch('/api/products').then(res => res.json()), fetch('/api/products').then(res => res.json()).catch(() => []),
]).then(values => { ]).then(values => {
console.log('Initial data loaded:', values); console.log('Initial data loaded:', values);
[settings, movements, clients, products] = values; [settings, movements, clients, products] = values;
@@ -1198,7 +1740,7 @@ async function initializeApp() {
renderProductTables(); renderProductTables();
console.log('Updating client datalist...'); console.log('Updating client datalist...');
updateClientDatalist(); updateClientDatalist();
populateArticuloDropdown(document.getElementById('m-categoria').value); populateArticuloDropdown('');
if (currentUser) { if (currentUser) {
console.log('Setting user info in form...'); console.log('Setting user info in form...');
@@ -1211,11 +1753,7 @@ async function initializeApp() {
setupUIForRole(currentUser.role); setupUIForRole(currentUser.role);
console.log('Activating initial tab...'); console.log('Activating initial tab...');
if (currentUser.role === 'admin') {
activateTab('tab-dashboard'); activateTab('tab-dashboard');
} else {
activateTab('tab-movements');
}
console.log('Activating client sub-tab...'); console.log('Activating client sub-tab...');
activateClientSubTab('sub-tab-register'); activateClientSubTab('sub-tab-register');
@@ -1223,6 +1761,8 @@ async function initializeApp() {
clearClientRecord(); clearClientRecord();
console.log('Populating footer...'); console.log('Populating footer...');
populateFooter(); populateFooter();
console.log('Initializing dynamic system...');
initializeDynamicSystem();
console.log('Initialization complete.'); console.log('Initialization complete.');
}).catch(error => { }).catch(error => {

View File

@@ -1,6 +1,6 @@
services: services:
ap-pos: ap-pos:
image: marcogll/ap_pos:latest image: coderk/ap_pos:1.3.5
container_name: ap-pos container_name: ap-pos
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -10,7 +10,7 @@ services:
SESSION_SECRET: ${SESSION_SECRET:-your-very-secret-key-change-it-in-production} SESSION_SECRET: ${SESSION_SECRET:-your-very-secret-key-change-it-in-production}
DB_PATH: /app/data/ap-pos.db DB_PATH: /app/data/ap-pos.db
volumes: volumes:
- ap_pos_data:/app/data - ./data:/app/data
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3111/login.html"] test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3111/login.html"]
interval: 30s interval: 30s
@@ -18,10 +18,4 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
volumes: # volumes section no longer needed - using direct bind mount
ap_pos_data:
driver: local
driver_opts:
type: none
o: bind
device: ./data

View File

@@ -12,7 +12,7 @@
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Icons+Outlined" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Material+Icons+Outlined" rel="stylesheet">
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" href="styles.css?v=1.4" /> <link rel="stylesheet" href="styles.css?v=1.8" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head> </head>
<body> <body>
@@ -79,49 +79,176 @@
<div id="tab-movements" class="tab-content"> <div id="tab-movements" class="tab-content">
<div class="section"> <div class="section">
<h2>Nuevo Movimiento</h2> <h2>Nuevo Movimiento</h2>
<form id="formMove"> <form id="formMove" class="form-modern">
<div class="form-grid"> <!-- Cliente -->
<label>Cliente:</label> <div class="form-row">
<div> <div class="form-group">
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" /> <label>Cliente</label>
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" placeholder="Nombre del cliente" />
<datalist id="client-list"></datalist> <datalist id="client-list"></datalist>
</div> </div>
<label>Categoría:</label> </div>
<select id="m-categoria" required>
<option value="service">Servicio</option> <!-- Cita -->
<option value="course">Curso</option> <div class="form-section" id="appointment-section">
</select> <h3>Datos de la Cita</h3>
<label>Artículo:</label> <div class="form-row">
<select id="m-articulo" required> <div class="form-group">
</select> <label>Fecha</label>
<div id="m-subtipo-container" class="hidden"> <div class="date-time-container">
<label>Subtipo:</label> <input type="number" id="m-cita-dia" min="1" max="31" placeholder="DD" class="date-field" />
<select id="m-subtipo"> <span class="date-separator">/</span>
<option value="Servicio">Servicio</option> <input type="number" id="m-cita-mes" min="1" max="12" placeholder="MM" class="date-field" />
<option value="Retoque">Retoque</option> <span class="date-separator">/</span>
<input type="number" id="m-cita-año" min="2024" max="2030" placeholder="AAAA" class="date-field-year" />
</div>
</div>
<div class="form-group">
<label>Hora</label>
<select id="m-hora-cita" class="time-select">
<option value="">-- Seleccionar hora --</option>
<option value="10:00">10:00 AM</option>
<option value="10:30">10:30 AM</option>
<option value="11:00">11:00 AM</option>
<option value="11:30">11:30 AM</option>
<option value="12:00">12:00 PM</option>
<option value="12:30">12:30 PM</option>
<option value="13:00">1:00 PM</option>
<option value="13:30">1:30 PM</option>
<option value="14:00">2:00 PM</option>
<option value="14:30">2:30 PM</option>
<option value="15:00">3:00 PM</option>
<option value="15:30">3:30 PM</option>
<option value="16:00">4:00 PM</option>
<option value="16:30">4:30 PM</option>
<option value="17:00">5:00 PM</option>
<option value="17:30">5:30 PM</option>
<option value="18:00">6:00 PM</option>
</select> </select>
</div> </div>
<label>Fecha de Cita:</label> </div>
<input type="date" id="m-fecha-cita" /> </div>
<label>Hora de Cita:</label>
<input type="time" id="m-hora-cita" /> <!-- Venta -->
<label>Monto (MXN):</label><input type="number" id="m-monto" step="0.01" min="0" required /> <div class="form-section">
<label>Método:</label> <h3>Venta</h3>
<select id="m-metodo"> <div class="products-container">
<option value="">-- Opcional --</option> <div class="product-selector">
<select id="m-categoria" required>
<option value="">-- Seleccione tipo --</option>
<option value="service">Servicio</option>
<option value="course">Curso</option>
<option value="anticipo">Anticipo</option>
</select>
<select id="m-articulo" class="product-select">
<option value="">-- Primero seleccione tipo --</option>
</select>
<input type="number" id="product-quantity" min="1" value="1" class="quantity-input" placeholder="Cant." />
<button type="button" id="add-product-btn" class="btn-add">Agregar</button>
</div>
<div id="selected-products" class="selected-products"></div>
</div>
</div>
<!-- Descuentos -->
<div class="form-section discount-section">
<div class="discount-header">
<input type="checkbox" id="discount-toggle" class="discount-checkbox">
<label for="discount-toggle" class="discount-label">
<span class="material-icons-outlined discount-icon">percent</span>
Aplicar descuento
</label>
</div>
<div class="discount-container" id="discount-container" style="display: none;">
<div class="discount-grid">
<div class="form-group">
<label>Tipo de descuento</label>
<select id="discount-type">
<option value="">Sin descuento</option>
<option value="percentage">Porcentaje (%)</option>
<option value="amount">Cantidad fija ($)</option>
<option value="warrior">🎗️ Vanity (100%)</option>
</select>
</div>
<div class="form-group">
<label>Valor del descuento</label>
<div class="input-with-symbol">
<input type="number" id="discount-value" min="0" step="0.01" placeholder="0" disabled />
<span class="input-symbol" id="discount-symbol">%</span>
</div>
</div>
<div class="form-group full-width-discount">
<label>Motivo del descuento</label>
<input type="text" id="discount-reason" placeholder="Ej: Cliente frecuente, promoción especial..." disabled />
</div>
</div>
<div class="discount-preview" id="discount-preview" style="display: none;">
<div class="discount-preview-item">
<span>Descuento aplicado:</span>
<span id="discount-preview-amount" class="discount-amount">$0.00</span>
</div>
</div>
</div>
</div>
<!-- Anticipos Disponibles -->
<div class="form-section anticipos-section" id="anticipos-section" style="display: none;">
<h4>💰 Anticipos Disponibles</h4>
<div id="anticipos-disponibles" class="anticipos-container">
<!-- Los anticipos se cargarán dinámicamente -->
</div>
</div>
<!-- Método de Pago y Detalles -->
<div class="form-row">
<div class="form-group">
<label>Método de Pago *</label>
<select id="m-metodo" required>
<option value="">-- Seleccione método de pago --</option>
<option value="Efectivo">Efectivo</option> <option value="Efectivo">Efectivo</option>
<option value="Tarjeta">Tarjeta</option> <option value="Tarjeta">Tarjeta</option>
<option value="Transferencia">Transferencia</option> <option value="Transferencia">Transferencia</option>
<option value="Depósito">Depósito</option> <option value="Depósito">Depósito</option>
<option value="Giftcard">Giftcard</option>
<option value="Interno">Interno</option>
<option value="Otros">Otros</option> <option value="Otros">Otros</option>
</select> </select>
<label>Concepto:</label><input type="text" id="m-concepto" />
<label>Atendió:</label><input type="text" id="m-staff" />
<label>Notas:</label><textarea id="m-notas" rows="2"></textarea>
</div> </div>
<div class="form-actions"> <div class="form-group">
<button type="submit">Guardar y Generar Recibo</button> <label>Atendió</label>
<button type="reset" class="btn-danger">Limpiar</button> <input type="text" id="m-staff" readonly />
</div>
</div>
<div class="form-group full-width">
<label>Notas</label>
<textarea id="m-notas" rows="2" placeholder="Notas adicionales..."></textarea>
</div>
<!-- Totales -->
<div class="totals-section">
<div class="totals-row">
<span>Subtotal:</span>
<span id="subtotal-display">$0.00</span>
</div>
<div class="totals-row" id="discount-display" style="display: none;">
<span>Descuento:</span>
<span id="discount-amount-display">-$0.00</span>
</div>
<div class="totals-row total-final">
<span>Total:</span>
<span id="total-display">$0.00</span>
</div>
</div>
<!-- Campos ocultos para compatibilidad -->
<input type="hidden" id="m-monto" />
<input type="hidden" id="m-concepto" />
<div class="form-actions-modern">
<button type="submit" class="btn-primary-large">Generar Venta y Ticket</button>
<button type="reset" class="btn-secondary-large">Limpiar Formulario</button>
</div> </div>
</form> </form>
</div> </div>
@@ -184,14 +311,14 @@
</div> </div>
<div class="checkbox-container"> <div class="checkbox-container">
<input type="checkbox" id="c-esOncologico" /> <input type="checkbox" id="c-pacienteOncologico" />
<label for="c-esOncologico">¿Es paciente oncológico?</label> <label for="c-pacienteOncologico">🎗️ Paciente Oncológico</label>
</div> </div>
</div> </div>
<!-- Campos condicionales para paciente oncológico --> <!-- Campos condicionales para paciente oncológico -->
<div id="oncologico-fields" class="sub-section hidden"> <div id="oncologico-fields" class="sub-section hidden">
<h3>Información Oncológica</h3> <h3>📋 Información Médica Oncológica</h3>
<div class="form-grid-single"> <div class="form-grid-single">
<div class="checkbox-container"> <div class="checkbox-container">
<input type="checkbox" id="c-oncologoAprueba" /> <input type="checkbox" id="c-oncologoAprueba" />
@@ -209,7 +336,7 @@
<div class="checkbox-container"> <div class="checkbox-container">
<input type="checkbox" id="c-pruebaAprobacion" /> <input type="checkbox" id="c-pruebaAprobacion" />
<label for="c-pruebaAprobacion">¿Presenta prueba de aprobación?</label> <label for="c-pruebaAprobacion">🎗️ Presenta autorización médica explícita firmada</label>
</div> </div>
<p class="data-location-info"> <p class="data-location-info">
@@ -447,20 +574,11 @@
</main> </main>
<footer class="main-footer"> <footer class="main-footer">
<div class="footer-logos">
<img src="src/logo_dev.png" alt="Marco Gallegos">
<img src="src/logo_gemini.png" alt="Google Gemini">
</div>
<div class="footer-info">
<p>Marco Gallegos | Creado con Google Gemini ®</p>
<p id="footer-date"></p>
<p id="footer-version"></p>
</div>
</footer> </footer>
<div id="printArea" class="no-print"></div> <div id="printArea" class="no-print"></div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
<script type="module" src="app.js?v=1.3"></script> <script type="module" src="app.js?v=1.8"></script>
</body> </body>
</html> </html>

View File

@@ -13,18 +13,6 @@ function esc(str) {
}[c])); }[c]));
} }
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();
const hours = String(adjustedDate.getHours()).padStart(2, '0');
const minutes = String(adjustedDate.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
}
/** /**
* Genera el HTML para un ticket de movimiento. * Genera el HTML para un ticket de movimiento.
@@ -33,7 +21,37 @@ function formatDate(dateString) {
* @returns {string} El HTML del ticket. * @returns {string} El HTML del ticket.
*/ */
function templateTicket(mov, settings) { function templateTicket(mov, settings) {
const fechaLocal = formatDate(mov.fechaISO || Date.now()); // Función de fecha EXCLUSIVA para tickets - no depende de nada más
function fechaParaTicketSolamente() {
console.log('>>> EJECUTANDO fechaParaTicketSolamente()');
// Crear fecha con zona horaria México directamente
const fechaObj = new Date();
console.log('>>> Objeto Date original:', fechaObj);
// Obtener fecha en zona horaria México (UTC-6)
const fechaMexico = new Date(fechaObj.getTime() - (6 * 60 * 60 * 1000));
console.log('>>> Fecha México:', fechaMexico);
// Obtener cada parte por separado
const año = fechaMexico.getUTCFullYear();
const mes = fechaMexico.getUTCMonth() + 1;
const día = fechaMexico.getUTCDate();
const hora = fechaMexico.getUTCHours();
const minuto = fechaMexico.getUTCMinutes();
// Formatear cada número manualmente
const dStr = día.toString().padStart(2, '0');
const mStr = mes.toString().padStart(2, '0');
const hStr = hora.toString().padStart(2, '0');
const minStr = minuto.toString().padStart(2, '0');
const fechaFinal = `${dStr}/${mStr}/${año} ${hStr}:${minStr}`;
console.log('>>> Fecha final:', fechaFinal);
return fechaFinal;
}
const montoFormateado = Number(mov.monto).toFixed(2); const montoFormateado = Number(mov.monto).toFixed(2);
const tipoServicio = mov.subtipo === 'Retoque' ? `Retoque de ${mov.tipo}` : mov.tipo; const tipoServicio = mov.subtipo === 'Retoque' ? `Retoque de ${mov.tipo}` : mov.tipo;
@@ -41,23 +59,40 @@ function templateTicket(mov, settings) {
lines.push('<div class="ticket">'); lines.push('<div class="ticket">');
lines.push('<img src="src/logo.png" alt="Logo" class="t-logo">'); lines.push('<img src="src/logo.png" alt="Logo" class="t-logo">');
if (settings.negocio) lines.push(`<div class="t-center t-bold">${esc(settings.negocio)}</div>`); // Información del negocio - verificar estructura de settings
if (settings.tagline) lines.push(`<div class="t-center t-tagline">${esc(settings.tagline)}</div>`); // Extraer datos desde settings o settings.settings (doble anidación)
if (settings.rfc) lines.push(`<div class="t-center t-small">RFC: ${esc(settings.rfc)}</div>`); const businessData = settings?.settings || settings || {};
if (settings.sucursal) lines.push(`<div class="t-center t-small">${esc(settings.sucursal)}</div>`);
if (settings.tel) lines.push(`<div class="t-center t-small">Tel: ${esc(settings.tel)}</div>`); const negocioNombre = businessData?.negocio || settings?.negocio || 'Ale Ponce';
const negocioTagline = businessData?.tagline || settings?.tagline || 'beauty expert';
const negocioCalle = businessData?.calle || settings?.calle;
const negocioColonia = businessData?.colonia || settings?.colonia;
const negocioCP = businessData?.cp || settings?.cp;
const negocioRFC = businessData?.rfc || settings?.rfc;
const negocioTel = businessData?.tel || settings?.tel || '8443555108';
lines.push(`<div class="t-center t-bold t-business-name">${esc(negocioNombre)}</div>`);
lines.push(`<div class="t-center t-tagline">${esc(negocioTagline)}</div>`);
lines.push('<div class="t-spacer"></div>');
if (negocioCalle) lines.push(`<div class="t-center t-small">${esc(negocioCalle)}</div>`);
if (negocioColonia && negocioCP) lines.push(`<div class="t-center t-small">${esc(negocioColonia)}, ${esc(negocioCP)}</div>`);
if (negocioRFC) lines.push(`<div class="t-center t-small">RFC: ${esc(negocioRFC)}</div>`);
lines.push(`<div class="t-center t-small">Tel: ${esc(negocioTel)}</div>`);
lines.push('<div class="t-divider"></div>'); lines.push('<div class="t-divider"></div>');
lines.push(`<div class="t-row t-small"><span>Folio:</span><span>${esc(mov.folio)}</span></div>`); lines.push(`<div class="t-row t-small"><span>Folio:</span><span>${esc(mov.folio)}</span></div>`);
lines.push(`<div class="t-row t-small"><span>Fecha:</span><span>${esc(fechaLocal)}</span></div>`); // Usar la función de fecha específica para tickets
const fechaFinal = fechaParaTicketSolamente();
lines.push(`<div class="t-row t-small"><span>Fecha:</span><span>${esc(fechaFinal)}</span></div>`);
lines.push('<div class="t-divider"></div>'); lines.push('<div class="t-divider"></div>');
lines.push(`<div><span class="t-bold">${esc(tipoServicio)}</span></div>`); lines.push(`<div class="t-service-title t-bold">${esc(tipoServicio)}</div>`);
if (mov.client) lines.push(`<div class="t-small">Cliente: ${esc(mov.client.nombre)}</div>`); if (mov.client) lines.push(`<div class="t-small t-service-detail">Cliente: ${esc(mov.client.nombre)}</div>`);
if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`); if (mov.concepto) lines.push(`<div class="t-small t-service-detail">Concepto: ${esc(mov.concepto)}</div>`);
if (mov.staff) lines.push(`<div class="t-small"><b>Te atendió:</b> ${esc(mov.staff)}</div>`); if (mov.staff) lines.push(`<div class="t-small t-service-detail"><b>Te atendió:</b> ${esc(mov.staff)}</div>`);
if (mov.metodo) lines.push(`<div class="t-small">Método: ${esc(mov.metodo)}</div>`); if (mov.metodo) lines.push(`<div class="t-small t-service-detail">Método: ${esc(mov.metodo)}</div>`);
if (mov.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`); if (mov.notas) lines.push(`<div class="t-small t-service-detail">Notas: ${esc(mov.notas)}</div>`);
lines.push('<div class="t-divider"></div>'); lines.push('<div class="t-divider"></div>');
lines.push(`<div class="t-row t-bold"><span>Total</span><span>${montoFormateado}</span></div>`); lines.push(`<div class="t-row t-bold"><span>Total</span><span>${montoFormateado}</span></div>`);
@@ -72,17 +107,21 @@ function templateTicket(mov, settings) {
if (mov.client.cedulaMedico) lines.push(`<div class="t-small">Cédula: ${esc(mov.client.cedulaMedico)}</div>`); if (mov.client.cedulaMedico) lines.push(`<div class="t-small">Cédula: ${esc(mov.client.cedulaMedico)}</div>`);
} }
lines.push('<div class="t-divider"></div>'); lines.push('<div class="t-divider"></div>');
lines.push(`<div class="t-small t-center">Al consentir el servicio, declara que la información médica proporcionada es veraz.</div>`); const consentText = mov.tipo === 'Curso' || tipoServicio.toLowerCase().includes('curso')
? 'Al inscribirse al curso, acepta los términos y condiciones del programa educativo.'
: 'Al consentir el servicio, declara que la información médica proporcionada es veraz.';
lines.push(`<div class="t-small t-center">${consentText}</div>`);
} }
if (settings.leyenda) lines.push(`<div class="t-footer t-center t-small">${esc(settings.leyenda)}</div>`);
lines.push('<div class="t-qr-section">'); lines.push('<div class="t-qr-section">');
lines.push('<div class="t-small t-bold">¡Tu opinión es muy importante!</div>'); lines.push('<div class="t-small t-bold t-center">¡Tu opinión es muy importante!</div>');
lines.push('<div class="t-small">Escanea el código QR para darnos tu feedback.</div>'); lines.push('<div class="t-small t-center">Escanea el código QR para darnos tu feedback.</div>');
lines.push('<canvas id="qr-canvas"></canvas>'); lines.push('<canvas id="qr-canvas"></canvas>');
lines.push('</div>'); lines.push('</div>');
const negocioLeyenda = businessData?.leyenda || settings?.leyenda;
if (negocioLeyenda) lines.push(`<div class="t-footer t-center t-small">${esc(negocioLeyenda)}</div>`);
lines.push('</div>'); lines.push('</div>');
return lines.join(''); return lines.join('');
} }

View File

@@ -21,14 +21,16 @@ app.use(session({
secret: SESSION_SECRET, secret: SESSION_SECRET,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { secure: IN_PROD, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 } // `secure: true` en producción con HTTPS cookie: { secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 } // secure: false para VPS sin HTTPS
})); }));
// --- DATABASE INITIALIZATION --- // --- DATABASE INITIALIZATION ---
// Usar un path que funcione tanto en desarrollo como en Docker // Usar un path que funcione tanto en desarrollo como en Docker
const dbPath = process.env.NODE_ENV === 'production' const dbPath = process.env.DB_PATH || (
process.env.NODE_ENV === 'production'
? path.join(__dirname, 'data', 'ap-pos.db') ? path.join(__dirname, 'data', 'ap-pos.db')
: path.join(__dirname, 'ap-pos.db'); : path.join(__dirname, 'ap-pos.db')
);
console.log(`Connecting to database at: ${dbPath}`); console.log(`Connecting to database at: ${dbPath}`);
const db = new sqlite3.Database(dbPath, (err) => { const db = new sqlite3.Database(dbPath, (err) => {
@@ -485,8 +487,8 @@ function startServer() {
} }
}); });
// --- Dashboard Route (Admin Only) --- // --- Dashboard Route (Authenticated Users) ---
apiRouter.get('/dashboard', isAdmin, (req, res) => { apiRouter.get('/dashboard', isAuthenticated, (req, res) => {
const queries = { const queries = {
totalIncome: "SELECT SUM(monto) as total FROM movements", totalIncome: "SELECT SUM(monto) as total FROM movements",
totalMovements: "SELECT COUNT(*) as total FROM movements", totalMovements: "SELECT COUNT(*) as total FROM movements",

View File

@@ -59,6 +59,400 @@ h3 {
align-items: center; align-items: center;
} }
/* Campos de fecha y hora mejorados */
.date-time-container {
display: flex;
align-items: center;
gap: 8px;
}
.date-field {
width: 50px !important;
text-align: center;
padding: 8px 4px !important;
font-size: 14px;
}
.date-field-year {
width: 70px !important;
text-align: center;
padding: 8px 4px !important;
font-size: 14px;
}
.date-separator {
font-weight: bold;
color: #6c757d;
font-size: 16px;
}
.time-select {
min-width: 160px;
font-size: 14px;
}
/* Nuevos estilos modernos para el POS */
.form-modern {
display: flex;
flex-direction: column;
gap: 25px;
max-width: 800px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #495057;
font-size: 14px;
text-align: left;
margin: 0;
}
.form-section {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.form-section h3 {
margin: 0 0 15px 0;
color: #343a40;
font-size: 16px;
font-weight: 600;
}
.products-container {
display: flex;
flex-direction: column;
gap: 15px;
}
.product-selector {
display: grid;
grid-template-columns: 150px 1fr 80px 100px;
gap: 12px;
align-items: end;
}
.product-select {
min-width: 200px;
}
.quantity-input {
width: 70px !important;
text-align: center;
padding: 8px 4px !important;
}
.btn-add {
background-color: #28a745;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background-color 0.2s;
}
.btn-add:hover {
background-color: #218838;
}
.selected-products {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.product-item {
display: grid;
grid-template-columns: 1fr 80px 100px 40px;
gap: 12px;
align-items: center;
background: white;
padding: 12px;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.product-item-name {
font-weight: 500;
color: #343a40;
}
.product-item-quantity {
text-align: center;
color: #6c757d;
font-size: 14px;
}
.product-item-price {
text-align: right;
font-weight: 600;
color: #28a745;
}
.btn-remove {
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-remove:hover {
background-color: #c82333;
}
.totals-section {
background: #343a40;
color: white;
padding: 20px;
border-radius: 8px;
margin: 10px 0;
}
.totals-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.totals-row:last-child {
border-bottom: none;
}
.total-final {
font-size: 18px;
font-weight: 600;
border-top: 2px solid rgba(255, 255, 255, 0.3);
margin-top: 10px;
padding-top: 15px;
}
/* Estilos para sección de descuentos colapsable */
.discount-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
transition: all 0.3s ease;
}
.discount-header {
padding: 0 0 15px 0;
background: transparent;
color: #343a40;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: color 0.2s ease;
border-bottom: 1px solid #dee2e6;
margin-bottom: 15px;
}
.discount-header:hover {
color: #007bff;
}
.discount-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #007bff;
}
.discount-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
margin: 0;
flex-grow: 1;
color: inherit;
}
.discount-icon {
font-size: 20px;
opacity: 0.8;
}
.discount-container {
background: transparent;
padding: 0;
border: none;
border-radius: 0;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 300px;
}
}
.discount-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
padding: 0;
border-bottom: 1px solid #dee2e6;
padding-bottom: 15px;
margin-bottom: 15px;
}
.full-width-discount {
grid-column: 1 / -1;
}
.input-with-symbol {
position: relative;
display: flex;
align-items: center;
}
.input-with-symbol input {
flex: 1;
padding-right: 35px;
}
.input-symbol {
position: absolute;
right: 12px;
color: #6c757d;
font-weight: 600;
font-size: 14px;
pointer-events: none;
}
.discount-preview {
background: #e8f5e8;
padding: 12px 15px;
border-radius: 6px;
border: 1px solid #d4edda;
}
.discount-preview-item {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.discount-preview-item span:first-child {
color: #495057;
}
.discount-amount {
color: #28a745;
font-size: 16px;
font-weight: 700;
}
/* Oncological patient section styling */
#oncologico-fields.active h3 {
color: #ea76cb;
transition: color 0.3s ease;
}
/* Warrior option styling */
option[value="warrior"] {
background-color: #ff6b6b;
color: white;
font-weight: bold;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.discount-grid {
grid-template-columns: 1fr;
gap: 15px;
}
.discount-header {
padding: 12px 15px;
}
.discount-label {
font-size: 14px;
}
}
.full-width {
grid-column: 1 / -1;
}
.form-actions-modern {
display: flex;
gap: 15px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid #dee2e6;
}
.btn-primary-large {
background-color: #007bff;
color: white;
border: none;
padding: 15px 30px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: background-color 0.2s;
}
.btn-primary-large:hover {
background-color: #0056b3;
}
.btn-secondary-large {
background-color: #6c757d;
color: white;
border: none;
padding: 15px 30px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: background-color 0.2s;
}
.btn-secondary-large:hover {
background-color: #5a6268;
}
label { label {
font-weight: 600; font-weight: 600;
text-align: right; text-align: right;
@@ -178,7 +572,7 @@ button.action-btn {
color: #000; color: #000;
padding: 10px; padding: 10px;
box-sizing: border-box; box-sizing: border-box;
font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
border: 1px solid #ccc; /* Visible en pantalla, no en impresión */ border: 1px solid #ccc; /* Visible en pantalla, no en impresión */
} }
.t-logo { .t-logo {
@@ -189,43 +583,65 @@ button.action-btn {
} }
.t-center { text-align: center; } .t-center { text-align: center; }
.t-bold { font-weight: bold; } .t-bold { font-weight: bold; }
.t-tagline { font-size: 11px; margin-bottom: 6px; } .t-business-name { font-size: 14px; margin-bottom: 4px; }
.t-small { font-size: 10px; } .t-tagline { font-size: 11px; margin-bottom: 8px; font-style: italic; }
.t-spacer { height: 4px; }
.t-small { font-size: 10px; line-height: 1.3; }
.t-service-title { margin-bottom: 6px; font-size: 12px; }
.t-service-detail { margin-bottom: 3px; }
.t-divider { border-top: 1px dashed #000; margin: 8px 0; } .t-divider { border-top: 1px dashed #000; margin: 8px 0; }
.t-row { display: flex; justify-content: space-between; } .t-row { display: flex; justify-content: space-between; margin-bottom: 2px; }
.t-footer { margin-top: 10px; } .t-footer { margin-top: 10px; }
.t-qr-section { .t-qr-section {
margin-top: 10px; margin-top: 12px;
padding-top: 10px; padding-top: 8px;
border-top: 1px dashed #000; border-top: 1px dashed #000;
text-align: center; text-align: center;
} }
.t-qr-section .t-small {
margin-bottom: 4px;
}
#qr-canvas { #qr-canvas {
margin: 5px auto; margin: 8px auto 4px auto;
display: block; display: block;
} }
/***** MODO IMPRESIÓN *****/ /***** MODO IMPRESIÓN *****/
@media print { @media print {
* {
visibility: hidden;
}
body { body {
background: #fff; background: #fff !important;
color: #000; color: #000 !important;
margin: 0; margin: 0 !important;
} }
.no-print, .container, .main-footer-credits { .no-print, .container, .main-footer-credits, .main-header, .main-footer,
.tabs, .tab-content, header, footer, nav, aside {
display: none !important; display: none !important;
visibility: hidden !important;
} }
#printArea { #printArea, #printArea * {
visibility: visible !important;
display: block !important; display: block !important;
} }
@page { @page {
size: 58mm auto; size: 58mm auto;
margin: 1cm 0; margin: 0.5cm 0.2cm;
}
/* Configuración adicional para impresión PDF */
@media print {
body {
width: 58mm !important;
max-width: 58mm !important;
}
} }
.ticket { .ticket {
@@ -234,6 +650,15 @@ button.action-btn {
max-width: 100%; max-width: 100%;
padding: 0; padding: 0;
} }
.t-logo {
display: block !important;
margin: 0 auto 8px auto;
max-width: 75%;
height: auto;
-webkit-print-color-adjust: exact;
color-adjust: exact;
}
} }
/* --- Estilos de Pestañas --- */ /* --- Estilos de Pestañas --- */
@@ -663,3 +1088,66 @@ table tbody tr:hover {
.footer-info p { .footer-info p {
margin: 5px 0; margin: 5px 0;
} }
/* Estilos para sección de anticipos */
.anticipos-section {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin: 15px 0;
}
.anticipos-section h4 {
margin: 0 0 15px 0;
color: #28a745;
font-size: 16px;
}
.anticipos-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.anticipo-item {
background: white;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.anticipo-info {
flex: 1;
}
.anticipo-monto {
font-weight: bold;
color: #28a745;
}
.anticipo-fecha {
font-size: 12px;
color: #6c757d;
}
.anticipo-actions {
display: flex;
gap: 8px;
}
.btn-aplicar-anticipo {
background: #28a745;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.btn-aplicar-anticipo:hover {
background: #218838;
}