mirror of
https://github.com/marcogll/ap_pos.git
synced 2026-01-13 13:15:16 +00:00
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:
95
README.md
95
README.md
@@ -4,27 +4,66 @@ Este es un sistema de Punto de Venta (POS) simple y eficiente, diseñado para ge
|
||||
|
||||
## Características Principales
|
||||
|
||||
- **Dashboard (Solo Admin):** Visualización rápida de estadísticas clave como ingresos totales, número de servicios y gráficos de rendimiento.
|
||||
- **Gestión de Ventas:** Creación de nuevos movimientos (ventas), generación de recibos para impresión y exportación de historial de ventas a formato CSV.
|
||||
- **Gestión de Clientes:** Registro y consulta de clientes, con la posibilidad de ver su expediente completo, incluyendo historial de servicios y cursos.
|
||||
- **Gestión de Productos:** Permite añadir, editar y eliminar tanto servicios como cursos ofrecidos por el negocio.
|
||||
- **Configuración (Solo Admin):**
|
||||
- Ajuste de los datos del negocio para los recibos.
|
||||
- Gestión de credenciales de usuario.
|
||||
- Administración de múltiples usuarios (crear, editar, eliminar).
|
||||
- **Dashboard:** Visualización rápida de estadísticas clave como ingresos totales, número de servicios y gráficos de rendimiento.
|
||||
- **Gestión de Ventas Avanzada:**
|
||||
- **Múltiples productos por venta**: Agregue varios servicios/cursos en una sola transacción
|
||||
- **Sistema de descuentos**: Descuentos por porcentaje o monto fijo con motivo
|
||||
- **Cálculo automático de totales**: Subtotal, descuento y total final en tiempo real
|
||||
- **Programación de citas**: Fecha y hora integradas en el flujo de ventas
|
||||
- **Generación de tickets**: Recibos optimizados para impresión térmica de 58mm
|
||||
- **Exportación a CSV**: Historial completo de ventas exportable
|
||||
- **Gestión de Clientes:** Registro y consulta de clientes, con expediente completo incluyendo historial de servicios y cursos.
|
||||
- **Gestión de Productos:** Administración completa de servicios y cursos con precios actualizables.
|
||||
- **Configuración:**
|
||||
- Ajuste de los datos del negocio para los recibos
|
||||
- Gestión de credenciales de usuario
|
||||
- Administración de múltiples usuarios (crear, editar, eliminar)
|
||||
- **Autenticación:** Sistema de inicio de sesión seguro para proteger el acceso a la información.
|
||||
- **Roles de Usuario:** Perfiles de Administrador (acceso total) y Usuario (acceso limitado a ventas y clientes).
|
||||
- **Roles de Usuario:** Perfiles de Administrador (acceso total) y Usuario (acceso limitado).
|
||||
|
||||
## Despliegue con Docker
|
||||
## Instalación y Despliegue
|
||||
|
||||
### Opción 1: Instalación Local (Desarrollo)
|
||||
|
||||
#### Prerrequisitos
|
||||
- Node.js v18 o superior
|
||||
- npm o yarn
|
||||
|
||||
#### Pasos
|
||||
1. **Clonar el repositorio**:
|
||||
```bash
|
||||
git clone <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
652
app.js
@@ -1,5 +1,5 @@
|
||||
import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js';
|
||||
import { renderTicketAndPrint } from './print.js';
|
||||
import { renderTicketAndPrint } from './print.js?v=1.8';
|
||||
|
||||
// --- UTILITIES ---
|
||||
function escapeHTML(str) {
|
||||
@@ -14,6 +14,448 @@ function escapeHTML(str) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function construirFechaCita() {
|
||||
const dia = document.getElementById('m-cita-dia').value;
|
||||
const mes = document.getElementById('m-cita-mes').value;
|
||||
const año = document.getElementById('m-cita-año').value;
|
||||
|
||||
if (!dia || !mes || !año) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Formatear con ceros a la izquierda
|
||||
const diaStr = dia.padStart(2, '0');
|
||||
const mesStr = mes.padStart(2, '0');
|
||||
|
||||
// Retornar en formato YYYY-MM-DD para compatibilidad
|
||||
return `${año}-${mesStr}-${diaStr}`;
|
||||
}
|
||||
|
||||
// Sistema dinámico de productos y descuentos
|
||||
let selectedProducts = [];
|
||||
let currentSubtotal = 0;
|
||||
let currentDiscount = 0;
|
||||
|
||||
function initializeDynamicSystem() {
|
||||
const articuloSelect = document.getElementById('m-articulo');
|
||||
const categoriaSelect = document.getElementById('m-categoria');
|
||||
const addProductBtn = document.getElementById('add-product-btn');
|
||||
const discountType = document.getElementById('discount-type');
|
||||
const discountValue = document.getElementById('discount-value');
|
||||
const discountReason = document.getElementById('discount-reason');
|
||||
const clienteInput = document.getElementById('m-cliente');
|
||||
|
||||
// Listener para cambio de categoría (servicio/curso)
|
||||
if (categoriaSelect) {
|
||||
categoriaSelect.addEventListener('change', function() {
|
||||
populateArticuloDropdown(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Botón para agregar productos
|
||||
if (addProductBtn) {
|
||||
addProductBtn.addEventListener('click', addCurrentProduct);
|
||||
}
|
||||
|
||||
// Sistema de descuentos colapsable
|
||||
const discountToggle = document.getElementById('discount-toggle');
|
||||
const discountContainer = document.getElementById('discount-container');
|
||||
const discountSymbol = document.getElementById('discount-symbol');
|
||||
|
||||
if (discountToggle && discountContainer) {
|
||||
discountToggle.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
discountContainer.style.display = 'block';
|
||||
// Habilitar campos cuando se abre la sección
|
||||
if (discountType.value) {
|
||||
discountValue.disabled = false;
|
||||
discountReason.disabled = false;
|
||||
}
|
||||
} else {
|
||||
discountContainer.style.display = 'none';
|
||||
// Limpiar y deshabilitar campos cuando se cierra
|
||||
discountType.value = '';
|
||||
discountValue.value = '';
|
||||
discountReason.value = '';
|
||||
discountValue.disabled = true;
|
||||
discountReason.disabled = true;
|
||||
calculateTotals();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (discountType) {
|
||||
discountType.addEventListener('change', function() {
|
||||
const isDiscountSelected = this.value !== '';
|
||||
discountValue.disabled = !isDiscountSelected;
|
||||
discountReason.disabled = !isDiscountSelected;
|
||||
|
||||
// Actualizar símbolo según el tipo
|
||||
if (discountSymbol) {
|
||||
if (this.value === 'percentage') {
|
||||
discountSymbol.textContent = '%';
|
||||
} else if (this.value === 'amount') {
|
||||
discountSymbol.textContent = '$';
|
||||
} else if (this.value === 'warrior') {
|
||||
discountSymbol.textContent = '🎗️';
|
||||
} else {
|
||||
discountSymbol.textContent = '%';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDiscountSelected) {
|
||||
discountValue.value = '';
|
||||
discountReason.value = '';
|
||||
}
|
||||
calculateTotals();
|
||||
});
|
||||
}
|
||||
|
||||
if (discountValue) {
|
||||
discountValue.addEventListener('input', calculateTotals);
|
||||
}
|
||||
|
||||
// Detección automática de pacientes oncológicos para descuento Warrior
|
||||
if (clienteInput) {
|
||||
clienteInput.addEventListener('blur', function() {
|
||||
const clienteNombre = this.value.trim();
|
||||
if (clienteNombre) {
|
||||
const client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase());
|
||||
if (client && client.esOncologico) {
|
||||
// Activar automáticamente el descuento Warrior
|
||||
activateWarriorDiscount();
|
||||
}
|
||||
// Cargar anticipos disponibles del cliente
|
||||
loadClientAnticipos(clienteNombre);
|
||||
} else {
|
||||
// Si no hay cliente, ocultar anticipos
|
||||
document.getElementById('anticipos-section').style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function activateWarriorDiscount() {
|
||||
const discountToggle = document.getElementById('discount-toggle');
|
||||
const discountContainer = document.getElementById('discount-container');
|
||||
const discountType = document.getElementById('discount-type');
|
||||
const discountValue = document.getElementById('discount-value');
|
||||
const discountReason = document.getElementById('discount-reason');
|
||||
|
||||
// Activar la sección de descuentos
|
||||
if (discountToggle && !discountToggle.checked) {
|
||||
discountToggle.checked = true;
|
||||
if (discountContainer) {
|
||||
discountContainer.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Seleccionar descuento Warrior
|
||||
if (discountType) {
|
||||
discountType.value = 'warrior';
|
||||
discountType.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
// Establecer valores automáticamente
|
||||
if (discountValue) {
|
||||
discountValue.value = 100;
|
||||
discountValue.disabled = true;
|
||||
}
|
||||
|
||||
if (discountReason) {
|
||||
discountReason.value = 'Paciente Oncológico';
|
||||
discountReason.disabled = true;
|
||||
}
|
||||
|
||||
// Calcular totales
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
function showDynamicSections() {
|
||||
// Show the product selection area and totals
|
||||
const selectedProducts = document.getElementById('selected-products');
|
||||
const totalsSection = document.querySelector('.totals-section');
|
||||
|
||||
if (selectedProducts) selectedProducts.style.display = 'block';
|
||||
if (totalsSection) totalsSection.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideDynamicSections() {
|
||||
const selectedProductsEl = document.getElementById('selected-products');
|
||||
const totalsSection = document.querySelector('.totals-section');
|
||||
|
||||
if (selectedProductsEl) selectedProductsEl.style.display = 'none';
|
||||
if (totalsSection) totalsSection.style.display = 'none';
|
||||
|
||||
selectedProducts = [];
|
||||
renderSelectedProducts();
|
||||
}
|
||||
|
||||
function addCurrentProduct() {
|
||||
const articuloSelect = document.getElementById('m-articulo');
|
||||
const categoriaSelect = document.getElementById('m-categoria');
|
||||
const quantityInput = document.getElementById('product-quantity');
|
||||
|
||||
if (!categoriaSelect.value) {
|
||||
alert('Selecciona el tipo (servicio, curso o anticipo) primero');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!articuloSelect.value) {
|
||||
alert('Selecciona un producto primero');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantity = parseInt(quantityInput.value) || 1;
|
||||
|
||||
// Manejar anticipos de forma especial
|
||||
if (categoriaSelect.value === 'anticipo') {
|
||||
let anticipoAmount = prompt('Ingresa el monto del anticipo:', '');
|
||||
if (anticipoAmount === null) return; // Usuario canceló
|
||||
|
||||
anticipoAmount = parseFloat(anticipoAmount);
|
||||
if (isNaN(anticipoAmount) || anticipoAmount <= 0) {
|
||||
alert('Por favor ingresa un monto válido para el anticipo');
|
||||
return;
|
||||
}
|
||||
|
||||
const clienteInput = document.getElementById('m-cliente');
|
||||
const clienteName = clienteInput.value.trim();
|
||||
let anticipoName = 'Anticipo';
|
||||
if (clienteName) {
|
||||
anticipoName = `Anticipo - ${clienteName}`;
|
||||
}
|
||||
|
||||
const existingIndex = selectedProducts.findIndex(p => p.name === anticipoName);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
selectedProducts[existingIndex].quantity += quantity;
|
||||
selectedProducts[existingIndex].price += anticipoAmount; // Acumular el monto
|
||||
} else {
|
||||
selectedProducts.push({
|
||||
id: 'anticipo-' + Date.now(),
|
||||
name: anticipoName,
|
||||
price: anticipoAmount,
|
||||
quantity: quantity,
|
||||
type: 'anticipo'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Manejar servicios y cursos como antes
|
||||
const productData = products.find(p => p.name === articuloSelect.value && p.type === categoriaSelect.value);
|
||||
|
||||
if (productData) {
|
||||
const existingIndex = selectedProducts.findIndex(p => p.name === productData.name);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
selectedProducts[existingIndex].quantity += quantity;
|
||||
} else {
|
||||
selectedProducts.push({
|
||||
id: productData.id,
|
||||
name: productData.name,
|
||||
price: parseFloat(productData.price),
|
||||
quantity: quantity,
|
||||
type: categoriaSelect.value
|
||||
});
|
||||
}
|
||||
} else {
|
||||
alert('Producto no encontrado');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderSelectedProducts();
|
||||
calculateTotals();
|
||||
quantityInput.value = 1;
|
||||
articuloSelect.value = '';
|
||||
|
||||
// Mostrar descuento inmediatamente
|
||||
showDiscountSection();
|
||||
}
|
||||
|
||||
function removeProduct(productName) {
|
||||
selectedProducts = selectedProducts.filter(p => p.name !== productName);
|
||||
renderSelectedProducts();
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
function renderSelectedProducts() {
|
||||
const container = document.getElementById('selected-products');
|
||||
if (!container) return;
|
||||
|
||||
if (selectedProducts.length === 0) {
|
||||
container.innerHTML = '<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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
224
index.html
224
index.html
@@ -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>
|
||||
99
print.js
99
print.js
@@ -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,17 +107,21 @@ 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('');
|
||||
}
|
||||
|
||||
14
server.js
14
server.js
@@ -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",
|
||||
|
||||
514
styles.css
514
styles.css
@@ -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 {
|
||||
* {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
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;
|
||||
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 --- */
|
||||
@@ -663,3 +1088,66 @@ 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;
|
||||
}
|
||||
Reference in New Issue
Block a user