mirror of
https://github.com/marcogll/ap_pos.git
synced 2026-01-13 21:25: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
|
## Características Principales
|
||||||
|
|
||||||
- **Dashboard (Solo Admin):** Visualización rápida de estadísticas clave como ingresos totales, número de servicios y gráficos de rendimiento.
|
- **Dashboard:** Visualización rápida de estadísticas clave como ingresos totales, número de servicios y gráficos de rendimiento.
|
||||||
- **Gestión de Ventas:** Creación de nuevos movimientos (ventas), generación de recibos para impresión y exportación de historial de ventas a formato CSV.
|
- **Gestión de Ventas Avanzada:**
|
||||||
- **Gestión de Clientes:** Registro y consulta de clientes, con la posibilidad de ver su expediente completo, incluyendo historial de servicios y cursos.
|
- **Múltiples productos por venta**: Agregue varios servicios/cursos en una sola transacción
|
||||||
- **Gestión de Productos:** Permite añadir, editar y eliminar tanto servicios como cursos ofrecidos por el negocio.
|
- **Sistema de descuentos**: Descuentos por porcentaje o monto fijo con motivo
|
||||||
- **Configuración (Solo Admin):**
|
- **Cálculo automático de totales**: Subtotal, descuento y total final en tiempo real
|
||||||
- Ajuste de los datos del negocio para los recibos.
|
- **Programación de citas**: Fecha y hora integradas en el flujo de ventas
|
||||||
- Gestión de credenciales de usuario.
|
- **Generación de tickets**: Recibos optimizados para impresión térmica de 58mm
|
||||||
- Administración de múltiples usuarios (crear, editar, eliminar).
|
- **Exportación a CSV**: Historial completo de ventas exportable
|
||||||
|
- **Gestión de Clientes:** Registro y consulta de clientes, con expediente completo incluyendo historial de servicios y cursos.
|
||||||
|
- **Gestión de Productos:** Administración completa de servicios y cursos con precios actualizables.
|
||||||
|
- **Configuración:**
|
||||||
|
- Ajuste de los datos del negocio para los recibos
|
||||||
|
- Gestión de credenciales de usuario
|
||||||
|
- Administración de múltiples usuarios (crear, editar, eliminar)
|
||||||
- **Autenticación:** Sistema de inicio de sesión seguro para proteger el acceso a la información.
|
- **Autenticación:** Sistema de inicio de sesión seguro para proteger el acceso a la información.
|
||||||
- **Roles de Usuario:** Perfiles de Administrador (acceso total) y Usuario (acceso limitado a ventas y clientes).
|
- **Roles de Usuario:** Perfiles de Administrador (acceso total) y Usuario (acceso limitado).
|
||||||
|
|
||||||
## Despliegue con Docker
|
## Instalación y Despliegue
|
||||||
|
|
||||||
|
### Opción 1: Instalación Local (Desarrollo)
|
||||||
|
|
||||||
|
#### Prerrequisitos
|
||||||
|
- Node.js v18 o superior
|
||||||
|
- npm o yarn
|
||||||
|
|
||||||
|
#### Pasos
|
||||||
|
1. **Clonar el repositorio**:
|
||||||
|
```bash
|
||||||
|
git clone <url-del-repositorio>
|
||||||
|
cd ap_pos
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Instalar dependencias**:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Ejecutar la aplicación**:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Acceder a la aplicación**:
|
||||||
|
- URL: `http://localhost:3111`
|
||||||
|
- En la primera ejecución serás redirigido a `/setup.html` para crear el usuario administrador
|
||||||
|
|
||||||
|
#### Base de datos
|
||||||
|
- Se crea automáticamente un archivo SQLite (`ap-pos.db`) en el directorio raíz
|
||||||
|
- Los datos se mantienen localmente en este archivo
|
||||||
|
|
||||||
|
### Opción 2: Despliegue con Docker
|
||||||
|
|
||||||
El sistema está diseñado para ser desplegado fácilmente utilizando Docker y Docker Compose, asegurando un entorno consistente y aislado.
|
El sistema está diseñado para ser desplegado fácilmente utilizando Docker y Docker Compose, asegurando un entorno consistente y aislado.
|
||||||
|
|
||||||
### Prerrequisitos
|
#### Prerrequisitos
|
||||||
|
|
||||||
- Tener instalado [Docker](https://docs.docker.com/get-docker/)
|
- Tener instalado [Docker](https://docs.docker.com/get-docker/)
|
||||||
- Tener instalado [Docker Compose](https://docs.docker.com/compose/install/)
|
- Tener instalado [Docker Compose](https://docs.docker.com/compose/install/)
|
||||||
|
|
||||||
### Pasos para el despliegue
|
#### Pasos para el despliegue
|
||||||
|
|
||||||
1. **Clona o descarga** este repositorio en tu máquina local.
|
1. **Clona o descarga** este repositorio en tu máquina local.
|
||||||
|
|
||||||
@@ -49,8 +88,38 @@ El sistema está diseñado para ser desplegado fácilmente utilizando Docker y D
|
|||||||
- URL: `http://localhost:3111`
|
- URL: `http://localhost:3111`
|
||||||
- En la primera ejecución serás redirigido a `/setup.html` para crear el usuario administrador
|
- En la primera ejecución serás redirigido a `/setup.html` para crear el usuario administrador
|
||||||
|
|
||||||
### Persistencia de datos
|
#### Persistencia de datos
|
||||||
|
|
||||||
- La base de datos SQLite se almacena en un volumen Docker persistente
|
- La base de datos SQLite se almacena en un volumen Docker persistente
|
||||||
- Los datos se mantienen entre reinicios y actualizaciones del contenedor
|
- Los datos se mantienen entre reinicios y actualizaciones del contenedor
|
||||||
- Para más información sobre Docker, consulta [DOCKER.md](./DOCKER.md)
|
- Para más información sobre Docker, consulta [DOCKER.md](./DOCKER.md)
|
||||||
|
|
||||||
|
## Novedades de la Versión 1.3.5
|
||||||
|
|
||||||
|
### 🚀 **Nueva Interfaz de Ventas**
|
||||||
|
- **Formulario modernizado**: Diseño más intuitivo y profesional
|
||||||
|
- **Múltiples productos**: Agrega varios servicios/cursos en una sola venta
|
||||||
|
- **Sistema de cantidades**: Especifica la cantidad de cada producto
|
||||||
|
|
||||||
|
### 💰 **Sistema de Descuentos Avanzado**
|
||||||
|
- **Interfaz colapsable**: Sección de descuentos elegante y fácil de usar
|
||||||
|
- **Dos tipos de descuento**: Por porcentaje (%) o monto fijo ($)
|
||||||
|
- **Motivo del descuento**: Registro del motivo para auditoría
|
||||||
|
- **Preview en tiempo real**: Ve el descuento aplicado instantáneamente
|
||||||
|
|
||||||
|
### 📅 **Gestión de Citas Mejorada**
|
||||||
|
- **Campos de fecha intuitivos**: DD/MM/AAAA más fácil de usar
|
||||||
|
- **Horarios preconfigurados**: Selección rápida de horas disponibles
|
||||||
|
- **Integración con ventas**: Cita programada directamente al crear la venta
|
||||||
|
|
||||||
|
### 🧾 **Tickets Optimizados**
|
||||||
|
- **Formato térmico 58mm**: Diseño específico para impresoras térmicas
|
||||||
|
- **Información completa**: Productos, cantidades, descuentos y totales
|
||||||
|
- **QR Code**: Para feedback de clientes
|
||||||
|
- **Fechas corregidas**: Formato de fecha y hora preciso
|
||||||
|
|
||||||
|
### ⚡ **Mejoras Técnicas**
|
||||||
|
- **Cálculos en tiempo real**: Totales actualizados automáticamente
|
||||||
|
- **Validaciones mejoradas**: Mejor control de errores
|
||||||
|
- **Base de datos optimizada**: Persistencia de datos mejorada
|
||||||
|
- **API REST**: Migración completa de localStorage a servidor
|
||||||
|
|||||||
652
app.js
652
app.js
@@ -1,5 +1,5 @@
|
|||||||
import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js';
|
import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js';
|
||||||
import { renderTicketAndPrint } from './print.js';
|
import { renderTicketAndPrint } from './print.js?v=1.8';
|
||||||
|
|
||||||
// --- UTILITIES ---
|
// --- UTILITIES ---
|
||||||
function escapeHTML(str) {
|
function escapeHTML(str) {
|
||||||
@@ -14,6 +14,448 @@ function escapeHTML(str) {
|
|||||||
.replace(/'/g, ''');
|
.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) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@@ -73,7 +515,7 @@ let isDashboardLoading = false;
|
|||||||
// --- LÓGICA DE NEGOCIO ---
|
// --- LÓGICA DE NEGOCIO ---
|
||||||
|
|
||||||
async function loadDashboardData() {
|
async function loadDashboardData() {
|
||||||
if (currentUser.role !== 'admin' || isDashboardLoading) {
|
if (isDashboardLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isDashboardLoading = true;
|
isDashboardLoading = true;
|
||||||
@@ -144,16 +586,38 @@ function generateFolio() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addMovement(mov) {
|
async function addMovement(mov) {
|
||||||
await save('movements', { movement: mov });
|
try {
|
||||||
movements.unshift(mov);
|
const response = await fetch('/api/movements', {
|
||||||
renderTable();
|
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) {
|
async function deleteMovement(id) {
|
||||||
if (confirm('¿Estás seguro de que quieres eliminar este movimiento?')) {
|
if (confirm('¿Estás seguro de que quieres eliminar este movimiento?')) {
|
||||||
await remove(KEY_DATA, id);
|
try {
|
||||||
movements = movements.filter(m => m.id !== id);
|
const response = await fetch(`/api/movements/${id}`, { method: 'DELETE' });
|
||||||
renderTable();
|
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,
|
genero: document.getElementById('c-genero').value,
|
||||||
cumpleaños: document.getElementById('c-cumple').value,
|
cumpleaños: document.getElementById('c-cumple').value,
|
||||||
consentimiento: document.getElementById('c-consent').checked,
|
consentimiento: document.getElementById('c-consent').checked,
|
||||||
esOncologico: document.getElementById('c-esOncologico').checked,
|
esOncologico: document.getElementById('c-pacienteOncologico').checked,
|
||||||
oncologoAprueba: document.getElementById('c-oncologoAprueba').checked,
|
oncologoAprueba: document.getElementById('c-oncologoAprueba').checked,
|
||||||
nombreMedico: document.getElementById('c-nombreMedico').value,
|
nombreMedico: document.getElementById('c-nombreMedico').value,
|
||||||
telefonoMedico: document.getElementById('c-telefonoMedico').value,
|
telefonoMedico: document.getElementById('c-telefonoMedico').value,
|
||||||
@@ -183,7 +647,20 @@ async function saveClient(clientData) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await save('clients', { client: clientToSave });
|
try {
|
||||||
|
const response = await fetch('/api/clients', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ client: clientToSave })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save client');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving client:', error);
|
||||||
|
alert('Error al guardar el cliente');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
const index = clients.findIndex(c => c.id === clientToSave.id);
|
const index = clients.findIndex(c => c.id === clientToSave.id);
|
||||||
@@ -204,11 +681,20 @@ async function saveClient(clientData) {
|
|||||||
|
|
||||||
async function deleteClient(id) {
|
async function deleteClient(id) {
|
||||||
if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) {
|
if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) {
|
||||||
await remove(KEY_CLIENTS, id);
|
try {
|
||||||
clients = clients.filter(c => c.id !== id);
|
const response = await fetch(`/api/clients/${id}`, { method: 'DELETE' });
|
||||||
renderClientsTable();
|
if (response.ok) {
|
||||||
updateClientDatalist();
|
clients = clients.filter(c => c.id !== id);
|
||||||
clearClientRecord();
|
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) {
|
function populateArticuloDropdown(category) {
|
||||||
const articuloSelect = document.getElementById('m-articulo');
|
const articuloSelect = document.getElementById('m-articulo');
|
||||||
if (!articuloSelect) return;
|
if (!articuloSelect) return;
|
||||||
articuloSelect.innerHTML = '';
|
|
||||||
const items = products.filter(p => p.type === category);
|
// Clear existing options except the first default option
|
||||||
items.forEach(i => {
|
if (category) {
|
||||||
const option = document.createElement('option');
|
let placeholder = '';
|
||||||
option.value = i.name;
|
if (category === 'service') placeholder = 'servicio';
|
||||||
option.textContent = i.name;
|
else if (category === 'course') placeholder = 'curso';
|
||||||
articuloSelect.appendChild(option);
|
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 ---
|
// --- MANEJADORES DE EVENTOS ---
|
||||||
@@ -393,8 +899,22 @@ async function handleSaveSettings(e) {
|
|||||||
settings.tel = document.getElementById('s-tel').value;
|
settings.tel = document.getElementById('s-tel').value;
|
||||||
settings.leyenda = document.getElementById('s-leyenda').value;
|
settings.leyenda = document.getElementById('s-leyenda').value;
|
||||||
settings.folioPrefix = document.getElementById('s-folioPrefix').value;
|
settings.folioPrefix = document.getElementById('s-folioPrefix').value;
|
||||||
await save(KEY_SETTINGS, { settings });
|
|
||||||
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) {
|
async function handleSaveCredentials(e) {
|
||||||
@@ -651,6 +1171,11 @@ async function handleNewMovement(e) {
|
|||||||
const monto = parseFloat(document.getElementById('m-monto').value || 0);
|
const monto = parseFloat(document.getElementById('m-monto').value || 0);
|
||||||
const clienteNombre = document.getElementById('m-cliente').value;
|
const clienteNombre = document.getElementById('m-cliente').value;
|
||||||
|
|
||||||
|
if (selectedProducts.length === 0) {
|
||||||
|
alert('Por favor selecciona al menos un producto o servicio');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase());
|
let client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase());
|
||||||
if (!client) {
|
if (!client) {
|
||||||
if (confirm(`El cliente "${clienteNombre}" no existe. ¿Deseas crearlo?`)) {
|
if (confirm(`El cliente "${clienteNombre}" no existe. ¿Deseas crearlo?`)) {
|
||||||
@@ -668,27 +1193,41 @@ async function handleNewMovement(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build concept from selected products
|
||||||
|
const concepto = selectedProducts.map(p => `${p.name} (${p.quantity}x)`).join(', ');
|
||||||
|
|
||||||
const newMovement = {
|
const newMovement = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
folio: generateFolio(),
|
folio: generateFolio(),
|
||||||
fechaISO: new Date().toISOString(),
|
fechaISO: new Date().toISOString(),
|
||||||
clienteId: client.id,
|
clienteId: client.id,
|
||||||
tipo: document.getElementById('m-categoria').value,
|
tipo: selectedProducts.length > 0 ? selectedProducts[0].type : 'service',
|
||||||
subtipo: '',
|
subtipo: '',
|
||||||
monto: Number(monto.toFixed(2)),
|
monto: Number(monto.toFixed(2)),
|
||||||
metodo: document.getElementById('m-metodo').value,
|
metodo: document.getElementById('m-metodo').value,
|
||||||
concepto: document.getElementById('m-articulo').value,
|
concepto: concepto,
|
||||||
staff: currentUser.name,
|
staff: currentUser.name,
|
||||||
notas: document.getElementById('m-notas').value,
|
notas: document.getElementById('m-notas').value,
|
||||||
fechaCita: document.getElementById('m-fecha-cita').value,
|
fechaCita: construirFechaCita(),
|
||||||
horaCita: document.getElementById('m-hora-cita').value,
|
horaCita: document.getElementById('m-hora-cita').value,
|
||||||
|
productos: selectedProducts, // Store product details for ticket
|
||||||
|
descuento: currentDiscount,
|
||||||
|
subtotal: currentSubtotal
|
||||||
};
|
};
|
||||||
|
|
||||||
await addMovement(newMovement);
|
await addMovement(newMovement);
|
||||||
renderTicketAndPrint({ ...newMovement, client }, settings);
|
renderTicketAndPrint({ ...newMovement, client }, settings);
|
||||||
|
|
||||||
|
// Reset form and clear products
|
||||||
form.reset();
|
form.reset();
|
||||||
|
selectedProducts = [];
|
||||||
|
currentSubtotal = 0;
|
||||||
|
currentDiscount = 0;
|
||||||
|
renderSelectedProducts();
|
||||||
|
calculateTotals();
|
||||||
|
hideDynamicSections();
|
||||||
|
|
||||||
document.getElementById('m-cliente').focus();
|
document.getElementById('m-cliente').focus();
|
||||||
subtipoContainer.classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportClientHistoryCSV(client, history) {
|
function exportClientHistoryCSV(client, history) {
|
||||||
@@ -915,7 +1454,7 @@ function activateTab(tabId) {
|
|||||||
tabContent.classList.add('active');
|
tabContent.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabId === 'tab-dashboard' && currentUser.role === 'admin') {
|
if (tabId === 'tab-dashboard') {
|
||||||
if (!incomeChart) {
|
if (!incomeChart) {
|
||||||
const ctx = document.getElementById('incomeChart').getContext('2d');
|
const ctx = document.getElementById('incomeChart').getContext('2d');
|
||||||
incomeChart = new Chart(ctx, {
|
incomeChart = new Chart(ctx, {
|
||||||
@@ -1015,8 +1554,8 @@ function setupUIForRole(role) {
|
|||||||
})
|
})
|
||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
} else {
|
} else {
|
||||||
if (dashboardTab) dashboardTab.style.display = 'none';
|
if (dashboardTab) dashboardTab.style.display = 'block';
|
||||||
if (settingsTab) settingsTab.style.display = 'none';
|
if (settingsTab) settingsTab.style.display = 'block';
|
||||||
if (userManagementSection) userManagementSection.style.display = 'none';
|
if (userManagementSection) userManagementSection.style.display = 'none';
|
||||||
if (dbInfoIcon) dbInfoIcon.style.display = 'none';
|
if (dbInfoIcon) dbInfoIcon.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -1027,18 +1566,13 @@ function setupUIForRole(role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateFooter() {
|
function populateFooter() {
|
||||||
const dateElement = document.getElementById('footer-date');
|
// Footer elements removed - no longer needed
|
||||||
const versionElement = document.getElementById('footer-version');
|
|
||||||
|
|
||||||
if (dateElement) {
|
|
||||||
dateElement.textContent = formatDate(new Date().toISOString());
|
|
||||||
}
|
|
||||||
if (versionElement) {
|
|
||||||
versionElement.textContent = `Versión ${APP_VERSION}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Make removeProduct globally accessible
|
||||||
|
window.removeProduct = removeProduct;
|
||||||
|
|
||||||
// --- INICIALIZACIÓN ---
|
// --- INICIALIZACIÓN ---
|
||||||
|
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
@@ -1106,9 +1640,12 @@ async function initializeApp() {
|
|||||||
document.getElementById('oncologico-fields').classList.add('hidden');
|
document.getElementById('oncologico-fields').classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('c-esOncologico')?.addEventListener('change', (e) => {
|
document.getElementById('c-pacienteOncologico')?.addEventListener('change', (e) => {
|
||||||
const oncologicoFields = document.getElementById('oncologico-fields');
|
const oncologicoFields = document.getElementById('oncologico-fields');
|
||||||
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) => {
|
btnCancelEditUser?.addEventListener('click', (e) => {
|
||||||
@@ -1150,10 +1687,15 @@ async function initializeApp() {
|
|||||||
document.getElementById('c-cumple').value = client.cumpleaños;
|
document.getElementById('c-cumple').value = client.cumpleaños;
|
||||||
document.getElementById('c-consent').checked = client.consentimiento;
|
document.getElementById('c-consent').checked = client.consentimiento;
|
||||||
|
|
||||||
const esOncologicoCheckbox = document.getElementById('c-esOncologico');
|
const esOncologicoCheckbox = document.getElementById('c-pacienteOncologico');
|
||||||
const oncologicoFields = document.getElementById('oncologico-fields');
|
const oncologicoFields = document.getElementById('oncologico-fields');
|
||||||
esOncologicoCheckbox.checked = client.esOncologico;
|
if (esOncologicoCheckbox) {
|
||||||
oncologicoFields.classList.toggle('hidden', !client.esOncologico);
|
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-oncologoAprueba').checked = client.oncologoAprueba;
|
||||||
document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
|
document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
|
||||||
@@ -1180,10 +1722,10 @@ async function initializeApp() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
load(KEY_SETTINGS, DEFAULT_SETTINGS),
|
fetch('/api/settings').then(res => res.json()).catch(() => DEFAULT_SETTINGS),
|
||||||
load(KEY_DATA, []),
|
fetch('/api/movements').then(res => res.json()).catch(() => []),
|
||||||
load(KEY_CLIENTS, []),
|
fetch('/api/clients').then(res => res.json()).catch(() => []),
|
||||||
fetch('/api/products').then(res => res.json()),
|
fetch('/api/products').then(res => res.json()).catch(() => []),
|
||||||
]).then(values => {
|
]).then(values => {
|
||||||
console.log('Initial data loaded:', values);
|
console.log('Initial data loaded:', values);
|
||||||
[settings, movements, clients, products] = values;
|
[settings, movements, clients, products] = values;
|
||||||
@@ -1198,7 +1740,7 @@ async function initializeApp() {
|
|||||||
renderProductTables();
|
renderProductTables();
|
||||||
console.log('Updating client datalist...');
|
console.log('Updating client datalist...');
|
||||||
updateClientDatalist();
|
updateClientDatalist();
|
||||||
populateArticuloDropdown(document.getElementById('m-categoria').value);
|
populateArticuloDropdown('');
|
||||||
|
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
console.log('Setting user info in form...');
|
console.log('Setting user info in form...');
|
||||||
@@ -1211,11 +1753,7 @@ async function initializeApp() {
|
|||||||
setupUIForRole(currentUser.role);
|
setupUIForRole(currentUser.role);
|
||||||
|
|
||||||
console.log('Activating initial tab...');
|
console.log('Activating initial tab...');
|
||||||
if (currentUser.role === 'admin') {
|
activateTab('tab-dashboard');
|
||||||
activateTab('tab-dashboard');
|
|
||||||
} else {
|
|
||||||
activateTab('tab-movements');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Activating client sub-tab...');
|
console.log('Activating client sub-tab...');
|
||||||
activateClientSubTab('sub-tab-register');
|
activateClientSubTab('sub-tab-register');
|
||||||
@@ -1223,6 +1761,8 @@ async function initializeApp() {
|
|||||||
clearClientRecord();
|
clearClientRecord();
|
||||||
console.log('Populating footer...');
|
console.log('Populating footer...');
|
||||||
populateFooter();
|
populateFooter();
|
||||||
|
console.log('Initializing dynamic system...');
|
||||||
|
initializeDynamicSystem();
|
||||||
console.log('Initialization complete.');
|
console.log('Initialization complete.');
|
||||||
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
ap-pos:
|
ap-pos:
|
||||||
image: marcogll/ap_pos:latest
|
image: coderk/ap_pos:1.3.5
|
||||||
container_name: ap-pos
|
container_name: ap-pos
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
SESSION_SECRET: ${SESSION_SECRET:-your-very-secret-key-change-it-in-production}
|
SESSION_SECRET: ${SESSION_SECRET:-your-very-secret-key-change-it-in-production}
|
||||||
DB_PATH: /app/data/ap-pos.db
|
DB_PATH: /app/data/ap-pos.db
|
||||||
volumes:
|
volumes:
|
||||||
- ap_pos_data:/app/data
|
- ./data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3111/login.html"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3111/login.html"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -18,10 +18,4 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
volumes:
|
# volumes section no longer needed - using direct bind mount
|
||||||
ap_pos_data:
|
|
||||||
driver: local
|
|
||||||
driver_opts:
|
|
||||||
type: none
|
|
||||||
o: bind
|
|
||||||
device: ./data
|
|
||||||
|
|||||||
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=Montserrat:wght@500;600&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Icons+Outlined" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Material+Icons+Outlined" rel="stylesheet">
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<link rel="stylesheet" href="styles.css?v=1.4" />
|
<link rel="stylesheet" href="styles.css?v=1.8" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -79,49 +79,176 @@
|
|||||||
<div id="tab-movements" class="tab-content">
|
<div id="tab-movements" class="tab-content">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Nuevo Movimiento</h2>
|
<h2>Nuevo Movimiento</h2>
|
||||||
<form id="formMove">
|
<form id="formMove" class="form-modern">
|
||||||
<div class="form-grid">
|
<!-- Cliente -->
|
||||||
<label>Cliente:</label>
|
<div class="form-row">
|
||||||
<div>
|
<div class="form-group">
|
||||||
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" />
|
<label>Cliente</label>
|
||||||
|
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" placeholder="Nombre del cliente" />
|
||||||
<datalist id="client-list"></datalist>
|
<datalist id="client-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<label>Categoría:</label>
|
</div>
|
||||||
<select id="m-categoria" required>
|
|
||||||
<option value="service">Servicio</option>
|
<!-- Cita -->
|
||||||
<option value="course">Curso</option>
|
<div class="form-section" id="appointment-section">
|
||||||
</select>
|
<h3>Datos de la Cita</h3>
|
||||||
<label>Artículo:</label>
|
<div class="form-row">
|
||||||
<select id="m-articulo" required>
|
<div class="form-group">
|
||||||
</select>
|
<label>Fecha</label>
|
||||||
<div id="m-subtipo-container" class="hidden">
|
<div class="date-time-container">
|
||||||
<label>Subtipo:</label>
|
<input type="number" id="m-cita-dia" min="1" max="31" placeholder="DD" class="date-field" />
|
||||||
<select id="m-subtipo">
|
<span class="date-separator">/</span>
|
||||||
<option value="Servicio">Servicio</option>
|
<input type="number" id="m-cita-mes" min="1" max="12" placeholder="MM" class="date-field" />
|
||||||
<option value="Retoque">Retoque</option>
|
<span class="date-separator">/</span>
|
||||||
|
<input type="number" id="m-cita-año" min="2024" max="2030" placeholder="AAAA" class="date-field-year" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hora</label>
|
||||||
|
<select id="m-hora-cita" class="time-select">
|
||||||
|
<option value="">-- Seleccionar hora --</option>
|
||||||
|
<option value="10:00">10:00 AM</option>
|
||||||
|
<option value="10:30">10:30 AM</option>
|
||||||
|
<option value="11:00">11:00 AM</option>
|
||||||
|
<option value="11:30">11:30 AM</option>
|
||||||
|
<option value="12:00">12:00 PM</option>
|
||||||
|
<option value="12:30">12:30 PM</option>
|
||||||
|
<option value="13:00">1:00 PM</option>
|
||||||
|
<option value="13:30">1:30 PM</option>
|
||||||
|
<option value="14:00">2:00 PM</option>
|
||||||
|
<option value="14:30">2:30 PM</option>
|
||||||
|
<option value="15:00">3:00 PM</option>
|
||||||
|
<option value="15:30">3:30 PM</option>
|
||||||
|
<option value="16:00">4:00 PM</option>
|
||||||
|
<option value="16:30">4:30 PM</option>
|
||||||
|
<option value="17:00">5:00 PM</option>
|
||||||
|
<option value="17:30">5:30 PM</option>
|
||||||
|
<option value="18:00">6:00 PM</option>
|
||||||
|
</select>
|
||||||
|
</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label>Fecha de Cita:</label>
|
<div class="form-group">
|
||||||
<input type="date" id="m-fecha-cita" />
|
<label>Atendió</label>
|
||||||
<label>Hora de Cita:</label>
|
<input type="text" id="m-staff" readonly />
|
||||||
<input type="time" id="m-hora-cita" />
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit">Guardar y Generar Recibo</button>
|
<div class="form-group full-width">
|
||||||
<button type="reset" class="btn-danger">Limpiar</button>
|
<label>Notas</label>
|
||||||
|
<textarea id="m-notas" rows="2" placeholder="Notas adicionales..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totales -->
|
||||||
|
<div class="totals-section">
|
||||||
|
<div class="totals-row">
|
||||||
|
<span>Subtotal:</span>
|
||||||
|
<span id="subtotal-display">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="totals-row" id="discount-display" style="display: none;">
|
||||||
|
<span>Descuento:</span>
|
||||||
|
<span id="discount-amount-display">-$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="totals-row total-final">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span id="total-display">$0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Campos ocultos para compatibilidad -->
|
||||||
|
<input type="hidden" id="m-monto" />
|
||||||
|
<input type="hidden" id="m-concepto" />
|
||||||
|
|
||||||
|
<div class="form-actions-modern">
|
||||||
|
<button type="submit" class="btn-primary-large">Generar Venta y Ticket</button>
|
||||||
|
<button type="reset" class="btn-secondary-large">Limpiar Formulario</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,14 +311,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="checkbox-container">
|
<div class="checkbox-container">
|
||||||
<input type="checkbox" id="c-esOncologico" />
|
<input type="checkbox" id="c-pacienteOncologico" />
|
||||||
<label for="c-esOncologico">¿Es paciente oncológico?</label>
|
<label for="c-pacienteOncologico">🎗️ Paciente Oncológico</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Campos condicionales para paciente oncológico -->
|
<!-- Campos condicionales para paciente oncológico -->
|
||||||
<div id="oncologico-fields" class="sub-section hidden">
|
<div id="oncologico-fields" class="sub-section hidden">
|
||||||
<h3>Información Oncológica</h3>
|
<h3>📋 Información Médica Oncológica</h3>
|
||||||
<div class="form-grid-single">
|
<div class="form-grid-single">
|
||||||
<div class="checkbox-container">
|
<div class="checkbox-container">
|
||||||
<input type="checkbox" id="c-oncologoAprueba" />
|
<input type="checkbox" id="c-oncologoAprueba" />
|
||||||
@@ -209,7 +336,7 @@
|
|||||||
|
|
||||||
<div class="checkbox-container">
|
<div class="checkbox-container">
|
||||||
<input type="checkbox" id="c-pruebaAprobacion" />
|
<input type="checkbox" id="c-pruebaAprobacion" />
|
||||||
<label for="c-pruebaAprobacion">¿Presenta prueba de aprobación?</label>
|
<label for="c-pruebaAprobacion">🎗️ Presenta autorización médica explícita firmada</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="data-location-info">
|
<p class="data-location-info">
|
||||||
@@ -447,20 +574,11 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="main-footer">
|
<footer class="main-footer">
|
||||||
<div class="footer-logos">
|
|
||||||
<img src="src/logo_dev.png" alt="Marco Gallegos">
|
|
||||||
<img src="src/logo_gemini.png" alt="Google Gemini">
|
|
||||||
</div>
|
|
||||||
<div class="footer-info">
|
|
||||||
<p>Marco Gallegos | Creado con Google Gemini ®</p>
|
|
||||||
<p id="footer-date"></p>
|
|
||||||
<p id="footer-version"></p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div id="printArea" class="no-print"></div>
|
<div id="printArea" class="no-print"></div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
|
||||||
<script type="module" src="app.js?v=1.3"></script>
|
<script type="module" src="app.js?v=1.8"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
99
print.js
99
print.js
@@ -13,18 +13,6 @@ function esc(str) {
|
|||||||
}[c]));
|
}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString) {
|
|
||||||
if (!dateString) return '';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
|
|
||||||
const adjustedDate = new Date(date.getTime() + userTimezoneOffset);
|
|
||||||
const day = String(adjustedDate.getDate()).padStart(2, '0');
|
|
||||||
const month = String(adjustedDate.getMonth() + 1).padStart(2, '0');
|
|
||||||
const year = adjustedDate.getFullYear();
|
|
||||||
const hours = String(adjustedDate.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(adjustedDate.getMinutes()).padStart(2, '0');
|
|
||||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Genera el HTML para un ticket de movimiento.
|
* Genera el HTML para un ticket de movimiento.
|
||||||
@@ -33,7 +21,37 @@ function formatDate(dateString) {
|
|||||||
* @returns {string} El HTML del ticket.
|
* @returns {string} El HTML del ticket.
|
||||||
*/
|
*/
|
||||||
function templateTicket(mov, settings) {
|
function templateTicket(mov, settings) {
|
||||||
const fechaLocal = formatDate(mov.fechaISO || Date.now());
|
// Función de fecha EXCLUSIVA para tickets - no depende de nada más
|
||||||
|
function fechaParaTicketSolamente() {
|
||||||
|
console.log('>>> EJECUTANDO fechaParaTicketSolamente()');
|
||||||
|
|
||||||
|
// Crear fecha con zona horaria México directamente
|
||||||
|
const fechaObj = new Date();
|
||||||
|
console.log('>>> Objeto Date original:', fechaObj);
|
||||||
|
|
||||||
|
// Obtener fecha en zona horaria México (UTC-6)
|
||||||
|
const fechaMexico = new Date(fechaObj.getTime() - (6 * 60 * 60 * 1000));
|
||||||
|
console.log('>>> Fecha México:', fechaMexico);
|
||||||
|
|
||||||
|
// Obtener cada parte por separado
|
||||||
|
const año = fechaMexico.getUTCFullYear();
|
||||||
|
const mes = fechaMexico.getUTCMonth() + 1;
|
||||||
|
const día = fechaMexico.getUTCDate();
|
||||||
|
const hora = fechaMexico.getUTCHours();
|
||||||
|
const minuto = fechaMexico.getUTCMinutes();
|
||||||
|
|
||||||
|
// Formatear cada número manualmente
|
||||||
|
const dStr = día.toString().padStart(2, '0');
|
||||||
|
const mStr = mes.toString().padStart(2, '0');
|
||||||
|
const hStr = hora.toString().padStart(2, '0');
|
||||||
|
const minStr = minuto.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
const fechaFinal = `${dStr}/${mStr}/${año} ${hStr}:${minStr}`;
|
||||||
|
console.log('>>> Fecha final:', fechaFinal);
|
||||||
|
|
||||||
|
return fechaFinal;
|
||||||
|
}
|
||||||
|
|
||||||
const montoFormateado = Number(mov.monto).toFixed(2);
|
const montoFormateado = Number(mov.monto).toFixed(2);
|
||||||
const tipoServicio = mov.subtipo === 'Retoque' ? `Retoque de ${mov.tipo}` : mov.tipo;
|
const tipoServicio = mov.subtipo === 'Retoque' ? `Retoque de ${mov.tipo}` : mov.tipo;
|
||||||
|
|
||||||
@@ -41,23 +59,40 @@ function templateTicket(mov, settings) {
|
|||||||
lines.push('<div class="ticket">');
|
lines.push('<div class="ticket">');
|
||||||
lines.push('<img src="src/logo.png" alt="Logo" class="t-logo">');
|
lines.push('<img src="src/logo.png" alt="Logo" class="t-logo">');
|
||||||
|
|
||||||
if (settings.negocio) lines.push(`<div class="t-center t-bold">${esc(settings.negocio)}</div>`);
|
// Información del negocio - verificar estructura de settings
|
||||||
if (settings.tagline) lines.push(`<div class="t-center t-tagline">${esc(settings.tagline)}</div>`);
|
// Extraer datos desde settings o settings.settings (doble anidación)
|
||||||
if (settings.rfc) lines.push(`<div class="t-center t-small">RFC: ${esc(settings.rfc)}</div>`);
|
const businessData = settings?.settings || settings || {};
|
||||||
if (settings.sucursal) lines.push(`<div class="t-center t-small">${esc(settings.sucursal)}</div>`);
|
|
||||||
if (settings.tel) lines.push(`<div class="t-center t-small">Tel: ${esc(settings.tel)}</div>`);
|
const negocioNombre = businessData?.negocio || settings?.negocio || 'Ale Ponce';
|
||||||
|
const negocioTagline = businessData?.tagline || settings?.tagline || 'beauty expert';
|
||||||
|
const negocioCalle = businessData?.calle || settings?.calle;
|
||||||
|
const negocioColonia = businessData?.colonia || settings?.colonia;
|
||||||
|
const negocioCP = businessData?.cp || settings?.cp;
|
||||||
|
const negocioRFC = businessData?.rfc || settings?.rfc;
|
||||||
|
const negocioTel = businessData?.tel || settings?.tel || '8443555108';
|
||||||
|
|
||||||
|
lines.push(`<div class="t-center t-bold t-business-name">${esc(negocioNombre)}</div>`);
|
||||||
|
lines.push(`<div class="t-center t-tagline">${esc(negocioTagline)}</div>`);
|
||||||
|
lines.push('<div class="t-spacer"></div>');
|
||||||
|
if (negocioCalle) lines.push(`<div class="t-center t-small">${esc(negocioCalle)}</div>`);
|
||||||
|
if (negocioColonia && negocioCP) lines.push(`<div class="t-center t-small">${esc(negocioColonia)}, ${esc(negocioCP)}</div>`);
|
||||||
|
if (negocioRFC) lines.push(`<div class="t-center t-small">RFC: ${esc(negocioRFC)}</div>`);
|
||||||
|
lines.push(`<div class="t-center t-small">Tel: ${esc(negocioTel)}</div>`);
|
||||||
|
|
||||||
lines.push('<div class="t-divider"></div>');
|
lines.push('<div class="t-divider"></div>');
|
||||||
lines.push(`<div class="t-row t-small"><span>Folio:</span><span>${esc(mov.folio)}</span></div>`);
|
lines.push(`<div class="t-row t-small"><span>Folio:</span><span>${esc(mov.folio)}</span></div>`);
|
||||||
lines.push(`<div class="t-row t-small"><span>Fecha:</span><span>${esc(fechaLocal)}</span></div>`);
|
// Usar la función de fecha específica para tickets
|
||||||
|
const fechaFinal = fechaParaTicketSolamente();
|
||||||
|
|
||||||
|
lines.push(`<div class="t-row t-small"><span>Fecha:</span><span>${esc(fechaFinal)}</span></div>`);
|
||||||
|
|
||||||
lines.push('<div class="t-divider"></div>');
|
lines.push('<div class="t-divider"></div>');
|
||||||
lines.push(`<div><span class="t-bold">${esc(tipoServicio)}</span></div>`);
|
lines.push(`<div class="t-service-title t-bold">${esc(tipoServicio)}</div>`);
|
||||||
if (mov.client) lines.push(`<div class="t-small">Cliente: ${esc(mov.client.nombre)}</div>`);
|
if (mov.client) lines.push(`<div class="t-small t-service-detail">Cliente: ${esc(mov.client.nombre)}</div>`);
|
||||||
if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`);
|
if (mov.concepto) lines.push(`<div class="t-small t-service-detail">Concepto: ${esc(mov.concepto)}</div>`);
|
||||||
if (mov.staff) lines.push(`<div class="t-small"><b>Te atendió:</b> ${esc(mov.staff)}</div>`);
|
if (mov.staff) lines.push(`<div class="t-small t-service-detail"><b>Te atendió:</b> ${esc(mov.staff)}</div>`);
|
||||||
if (mov.metodo) lines.push(`<div class="t-small">Método: ${esc(mov.metodo)}</div>`);
|
if (mov.metodo) lines.push(`<div class="t-small t-service-detail">Método: ${esc(mov.metodo)}</div>`);
|
||||||
if (mov.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`);
|
if (mov.notas) lines.push(`<div class="t-small t-service-detail">Notas: ${esc(mov.notas)}</div>`);
|
||||||
|
|
||||||
lines.push('<div class="t-divider"></div>');
|
lines.push('<div class="t-divider"></div>');
|
||||||
lines.push(`<div class="t-row t-bold"><span>Total</span><span>${montoFormateado}</span></div>`);
|
lines.push(`<div class="t-row t-bold"><span>Total</span><span>${montoFormateado}</span></div>`);
|
||||||
@@ -72,16 +107,20 @@ function templateTicket(mov, settings) {
|
|||||||
if (mov.client.cedulaMedico) lines.push(`<div class="t-small">Cédula: ${esc(mov.client.cedulaMedico)}</div>`);
|
if (mov.client.cedulaMedico) lines.push(`<div class="t-small">Cédula: ${esc(mov.client.cedulaMedico)}</div>`);
|
||||||
}
|
}
|
||||||
lines.push('<div class="t-divider"></div>');
|
lines.push('<div class="t-divider"></div>');
|
||||||
lines.push(`<div class="t-small t-center">Al consentir el servicio, declara que la información médica proporcionada es veraz.</div>`);
|
const consentText = mov.tipo === 'Curso' || tipoServicio.toLowerCase().includes('curso')
|
||||||
|
? 'Al inscribirse al curso, acepta los términos y condiciones del programa educativo.'
|
||||||
|
: 'Al consentir el servicio, declara que la información médica proporcionada es veraz.';
|
||||||
|
lines.push(`<div class="t-small t-center">${consentText}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.leyenda) lines.push(`<div class="t-footer t-center t-small">${esc(settings.leyenda)}</div>`);
|
|
||||||
|
|
||||||
lines.push('<div class="t-qr-section">');
|
lines.push('<div class="t-qr-section">');
|
||||||
lines.push('<div class="t-small t-bold">¡Tu opinión es muy importante!</div>');
|
lines.push('<div class="t-small t-bold t-center">¡Tu opinión es muy importante!</div>');
|
||||||
lines.push('<div class="t-small">Escanea el código QR para darnos tu feedback.</div>');
|
lines.push('<div class="t-small t-center">Escanea el código QR para darnos tu feedback.</div>');
|
||||||
lines.push('<canvas id="qr-canvas"></canvas>');
|
lines.push('<canvas id="qr-canvas"></canvas>');
|
||||||
lines.push('</div>');
|
lines.push('</div>');
|
||||||
|
|
||||||
|
const negocioLeyenda = businessData?.leyenda || settings?.leyenda;
|
||||||
|
if (negocioLeyenda) lines.push(`<div class="t-footer t-center t-small">${esc(negocioLeyenda)}</div>`);
|
||||||
|
|
||||||
lines.push('</div>');
|
lines.push('</div>');
|
||||||
return lines.join('');
|
return lines.join('');
|
||||||
|
|||||||
14
server.js
14
server.js
@@ -21,14 +21,16 @@ app.use(session({
|
|||||||
secret: SESSION_SECRET,
|
secret: SESSION_SECRET,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: { secure: IN_PROD, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 } // `secure: true` en producción con HTTPS
|
cookie: { secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 } // secure: false para VPS sin HTTPS
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// --- DATABASE INITIALIZATION ---
|
// --- DATABASE INITIALIZATION ---
|
||||||
// Usar un path que funcione tanto en desarrollo como en Docker
|
// Usar un path que funcione tanto en desarrollo como en Docker
|
||||||
const dbPath = process.env.NODE_ENV === 'production'
|
const dbPath = process.env.DB_PATH || (
|
||||||
? path.join(__dirname, 'data', 'ap-pos.db')
|
process.env.NODE_ENV === 'production'
|
||||||
: path.join(__dirname, 'ap-pos.db');
|
? path.join(__dirname, 'data', 'ap-pos.db')
|
||||||
|
: path.join(__dirname, 'ap-pos.db')
|
||||||
|
);
|
||||||
console.log(`Connecting to database at: ${dbPath}`);
|
console.log(`Connecting to database at: ${dbPath}`);
|
||||||
|
|
||||||
const db = new sqlite3.Database(dbPath, (err) => {
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
@@ -485,8 +487,8 @@ function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Dashboard Route (Admin Only) ---
|
// --- Dashboard Route (Authenticated Users) ---
|
||||||
apiRouter.get('/dashboard', isAdmin, (req, res) => {
|
apiRouter.get('/dashboard', isAuthenticated, (req, res) => {
|
||||||
const queries = {
|
const queries = {
|
||||||
totalIncome: "SELECT SUM(monto) as total FROM movements",
|
totalIncome: "SELECT SUM(monto) as total FROM movements",
|
||||||
totalMovements: "SELECT COUNT(*) as total FROM movements",
|
totalMovements: "SELECT COUNT(*) as total FROM movements",
|
||||||
|
|||||||
516
styles.css
516
styles.css
@@ -59,6 +59,400 @@ h3 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Campos de fecha y hora mejorados */
|
||||||
|
.date-time-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field {
|
||||||
|
width: 50px !important;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 4px !important;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field-year {
|
||||||
|
width: 70px !important;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 4px !important;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-separator {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-select {
|
||||||
|
min-width: 160px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nuevos estilos modernos para el POS */
|
||||||
|
.form-modern {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h3 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #343a40;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-selector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px 1fr 80px 100px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-input {
|
||||||
|
width: 70px !important;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-products {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 80px 100px 40px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item-quantity {
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item-price {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-section {
|
||||||
|
background: #343a40;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-final {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-top: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos para sección de descuentos colapsable */
|
||||||
|
.discount-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-header {
|
||||||
|
padding: 0 0 15px 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #343a40;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-header:hover {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-container {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width-discount {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-symbol {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-symbol input {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-symbol {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-preview {
|
||||||
|
background: #e8f5e8;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d4edda;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-preview-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-preview-item span:first-child {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-amount {
|
||||||
|
color: #28a745;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Oncological patient section styling */
|
||||||
|
#oncologico-fields.active h3 {
|
||||||
|
color: #ea76cb;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warrior option styling */
|
||||||
|
option[value="warrior"] {
|
||||||
|
background-color: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.discount-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-header {
|
||||||
|
padding: 12px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions-modern {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-large {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-large:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-large {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-large:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -178,7 +572,7 @@ button.action-btn {
|
|||||||
color: #000;
|
color: #000;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
border: 1px solid #ccc; /* Visible en pantalla, no en impresión */
|
border: 1px solid #ccc; /* Visible en pantalla, no en impresión */
|
||||||
}
|
}
|
||||||
.t-logo {
|
.t-logo {
|
||||||
@@ -189,43 +583,65 @@ button.action-btn {
|
|||||||
}
|
}
|
||||||
.t-center { text-align: center; }
|
.t-center { text-align: center; }
|
||||||
.t-bold { font-weight: bold; }
|
.t-bold { font-weight: bold; }
|
||||||
.t-tagline { font-size: 11px; margin-bottom: 6px; }
|
.t-business-name { font-size: 14px; margin-bottom: 4px; }
|
||||||
.t-small { font-size: 10px; }
|
.t-tagline { font-size: 11px; margin-bottom: 8px; font-style: italic; }
|
||||||
|
.t-spacer { height: 4px; }
|
||||||
|
.t-small { font-size: 10px; line-height: 1.3; }
|
||||||
|
.t-service-title { margin-bottom: 6px; font-size: 12px; }
|
||||||
|
.t-service-detail { margin-bottom: 3px; }
|
||||||
.t-divider { border-top: 1px dashed #000; margin: 8px 0; }
|
.t-divider { border-top: 1px dashed #000; margin: 8px 0; }
|
||||||
.t-row { display: flex; justify-content: space-between; }
|
.t-row { display: flex; justify-content: space-between; margin-bottom: 2px; }
|
||||||
.t-footer { margin-top: 10px; }
|
.t-footer { margin-top: 10px; }
|
||||||
|
|
||||||
.t-qr-section {
|
.t-qr-section {
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
padding-top: 10px;
|
padding-top: 8px;
|
||||||
border-top: 1px dashed #000;
|
border-top: 1px dashed #000;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.t-qr-section .t-small {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
#qr-canvas {
|
#qr-canvas {
|
||||||
margin: 5px auto;
|
margin: 8px auto 4px auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/***** MODO IMPRESIÓN *****/
|
/***** MODO IMPRESIÓN *****/
|
||||||
@media print {
|
@media print {
|
||||||
body {
|
* {
|
||||||
background: #fff;
|
visibility: hidden;
|
||||||
color: #000;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#printArea {
|
#printArea, #printArea * {
|
||||||
|
visibility: visible !important;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@page {
|
@page {
|
||||||
size: 58mm auto;
|
size: 58mm auto;
|
||||||
margin: 1cm 0;
|
margin: 0.5cm 0.2cm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Configuración adicional para impresión PDF */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
width: 58mm !important;
|
||||||
|
max-width: 58mm !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket {
|
.ticket {
|
||||||
@@ -234,6 +650,15 @@ button.action-btn {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.t-logo {
|
||||||
|
display: block !important;
|
||||||
|
margin: 0 auto 8px auto;
|
||||||
|
max-width: 75%;
|
||||||
|
height: auto;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
color-adjust: exact;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Estilos de Pestañas --- */
|
/* --- Estilos de Pestañas --- */
|
||||||
@@ -662,4 +1087,67 @@ table tbody tr:hover {
|
|||||||
|
|
||||||
.footer-info p {
|
.footer-info p {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos para sección de anticipos */
|
||||||
|
.anticipos-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticipos-section h4 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #28a745;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticipos-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticipo-item {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticipo-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticipo-monto {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticipo-fecha {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticipo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-aplicar-anticipo {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-aplicar-anticipo:hover {
|
||||||
|
background: #218838;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user