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
- **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 <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.
### 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

652
app.js
View File

@@ -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, '&#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) {
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 = `<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);
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 = '<option value="">-- Primero seleccione tipo --</option>';
}
}
// --- 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 => {

View File

@@ -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

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=Material+Icons+Outlined" rel="stylesheet">
<!-- 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>
</head>
<body>
@@ -79,49 +79,176 @@
<div id="tab-movements" class="tab-content">
<div class="section">
<h2>Nuevo Movimiento</h2>
<form id="formMove">
<div class="form-grid">
<label>Cliente:</label>
<div>
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" />
<form id="formMove" class="form-modern">
<!-- Cliente -->
<div class="form-row">
<div class="form-group">
<label>Cliente</label>
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" placeholder="Nombre del cliente" />
<datalist id="client-list"></datalist>
</div>
<label>Categoría:</label>
<select id="m-categoria" required>
<option value="service">Servicio</option>
<option value="course">Curso</option>
</select>
<label>Artículo:</label>
<select id="m-articulo" required>
</select>
<div id="m-subtipo-container" class="hidden">
<label>Subtipo:</label>
<select id="m-subtipo">
<option value="Servicio">Servicio</option>
<option value="Retoque">Retoque</option>
</div>
<!-- Cita -->
<div class="form-section" id="appointment-section">
<h3>Datos de la Cita</h3>
<div class="form-row">
<div class="form-group">
<label>Fecha</label>
<div class="date-time-container">
<input type="number" id="m-cita-dia" min="1" max="31" placeholder="DD" class="date-field" />
<span class="date-separator">/</span>
<input type="number" id="m-cita-mes" min="1" max="12" placeholder="MM" class="date-field" />
<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>
</div>
</div>
</div>
<!-- Venta -->
<div class="form-section">
<h3>Venta</h3>
<div class="products-container">
<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="Tarjeta">Tarjeta</option>
<option value="Transferencia">Transferencia</option>
<option value="Depósito">Depósito</option>
<option value="Giftcard">Giftcard</option>
<option value="Interno">Interno</option>
<option value="Otros">Otros</option>
</select>
</div>
<label>Fecha de Cita:</label>
<input type="date" id="m-fecha-cita" />
<label>Hora de Cita:</label>
<input type="time" id="m-hora-cita" />
<label>Monto (MXN):</label><input type="number" id="m-monto" step="0.01" min="0" required />
<label>Método:</label>
<select id="m-metodo">
<option value="">-- Opcional --</option>
<option value="Efectivo">Efectivo</option>
<option value="Tarjeta">Tarjeta</option>
<option value="Transferencia">Transferencia</option>
<option value="Depósito">Depósito</option>
<option value="Otros">Otros</option>
</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 class="form-group">
<label>Atendió</label>
<input type="text" id="m-staff" readonly />
</div>
</div>
<div class="form-actions">
<button type="submit">Guardar y Generar Recibo</button>
<button type="reset" class="btn-danger">Limpiar</button>
<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>
</form>
</div>
@@ -184,14 +311,14 @@
</div>
<div class="checkbox-container">
<input type="checkbox" id="c-esOncologico" />
<label for="c-esOncologico">¿Es paciente oncológico?</label>
<input type="checkbox" id="c-pacienteOncologico" />
<label for="c-pacienteOncologico">🎗️ Paciente Oncológico</label>
</div>
</div>
<!-- Campos condicionales para paciente oncológico -->
<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="checkbox-container">
<input type="checkbox" id="c-oncologoAprueba" />
@@ -209,7 +336,7 @@
<div class="checkbox-container">
<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>
<p class="data-location-info">
@@ -447,20 +574,11 @@
</main>
<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>
<div id="printArea" class="no-print"></div>
<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>
</html>

View File

@@ -13,18 +13,6 @@ function esc(str) {
}[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.
@@ -33,7 +21,37 @@ function formatDate(dateString) {
* @returns {string} El HTML del ticket.
*/
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 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('<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>`);
if (settings.tagline) lines.push(`<div class="t-center t-tagline">${esc(settings.tagline)}</div>`);
if (settings.rfc) lines.push(`<div class="t-center t-small">RFC: ${esc(settings.rfc)}</div>`);
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>`);
// Información del negocio - verificar estructura de settings
// Extraer datos desde settings o settings.settings (doble anidación)
const businessData = settings?.settings || settings || {};
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-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><span class="t-bold">${esc(tipoServicio)}</span></div>`);
if (mov.client) lines.push(`<div class="t-small">Cliente: ${esc(mov.client.nombre)}</div>`);
if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`);
if (mov.staff) lines.push(`<div class="t-small"><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.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`);
lines.push(`<div class="t-service-title t-bold">${esc(tipoServicio)}</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 t-service-detail">Concepto: ${esc(mov.concepto)}</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 t-service-detail">Método: ${esc(mov.metodo)}</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-row t-bold"><span>Total</span><span>${montoFormateado}</span></div>`);
@@ -72,16 +107,20 @@ function templateTicket(mov, settings) {
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-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-small t-bold">¡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-bold t-center">¡Tu opinión es muy importante!</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('</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>');
return lines.join('');

View File

@@ -21,14 +21,16 @@ app.use(session({
secret: SESSION_SECRET,
resave: 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 ---
// Usar un path que funcione tanto en desarrollo como en Docker
const dbPath = process.env.NODE_ENV === 'production'
? path.join(__dirname, 'data', 'ap-pos.db')
: path.join(__dirname, 'ap-pos.db');
const dbPath = process.env.DB_PATH || (
process.env.NODE_ENV === 'production'
? path.join(__dirname, 'data', 'ap-pos.db')
: path.join(__dirname, 'ap-pos.db')
);
console.log(`Connecting to database at: ${dbPath}`);
const db = new sqlite3.Database(dbPath, (err) => {
@@ -485,8 +487,8 @@ function startServer() {
}
});
// --- Dashboard Route (Admin Only) ---
apiRouter.get('/dashboard', isAdmin, (req, res) => {
// --- Dashboard Route (Authenticated Users) ---
apiRouter.get('/dashboard', isAuthenticated, (req, res) => {
const queries = {
totalIncome: "SELECT SUM(monto) as total FROM movements",
totalMovements: "SELECT COUNT(*) as total FROM movements",

View File

@@ -59,6 +59,400 @@ h3 {
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 {
font-weight: 600;
text-align: right;
@@ -178,7 +572,7 @@ button.action-btn {
color: #000;
padding: 10px;
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 */
}
.t-logo {
@@ -189,43 +583,65 @@ button.action-btn {
}
.t-center { text-align: center; }
.t-bold { font-weight: bold; }
.t-tagline { font-size: 11px; margin-bottom: 6px; }
.t-small { font-size: 10px; }
.t-business-name { font-size: 14px; margin-bottom: 4px; }
.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-row { display: flex; justify-content: space-between; }
.t-row { display: flex; justify-content: space-between; margin-bottom: 2px; }
.t-footer { margin-top: 10px; }
.t-qr-section {
margin-top: 10px;
padding-top: 10px;
margin-top: 12px;
padding-top: 8px;
border-top: 1px dashed #000;
text-align: center;
}
.t-qr-section .t-small {
margin-bottom: 4px;
}
#qr-canvas {
margin: 5px auto;
margin: 8px auto 4px auto;
display: block;
}
/***** MODO IMPRESIÓN *****/
@media print {
body {
background: #fff;
color: #000;
margin: 0;
* {
visibility: hidden;
}
.no-print, .container, .main-footer-credits {
body {
background: #fff !important;
color: #000 !important;
margin: 0 !important;
}
.no-print, .container, .main-footer-credits, .main-header, .main-footer,
.tabs, .tab-content, header, footer, nav, aside {
display: none !important;
visibility: hidden !important;
}
#printArea {
#printArea, #printArea * {
visibility: visible !important;
display: block !important;
}
@page {
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 {
@@ -234,6 +650,15 @@ button.action-btn {
max-width: 100%;
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 --- */
@@ -662,4 +1087,67 @@ table tbody tr:hover {
.footer-info p {
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;
}