Created
November 28, 2025 19:53
-
-
Save iDavidMorales/790d325b0d1a48cdbb7496085619d41f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| // Este bloque PHP se ejecutaría en tu servidor. | |
| // Asegúrate de que tu lógica de inicio de sesión y base de datos establezca $_SESSION['plan'] y $_SESSION['loggedIn']. | |
| session_start(); | |
| // --- SIMULACIÓN DE DATOS DE SESIÓN PHP --- | |
| // Para probar: | |
| // - Cambia 'free' a 'premium' para ver la interfaz premium. | |
| // - Cambia '1' a '0' para simular un usuario no loggeado (aunque el sistema asume loggeado para funcionalidades).\ | |
| // NOTA: '1' para premium, '0' para gratuito | |
| $_SESSION['plan'] = '1'; // <-- ¡CAMBIA ESTO A 'premium' PARA PROBAR EL PLAN PREMIUM! | |
| $_SESSION['userID'] = $_SESSION['user_id']; | |
| $_SESSION['loggedIn'] = '1'; | |
| // --- FIN DE SIMULACIÓN --- | |
| // Determinar si el usuario es premium | |
| // Aseguramos que el valor sea booleano para evitar cualquier problema de tipo en JavaScript. | |
| $is_premium_user_php = (isset($_SESSION['plan']) && $_SESSION['plan'] === '1' && $_SESSION['loggedIn'] === '1'); | |
| // Convertimos el booleano PHP directamente a su representación de cadena JavaScript 'true' o 'false'. | |
| $is_premium_user_js_value = $is_premium_user_php ? 'true' : 'false'; | |
| ?> | |
| <?php | |
| if($_SESSION['user_id']!=''){ | |
| // header("Location: https://routicket.com/app/?href=index?redirect_session=1&id_profile=".$_SESSION['user_id']); | |
| }else{ | |
| header("Location: https://routicket.com/login/v2/?punto_de_venta_login_requerido=1"); | |
| } | |
| ?> | |
| <script async src="https://www.googletagmanager.com/gtag/js?id=G-N6JNVM4NVD"></script> | |
| <script> | |
| window.dataLayer = window.dataLayer || []; | |
| function gtag(){dataLayer.push(arguments);} | |
| gtag('js', new Date()); | |
| gtag('config', 'G-N6JNVM4NVD'); | |
| </script> | |
| <!DOCTYPE html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Routicket | Punto de Venta</title> | |
| <script src="../../chart.js"></script> | |
| <script src="excl.js"></script> | |
| <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> | |
| <script src="funciones.js"></script> | |
| <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"> | |
| <!--<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> | |
| --><!--<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| --> | |
| <link rel="stylesheet" href="stylepdv.css"> | |
| </head> | |
| <body> | |
| <div id="messageContainer"></div> | |
| <div id="customConfirmModal" class="custom-modal-overlay"> | |
| <div class="custom-modal-content"> | |
| <h5 id="customConfirmMessage">¿Estás seguro de que quieres realizar esta acción?</h5> | |
| <div class="modal-buttons"> | |
| <button class="btn btn-danger" id="customConfirmYes">Sí</button> | |
| <button class="btn btn-secondary" id="customConfirmNo">No</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="loadingModal" class="custom-modal-overlay"> | |
| <div class="custom-modal-content"> | |
| <div class="spinner-border" role="status"> | |
| <span class="sr-only">Cargando...</span> | |
| </div> | |
| <div class="loading-message">Cargando...</div> | |
| </div> | |
| </div> | |
| <!-- New Modal for Weight-based Product Entry --> | |
| <div id="weightEntryModal" class="custom-modal-overlay"> | |
| <div class="custom-modal-content"> | |
| <h5>Agregar Producto por Peso (kg/g)</h5> | |
| <div class="form-group"> | |
| <label for="weightProductCode">Código o Nombre del Producto:</label> | |
| <input type="text" class="form-control" id="weightProductCode" placeholder="Código o nombre del producto" autofocus> | |
| <small class="form-text text-muted text-left">Ej: 7501234567890 o Manzanas</small> | |
| <div id="weightProductSearchResults" class="list-group product-search-results"></div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="weightInput">Peso (kg):</label> | |
| <input type="number" class="form-control" id="weightInput" step="0.001" min="0.001" value="0.000" placeholder="0.000"> | |
| <small class="form-text text-muted text-left">Ej: 1.5 para 1.5 kg, 0.25 para 250 gramos</small> | |
| </div> | |
| <div class="form-group"> | |
| <label>Producto Seleccionado:</label> | |
| <p id="selectedWeightedProductInfo" class="text-left font-weight-bold"></p> | |
| <p id="calculatedWeightedPrice" class="text-left font-weight-bold text-success"></p> | |
| </div> | |
| <div class="modal-buttons"> | |
| <button class="btn btn-success" id="addWeightedProductBtn"><i class="fas fa-cart-plus"></i> Añadir al Carrito</button> | |
| <button class="btn btn-secondary" id="closeWeightEntryModalBtn">Cancelar</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="app-wrapper"> | |
| <header class="app-header"> | |
| <a class="navbar-brand" href="#"> | |
| Routicket | Punto de Venta | |
| </a> | |
| <ul class="navbar-nav ml-auto d-flex flex-row align-items-center"> <!-- Added d-flex flex-row align-items-center --> | |
| <!-- Botón de Nuevos Pedidos en la cabecera (siempre visible) --> | |
| <li class="nav-item position-relative"> | |
| <a class="nav-link" href="#" id="newOrdersNotification" data-page="ordersPage"> | |
| <i class="fas fa-bell"></i> Nuevos Pedidos | |
| <span class="order-notification-badge d-none" id="orderCountBadge">0</span> | |
| </a> | |
| </li> | |
| </ul> | |
| </header> | |
| <aside class="app-sidebar"> | |
| <div class="sidebar-section"> | |
| <div class="sidebar-header">Menú Principal</div> | |
| <ul class="sidebar-nav"> | |
| <li><a href="#" class="sidebar-link active" data-page="puntoVentaPage" data-section="puntoVenta"><i class="fas fa-cash-register"></i> Punto de Venta</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="ordersPage" data-section="orders"><i class="fas fa-box-open"></i> Pedidos</a></li> | |
| <li><a href="index.php"><i class="fas fa-file-excel"></i> Cargar Excel</a></li> | |
| <!-- Botón de Atajos movido a la barra lateral --> | |
| <li><a href="#" class="sidebar-link" id="shortcutsLinkSidebar" data-page="shortcutsPage" data-section="shortcuts"><i class="fas fa-keyboard"></i> Atajos</a></li> | |
| </ul> | |
| </div> | |
| <div class="sidebar-section" id="reportes-section"> | |
| <div class="sidebar-header">Reportes</div> | |
| <ul class="sidebar-nav"> | |
| <li><a href="#" class="sidebar-link" data-page="stockReportsPage" data-section="stockReports"><i class="fas fa-chart-bar"></i> Reportes de Stock</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="salesHistoryPage" data-section="salesHistory"><i class="fas fa-history"></i> Historial de Ventas</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="analyticsPage" data-section="analytics"><i class="fas fa-chart-line"></i> Análisis de Ventas</a></li> | |
| </ul> | |
| </div> | |
| <div class="sidebar-section" id="gestion-section"> | |
| <div class="sidebar-header">Gestión</div> | |
| <ul class="sidebar-nav"> | |
| <li><a href="#" class="sidebar-link" data-page="productManagementPage" data-section="productManagement"><i class="fas fa-cubes"></i> Gestión de Productos</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="grammageProductsPage" data-section="grammageProducts"><i class="fas fa-weight-hanging"></i> Productos por Peso</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="expensesPage" data-section="expenses"><i class="fas fa-money-check-alt"></i> Gastos</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="notesPage" data-section="notes"><i class="fas fa-clipboard"></i> Notas</a></li> | |
| </ul> | |
| </div> | |
| <div class="sidebar-section" id="configuraciones-section"> | |
| <div class="sidebar-header">Configuraciones</div> | |
| <ul class="sidebar-nav"> | |
| <li><a href="#" class="sidebar-link" data-page="subscriptionPage" data-section="subscription"><i class="fas fa-star"></i> Suscripción</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="settingsPage" data-section="settings"><i class="fas fa-cogs"></i> Opciones Generales</a></li> | |
| <li><a href="#" class="sidebar-link" data-page="userManagementPage" data-section="userManagement"><i class="fas fa-user-shield"></i> Gestión de Usuarios</a></li> | |
| </ul> | |
| </div> | |
| <div class="sidebar-section sidebar-premium-features"> | |
| <div class="sidebar-header">Características Premium <i class="fas fa-star text-warning ml-1"></i></div> | |
| <ul class="sidebar-nav"> | |
| <li class="premium-feature-item" data-premium-feature="grammage_products"><i class="fas fa-check-circle"></i> Productos por Gramaje</li> | |
| <li class="premium-feature-item" data-premium-feature="currency_management"><i class="fas fa-check-circle"></i> Gestión de Moneda</li> | |
| <li class="premium-feature-item" data-premium-feature="advanced_analytics"><i class="fas fa-check-circle"></i> Análisis Avanzado (Ventas y Pedidos)</li> | |
| <li class="premium-feature-item" data-premium-feature="orders"><i class="fas fa-check-circle"></i> Gestión de Pedidos</li> | |
| <li class="premium-feature-item" data-premium-feature="advanced_pricing"><i class="fas fa-check-circle"></i> Gestión de Precios Avanzada</li> | |
| <!-- | |
| <li class="premium-feature-item" data-premium-feature="multi_warehouse"><i class="fas fa-check-circle"></i> Múltiples Almacenes</li> | |
| <li class="premium-feature-item" data-premium-feature="cloud_sync"><i class="fas fa-check-circle"></i> Sincronización en la Nube</li> | |
| <li class="premium-feature-item" data-premium-feature="e_invoicing"><i class="fas fa-check-circle"></i> Facturación Electrónica</li> | |
| --> | |
| </ul> | |
| <button class="btn btn-info btn-sm mt-3 mx-auto d-block" id="upgradePremiumBtn">Actualizar a Premium</button> | |
| </div> | |
| </aside> | |
| <main class="app-main-content"> | |
| <div id="puntoVentaPage" class="main-content-page active"> | |
| <div class="form-group mb-3"> | |
| <input type="text" class="form-control" id="product-search" placeholder="Escanear código o buscar producto por nombre/código/departamento..." autofocus> | |
| </div> | |
| <div class="main-columns"> | |
| <div class="cart-panel panel-windows"> | |
| <h2>Carrito de Compras</h2> | |
| <ul id="cart-list"> | |
| <li class="text-center text-muted">Tu carrito está vacío.</li> | |
| </ul> | |
| </div> | |
| <div class="right-column-wrapper" style="height:650px;"> | |
| <div class="payment-info-panel panel-windows" > | |
| <h2>Información de Pago</h2> | |
| <div class="payment-section"> | |
| <div class="form-group mt-3"> | |
| <label>Método de Pago:</label> | |
| <div class="payment-method-options"> | |
| <div class="form-check form-check-inline"> | |
| <input class="form-check-input" type="radio" name="paymentMethod" id="paymentCash" value="Efectivo" checked> | |
| <label class="form-check-label" for="paymentCash"><i class="fas fa-money-bill-wave"></i> Efectivo</label> | |
| </div> | |
| <div class="form-check form-check-inline"> | |
| <input class="form-check-input" type="radio" name="paymentMethod" id="paymentCard" value="Tarjeta"> | |
| <label class="form-check-label" for="paymentCard"><i class="fas fa-credit-card"></i> Tarjeta</label> | |
| </div> | |
| <div class="form-check form-check-inline"> | |
| <input class="form-check-input" type="radio" name="paymentMethod" id="paymentTransfer" value="Transferencia"> | |
| <label class="form-check-label" for="paymentTransfer"><i class="fas fa-exchange-alt"></i> Transferencia</label> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="amountReceived">Monto Recibido:</label> | |
| <input type="number" class="form-control" id="amountReceived" placeholder="0.00" step="0.01"> | |
| </div> | |
| <div class="summary-item text-right"> | |
| <strong>Cambio a devolver:</strong> <span id="cambioDevolver">0.00</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="product-panel panel-windows" id="productCatalogPanel"> | |
| <h2>Catálogo de Productos</h2> | |
| <div class="form-group mb-3"> | |
| <input type="text" class="form-control" id="catalog-filter-search" placeholder="Filtrar catálogo por nombre, código o departamento..."> | |
| </div> | |
| <div id="product-list" class="mt-3"> | |
| <p class="text-center text-muted">Carga un Excel desde "Cargar Excel" para ver los productos.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="stockReportsPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-chart-bar"></i> Reportes de Stock</h2> | |
| <div class="d-flex justify-content-between mb-3 flex-wrap"> | |
| <button class="btn btn-primary btn-sm mb-2 mb-md-0" onclick="downloadStockReport('csv')"><i class="fas fa-download"></i> Descargar Reporte de Stock (CSV)</button> | |
| <button class="btn btn-primary btn-sm mb-2 mb-md-0" onclick="downloadStockReport('xlsx')"><i class="fas fa-file-excel"></i> Descargar Reporte de Stock (Excel)</button> | |
| <button class="btn btn-info btn-sm" onclick="downloadAllProductsReport('csv')"><i class="fas fa-download"></i> Descargar Inventario Completo (CSV)</button> | |
| <button class="btn btn-info btn-sm" onclick="downloadAllProductsReport('xlsx')"><i class="fas fa-file-excel"></i> Descargar Inventario Completo (Excel)</button> | |
| </div> | |
| <h3 class="mt-3 text-danger"><i class="fas fa-exclamation-triangle"></i> Productos Agotados (Existencia ≤ 0)</h3> | |
| <div class="form-group"> | |
| <input type="text" class="form-control form-control-sm" id="stock-search-out-of-stock" placeholder="Filtrar agotados por código, producto o departamento..."> | |
| </div> | |
| <button class="btn btn-secondary btn-sm mb-2" onclick="copyTableContentToClipboard('outOfStockList', 'csv')"><i class="fas fa-copy"></i> Copiar Agotados (CSV)</button> | |
| <div id="outOfStockList" class="report-table-container mb-4"> | |
| <p class="text-center text-muted">No hay productos agotados.</p> | |
| </div> | |
| <h3 class="mt-4 text-warning"><i class="fas fa-warehouse"></i> Productos con Stock Bajo (Existencia < 10)</h3> | |
| <div class="form-group"> | |
| <input type="text" class="form-control form-control-sm" id="stock-search-low-stock" placeholder="Filtrar stock bajo por código, producto o departamento..."> | |
| </div> | |
| <button class="btn btn-secondary btn-sm mb-2" onclick="copyTableContentToClipboard('lowStockList', 'csv')"><i class="fas fa-copy"></i> Copiar Stock Bajo (CSV)</button> | |
| <div id="lowStockList" class="report-table-container"> | |
| <p class="text-center text-muted">No hay productos con stock bajo.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="salesHistoryPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-history"></i> Historial de Ventas y Pedidos</h2> | |
| <div class="form-group row"> | |
| <div class="col-md-5"> | |
| <label for="startDate">Fecha Inicio:</label> | |
| <input type="date" class="form-control" id="startDate"> | |
| </div> | |
| <div class="col-md-5"> | |
| <label for="endDate">Fecha Fin:</label> | |
| <input type="date" class="form-control" id="endDate"> | |
| </div> | |
| <div class="col-md-2 d-flex align-items-end"> | |
| <button class="btn btn-primary btn-block" id="filterSalesBtn"><i class="fas fa-filter"></i> Filtrar</button> | |
| </div> | |
| </div> | |
| <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap"> | |
| <h5 class="mb-0">Total Generado: <span id="filteredSalesTotal" class="text-success font-weight-bold">0.00</span></h5> | |
| <div> | |
| <button class="btn btn-primary btn-sm mb-2 mb-md-0" onclick="downloadSalesHistoryReport('csv')"><i class="fas fa-download"></i> Descargar Historial (CSV)</button> | |
| <button class="btn btn-primary btn-sm" onclick="downloadSalesHistoryReport('xlsx')"><i class="fas fa-file-excel"></i> Descargar Historial (Excel)</button> | |
| <button class="btn btn-secondary btn-sm mb-2" onclick="copyTableContentToClipboard('salesHistoryList', 'csv')"><i class="fas fa-copy"></i> Copiar Historial (CSV)</button> | |
| </div> | |
| </div> | |
| <div id="salesHistoryList" class="report-table-container mt-3"> | |
| <p class="text-center text-muted">Aún no hay ventas o pedidos registrados.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="analyticsPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-chart-line"></i> Análisis de Ventas y Pedidos</h2> | |
| <p class="text-muted text-center">Este análisis muestra el rendimiento combinado de tus ventas y pedidos.</p> | |
| <h3 class="mt-4"><i class="fas fa-chart-area"></i> Ventas y Pedidos por Día (Últimos 7 Días)</h3> | |
| <div id="salesChartContainer"> | |
| <canvas id="salesChart"></canvas> | |
| </div> | |
| <h3 class="mt-4"><i class="fas fa-info-circle"></i> Métricas Clave</h3> | |
| <div class="analytics-summary"> | |
| <div class="analytics-card"> | |
| <h4>Total de Transacciones</h4> | |
| <span class="value neutral" id="totalSalesCount">0</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Ingresos Totales</h4> | |
| <span class="value positive" id="totalRevenue">0.00</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Ticket Promedio</h4> | |
| <span class="value neutral" id="averageTicket">0.00</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Producto Más Vendido (Cantidad)</h4> | |
| <span class="value neutral" id="mostSoldProductQuantity">N/A</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Producto Más Vendido (Ingresos)</h4> | |
| <span class="value neutral" id="mostSoldProductRevenue">N/A</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Departamento Más Rentable (Ingresos)</h4> | |
| <span class="value neutral" id="mostProfitableDeptRevenue">N/A</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Método de Pago Preferido</h4> | |
| <span class="value neutral" id="preferredPaymentMethod">N/A</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Total de Artículos Vendidos</h4> | |
| <span class="value neutral" id="totalItemsSold">0</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Clientes Atendidos</h4> | |
| <span class="value neutral" id="uniqueCustomers">0</span> | |
| </div> | |
| <div class="analytics-card"> | |
| <h4>Transacciones por Hora (Promedio)</h4> | |
| <span class="value neutral" id="averageSalesPerHour">N/A</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="productManagementPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-cubes"></i> Gestión de Productos</h2> | |
| <div class="product-form"> | |
| <h3>Agregar/Editar Producto</h3> | |
| <div class="form-group"> | |
| <label for="mgmtProductCode">Código de Producto:</label> | |
| <input type="text" class="form-control" id="mgmtProductCode" placeholder="Código único del producto" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="mgmtProductName">Nombre del Producto:</label> | |
| <input type="text" class="form-control" id="mgmtProductName" placeholder="Nombre del producto" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="mgmtProductDepartment">Departamento:</label> | |
| <input type="text" class="form-control" id="mgmtProductDepartment" placeholder="Ej: Abarrotes, Lácteos"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="mgmtProductPrice">Precio de Venta (<span id="mgmtProductPriceCurrency"></span>):</label> | |
| <input type="number" class="form-control" id="mgmtProductPrice" step="0.01" min="0" value="0.00" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="mgmtProductStock">Existencia:</label> | |
| <input type="number" class="form-control" id="mgmtProductStock" step="1" min="0" value="0" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="mgmtProductUnitType">Tipo de Unidad:</label> | |
| <select class="form-control" id="mgmtProductUnitType" required> | |
| <option value="unit">Unidad</option> | |
| <option value="kg">Kilogramo (kg)</option> | |
| </select> | |
| </div> | |
| <div class="d-flex justify-content-center"> | |
| <button class="btn btn-primary mr-2" id="btnSaveProduct"><i class="fas fa-save"></i> Guardar Producto</button> | |
| <button class="btn btn-secondary" id="btnClearProductForm"><i class="fas fa-redo"></i> Limpiar Formulario</button> | |
| </div> | |
| </div> | |
| <h3 class="mt-4"><i class="fas fa-list-alt"></i> Listado de Productos</h3> | |
| <div class="form-group"> | |
| <input type="text" class="form-control" id="product-management-search" placeholder="Buscar por código, nombre o departamento para editar/eliminar..."> | |
| </div> | |
| <div id="productManagementList" class="product-management-list-container"> | |
| <p class="text-center text-muted">Carga un Excel o agrega productos para verlos aquí.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="grammageProductsPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-weight-hanging"></i> Productos por Peso (kg/g)</h2> | |
| <p class="text-muted text-center">Aquí se muestran todos los productos configurados para venta por peso.</p> | |
| <div class="form-group"> | |
| <input type="text" class="form-control form-control-sm" id="grammage-product-search" placeholder="Filtrar por código, producto o departamento..."> | |
| </div> | |
| <button class="btn btn-secondary btn-sm mb-2" onclick="copyTableContentToClipboard('grammageProductList', 'csv')"><i class="fas fa-copy"></i> Copiar Productos por Peso (CSV)</button> | |
| <div id="grammageProductList" class="report-table-container"> | |
| <p class="text-center text-muted">No hay productos configurados para venta por peso.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="expensesPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-money-check-alt"></i> Gestión de Gastos</h2> | |
| <div class="expense-form"> | |
| <h3>Registrar Nuevo Gasto</h3> | |
| <div class="form-group"> | |
| <label for="expenseDate">Fecha:</label> | |
| <input type="date" class="form-control" id="expenseDate" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="expenseDescription">Descripción:</label> | |
| <input type="text" class="form-control" id="expenseDescription" placeholder="Ej: Pago de luz, Compra de insumos" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="expenseAmount">Monto (<span id="expenseAmountCurrency"></span>):</label> | |
| <div class="input-group"> | |
| <div class="input-group-prepend"> | |
| <span class="input-group-text" id="expenseCurrencySymbol"></span> | |
| </div> | |
| <input type="number" class="form-control" id="expenseAmount" step="0.01" min="0" placeholder="0.00" required> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="expenseCategory">Categoría:</label> | |
| <input type="text" class="form-control" id="expenseCategory" placeholder="Ej: Servicios, Materiales, Salarios"> | |
| </div> | |
| <div class="d-flex justify-content-center"> | |
| <button class="btn btn-primary mr-2" id="btnAddExpense"><i class="fas fa-plus-circle"></i> Añadir Gasto</button> | |
| <button class="btn btn-secondary" id="btnClearExpenseForm"><i class="fas fa-redo"></i> Limpiar Formulario</button> | |
| </div> | |
| </div> | |
| <h3 class="mt-4"><i class="fas fa-file-invoice-dollar"></i> Reporte de Gastos</h3> | |
| <div class="d-flex justify-content-between mb-3 flex-wrap"> | |
| <button class="btn btn-primary btn-sm mb-2 mb-md-0" onclick="downloadExpensesReport('csv')"><i class="fas fa-download"></i> Descargar Gastos (CSV)</button> | |
| <button class="btn btn-primary btn-sm" onclick="downloadExpensesReport('xlsx')"><i class="fas fa-file-excel"></i> Descargar Gastos (Excel)</button> | |
| </div> | |
| <div class="form-group"> | |
| <input type="text" class="form-control form-control-sm" id="expense-search" placeholder="Filtrar gastos por descripción o categoría..."> | |
| </div> | |
| <button class="btn btn-secondary btn-sm mb-2" onclick="copyTableContentToClipboard('expenseList', 'csv')"><i class="fas fa-copy"></i> Copiar Gastos (CSV)</button> | |
| <div id="expenseList" class="expense-list-container report-table-container"> | |
| <p class="text-center text-muted">No hay gastos registrados.</p> | |
| </div> | |
| <div class="total-expenses-summary"> | |
| Total de Gastos Filtrados: <span id="filteredTotalExpenses">0.00</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="notesPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-clipboard"></i> Mis Notas</h2> | |
| <div class="note-form"> | |
| <h3>Añadir Nueva Nota</h3> | |
| <div class="form-group"> | |
| <label for="noteTitle">Título:</label> | |
| <input type="text" class="form-control" id="noteTitle" placeholder="Título de la nota" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="noteContent">Contenido:</label> | |
| <textarea class="form-control" id="noteContent" rows="5" placeholder="Escribe tu nota aquí..." required></textarea> | |
| </div> | |
| <div class="d-flex justify-content-center"> | |
| <button class="btn btn-primary mr-2" id="btnAddNote"><i class="fas fa-plus-square"></i> Guardar Nota</button> | |
| <button class="btn btn-secondary" id="btnClearNoteForm"><i class="fas fa-redo"></i> Limpiar Formulario</button> | |
| </div> | |
| </div> | |
| <h3 class="mt-4"><i class="fas fa-list-ul"></i> Listado de Notas</h3> | |
| <div id="notesList" class="notes-list-container"> | |
| <p class="text-center text-muted">No hay notas guardadas.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="ordersPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-box-open"></i> Gestión de Pedidos</h2> | |
| <p class="text-center text-muted">Aquí se mostrarán los pedidos pendientes o en proceso.</p> | |
| <div class="form-group"> | |
| <input type="text" class="form-control" id="order-search" placeholder="Buscar pedido por ID, producto o WhatsApp del cliente..."> | |
| </div> | |
| <div id="ordersList" class="orders-list-container"> | |
| <p class="text-center text-muted">No hay pedidos pendientes.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="shortcutsPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-keyboard"></i> Atajos de Teclado</h2> | |
| <p class="text-muted text-center">Aquí puedes consultar los atajos de teclado para una mayor eficiencia.</p> | |
| <ul class="list-group text-left"> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F3</strong></span> | |
| <span>Limpiar Carrito y Enfocar Scanner</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F7</strong></span> | |
| <span>Enfocar el campo "Escanear código o buscar producto"</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F8</strong></span> | |
| <span>Enfocar el campo "Monto Recibido" (Caja)</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F9</strong></span> | |
| <span>Pre-cargar el total en "Monto Recibido" y enfocar para confirmar venta (Cobrar)</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F6</strong></span> | |
| <span>Abrir el modal para agregar productos por peso (kg/g) y enfocar el campo de código/nombre</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F5</strong></span> | |
| <span>Abrir el modal para agregar productos por peso (kg/g) y enfocar el campo de peso</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F4</strong></span> | |
| <span>Regresar al Punto de Venta y enfocar el scanner</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F1</strong></span> | |
| <span>Ir a la página de Atajos de Teclado</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">F10</strong></span> | |
| <span>Enfocar el campo "Filtrar catálogo por nombre, código o departamento"</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">Enter</strong> (en campo de scanner)</span> | |
| <span>Añadir producto al carrito</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">Enter</strong> (en "Monto Recibido")</span> | |
| <span>Finalizar la venta</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">↑ ↓</strong> (en catálogo de productos)</span> | |
| <span>Navegar por productos</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">+</strong> (en catálogo de productos)</span> | |
| <span>Añadir producto resaltado al carrito</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">-</strong> (en catálogo de productos)</span> | |
| <span>Disminuir cantidad del producto resaltado en el carrito</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">↑ ↓</strong> (en carrito de compras)</span> | |
| <span>Navegar por productos en el carrito</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">+</strong> (en carrito de compras)</span> | |
| <span>Aumentar cantidad del producto resaltado en el carrito</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">-</strong> (en carrito de compras)</span> | |
| <span>Disminuir cantidad del producto resaltado en el carrito</span> | |
| </li> | |
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |
| <span><strong class="text-primary">Esc</strong></span> | |
| <span>Cerrar cualquier modal abierto</span> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div id="subscriptionPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-star"></i> Estado de la Suscripción</h2> | |
| <div class="subscription-panel"> | |
| <h3 class="mb-4">Tu Plan Actual</h3> | |
| <span id="subscriptionStatus" class="subscription-status">Cargando...</span> | |
| <h3 class="mt-5 mb-3">Ventajas del Plan Premium</h3> | |
| <ul class="advantages-list"> | |
| <li><i class="fas fa-check-circle"></i> Gestión de Precios Avanzada</li> | |
| <li><i class="fas fa-check-circle"></i> Múltiples Almacenes</li> | |
| <li><i class="fas fa-check-circle"></i> Sincronización en la Nube (Backup automático)</li> | |
| <li><i class="fas fa-check-circle"></i> Facturación Electrónica Integrada</li> | |
| <li><i class="fas fa-check-circle"></i> Soporte Prioritario 24/7</li> | |
| <li><i class="fas fa-check-circle"></i> Reportes Personalizados y Avanzados</li> | |
| <li><i class="fas fa-check-circle"></i> Acceso a Nuevas Características Exclusivas</li> | |
| </ul> | |
| <button class="btn btn-info upgrade-button" id="upgradePremiumBtn">Actualizar a Premium</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="settingsPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-cogs"></i> Opciones Generales</h2> | |
| <div class="settings-panel"> | |
| <h3>Visibilidad de Secciones</h3> | |
| <div class="form-group"> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleProductCatalog" data-setting-target="productCatalogPanel"> | |
| <label class="form-check-label" for="toggleProductCatalog"> | |
| Mostrar Catálogo de Productos en Punto de Venta | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleReportsSection" data-setting-target="reportes-section"> | |
| <label class="form-check-label" for="toggleReportsSection"> | |
| Mostrar Sección de Reportes | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleManagementSection" data-setting-target="gestion-section"> | |
| <label class="form-check-label" for="toggleManagementSection"> | |
| Mostrar Sección de Gestión | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleConfigSection" data-setting-target="configuraciones-section"> | |
| <label class="form-check-label" for="toggleConfigSection"> | |
| Mostrar Sección de Configuraciones | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleSubscriptionPage" data-setting-target="subscriptionPage-link"> | |
| <label class="form-check-label" for="toggleSubscriptionPage"> | |
| Mostrar Página de Suscripción | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleUserManagementPage" data-setting-target="userManagementPage-link"> | |
| <label class="form-check-label" for="toggleUserManagementPage"> | |
| Mostrar Gestión de Usuarios | |
| </label> | |
| </div> | |
| </div> | |
| <h3 class="mt-4">Características Premium (Visibilidad)</h3> | |
| <div class="form-group"> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleGrammageProductsPage"> | |
| <label class="form-check-label" for="toggleGrammageProductsPage"> | |
| Mostrar "Productos por Peso" | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleCurrencyManagement"> | |
| <label class="form-check-label" for="toggleCurrencyManagement"> | |
| Habilitar Gestión de Moneda | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleAdvancedAnalytics"> | |
| <label class="form-check-label" for="toggleAdvancedAnalytics"> | |
| Mostrar "Análisis de Ventas" | |
| </label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="checkbox" id="toggleOrdersPage"> | |
| <label class="form-check-label" for="toggleOrdersPage"> | |
| Mostrar "Pedidos" y Notificaciones | |
| </label> | |
| </div> | |
| </div> | |
| <h3 class="mt-4">Configuración de Moneda</h3> | |
| <div class="form-group"> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="radio" name="currencyOption" id="currencyMXN" value="MXN"> | |
| <label class="form-check-label" for="currencyMXN">MXN ($)</label> | |
| </div> | |
| <div class="form-check"> | |
| <input class="form-check-input" type="radio" name="currencyOption" id="currencyUSD" value="USD"> | |
| <label class="form-check-label" for="currencyUSD">USD (US$)</label> | |
| </div> | |
| </div> | |
| <div class="form-group mt-3"> | |
| <label for="exchangeRateInput">Tipo de Cambio (1 USD = MXN):</label> | |
| <input type="number" class="form-control" id="exchangeRateInput" step="0.01" min="1" value="17.00"> | |
| <small class="form-text text-muted">Ajusta el valor del dólar.</small> | |
| </div> | |
| <button class="btn btn-primary" id="saveSettingsBtn"><i class="fas fa-save"></i> Guardar Ajustes</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="userManagementPage" class="main-content-page"> | |
| <div class="report-panel"> | |
| <h2><i class="fas fa-user-shield"></i> Gestión de Usuarios</h2> | |
| <p class="text-center text-muted">Esta es una característica premium que te permitirá gestionar usuarios y roles.</p> | |
| <p class="text-center text-muted">¡Actualiza a Premium para desbloquearla!</p> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <div class="fixed-checkout-bar"> | |
| <div class="total-section"> | |
| Total: <span id="cart-total-currency-symbol"></span><span id="cart-total">0.00</span> | |
| <span class="small-total-usd" id="cart-total-usd"></span> | |
| </div> | |
| <div class="btn-group"> | |
| <button class="btn btn-success btn-lg" id="btn-cobrar" disabled><i class="fas fa-dollar-sign"></i> Cobrar</button> | |
| <button class="btn btn-danger btn-lg" id="btn-limpiar-carrito" disabled><i class="fas fa-trash-alt"></i> Limpiar Carrito</button> | |
| </div> | |
| </div> | |
| <br> | |
| <footer class="app-footer"> | |
| <p>© 2025 Routicket.com por David Morales. Todos los derechos reservados.</p> | |
| </footer> | |
| <!-- <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> | |
| --> | |
| <script src="jquery-3.5.1.slim.min.js"></script> | |
| <script src="bootstrap.bundle.min.js"></script> | |
| <script> | |
| // Captura errores globales para depuración | |
| window.onerror = function(message, source, lineno, colno, error) { | |
| console.error("Uncaught JavaScript Error:", message, "at", source, "line", lineno, "col", colno, "Error object:", error); | |
| // mostrarMensaje(`❌ Error de JavaScript: ${message} (Línea: ${lineno})`, 'danger'); | |
| return true; // Evita el manejo de errores predeterminado del navegador | |
| }; | |
| // --- PHP-JS Bridge: Esta variable es establecida por el bloque PHP al cargar la página --- | |
| const isPremiumUser = <?php echo $is_premium_user_js_value; ?>; | |
| console.log('isPremiumUser (from PHP):', isPremiumUser); | |
| let allProducts = []; | |
| let productLookupMap = new Map(); // For O(1) product lookup by code | |
| let cart = []; | |
| let salesHistory = []; | |
| let expenses = []; // New array for expenses | |
| let notes = []; // New array for notes | |
| let orders = []; // Array to store fetched orders | |
| let barcodeScanBuffer = ''; // To accumulate characters during a scan | |
| let lastScanTime = 0; // To detect end of scan | |
| let salesChartInstance = null; // To store the Chart.js instance | |
| let newOrderCount = 0; // For notification badge | |
| let previousOrderIds = new Set(); // To store IDs of orders seen in the last fetch | |
| // Almacenar el CÓDIGO del producto/item resaltado, no el elemento DOM directamente. | |
| // Esto permite que el resaltado persista si la lista se re-renderiza. | |
| let highlightedProductCode = null; | |
| let highlightedCartItemCode = null; | |
| // --- Currency Configuration --- | |
| let currentCurrency = 'MXN'; // Default currency | |
| let currentCurrencySymbol = '$'; // Default symbol for MXN | |
| let exchangeRateUSDToMXN = 17.0; // Default example rate, will be loaded from settings | |
| // --- Local Storage Keys --- | |
| const PRODUCTS_STORAGE_KEY = 'datosExcel'; | |
| const CART_STORAGE_KEY = 'puntoDeVentaCart'; | |
| const SALES_HISTORY_STORAGE_KEY = 'puntoDeVentaSalesHistory'; | |
| const EXPENSES_STORAGE_KEY = 'puntoDeVentaExpenses'; // New key for expenses | |
| const NOTES_STORAGE_KEY = 'puntoDeVentaNotes'; // New key for notes | |
| const ORDERS_STORAGE_KEY = 'puntoDeVentaOrders'; // Key for locally stored orders (for comparison) | |
| const SETTINGS_STORAGE_KEY = 'puntoDeVentaSettings'; // New key for settings | |
| // Default settings for the application. | |
| const defaultSettings = { | |
| showProductCatalog: true, | |
| showReportsSection: true, | |
| showManagementSection: true, | |
| showConfigSection: true, | |
| showSubscriptionPage: true, | |
| showUserManagementPage: true, | |
| showGrammageProductsPage: true, // New premium feature | |
| enableCurrencyManagement: true, // New premium feature | |
| showAnalyticsPage: true, // New premium feature | |
| showOrdersPage: true, // New premium feature | |
| currency: 'MXN', | |
| exchangeRateUSDToMXN: 17.00 | |
| }; | |
| let currentSettings = {}; // Will be populated from localStorage or defaultSettings | |
| /** | |
| * Muestra un modal de confirmación personalizado. | |
| * @param {string} message - El mensaje a mostrar en el modal. | |
| * @param {number} timeout - Tiempo en segundos para auto-confirmar (0 para no auto-confirmar). | |
| * @returns {Promise<boolean>} Una promesa que se resuelve a true si el usuario confirma o si se auto-confirma, false si cancela. | |
| */ | |
| function showCustomConfirm(message, timeout = 0) { | |
| console.log('showCustomConfirm called with message:', message); | |
| const customConfirmModal = document.getElementById('customConfirmModal'); | |
| const customConfirmMessage = document.getElementById('customConfirmMessage'); | |
| const customConfirmYes = document.getElementById('customConfirmYes'); | |
| const customConfirmNo = document.getElementById('customConfirmNo'); | |
| customConfirmMessage.textContent = message; | |
| customConfirmModal.classList.add('show'); | |
| return new Promise((resolve) => { | |
| let timer; | |
| let resolved = false; | |
| const cleanup = () => { | |
| if (timer) clearTimeout(timer); | |
| customConfirmYes.removeEventListener('click', onYesClick); | |
| customConfirmNo.removeEventListener('click', onNoClick); | |
| document.removeEventListener('keydown', onKeyDown); // Eliminar el listener de teclado | |
| customConfirmModal.classList.remove('show'); | |
| }; | |
| const onYesClick = () => { | |
| if (!resolved) { | |
| resolved = true; | |
| cleanup(); | |
| resolve(true); | |
| } | |
| }; | |
| const onNoClick = () => { | |
| if (!resolved) { | |
| resolved = true; | |
| cleanup(); | |
| resolve(false); | |
| } | |
| }; | |
| // Nuevo: Listener de teclado para Enter (confirmar) y Escape (cancelar) | |
| const onKeyDown = (event) => { | |
| if (!resolved) { // Solo manejar si la promesa no ha sido resuelta | |
| if (event.key === 'Enter') { | |
| event.preventDefault(); // Prevenir el comportamiento por defecto (ej. envío de formulario) | |
| onYesClick(); // Tratar Enter como confirmación ("Sí") | |
| } else if (event.key === 'Escape') { | |
| event.preventDefault(); // Prevenir el comportamiento por defecto | |
| onNoClick(); // Tratar Escape como cancelación ("No") | |
| } | |
| } | |
| }; | |
| customConfirmYes.addEventListener('click', onYesClick); | |
| customConfirmNo.addEventListener('click', onNoClick); | |
| document.addEventListener('keydown', onKeyDown); // Añadir el listener de teclado al documento | |
| if (timeout > 0) { | |
| timer = setTimeout(() => { | |
| if (!resolved) { | |
| mostrarMensaje('Alerta de cambio ignorada. Procediendo con la venta.', 'info'); | |
| resolved = true; | |
| cleanup(); | |
| resolve(true); | |
| } | |
| }, timeout * 1000); | |
| } | |
| }); | |
| } | |
| /** | |
| * Muestra u oculta el modal de carga. | |
| * @param {boolean} show - True para mostrar, false para ocultar. | |
| * @param {string} message - Mensaje a mostrar en el modal (opcional). | |
| * @param {boolean} isOrderFetch - Si es true, muestra una alerta tipo "toast" en lugar del modal completo. | |
| */ | |
| function toggleLoadingModal(show, message = 'Cargando...', isOrderFetch = false) { | |
| console.log('toggleLoadingModal called:', show, message, 'isOrderFetch:', isOrderFetch); | |
| const loadingModal = document.getElementById('loadingModal'); | |
| const loadingMessage = loadingModal.querySelector('.loading-message'); | |
| if (isOrderFetch) { | |
| if (show) { | |
| mostrarMensaje(message, 'info'); | |
| } | |
| // For order fetch, the 'hide' part is handled by explicit success/error messages | |
| return; | |
| } | |
| // Default modal behavior for other loading scenarios | |
| loadingMessage.textContent = message; | |
| if (show) { | |
| loadingModal.classList.add('show'); | |
| } else { | |
| loadingModal.classList.remove('show'); | |
| } | |
| } | |
| /** | |
| * Reproduce un sonido de notificación. | |
| */ | |
| function playNotificationSound() { | |
| console.log('playNotificationSound called'); | |
| const audio = new Audio('https://routicket.com/plugins/points/audios/venta1.mp3'); | |
| audio.play().catch(e => console.error("Error playing sound:", e)); | |
| } | |
| /** | |
| * Cambia la página principal visible y actualiza el enlace activo en la barra lateral. | |
| * @param {string} pageId - El ID de la página a mostrar. | |
| */ | |
| function showPage(pageId) { | |
| console.log('showPage called with:', pageId); | |
| document.querySelectorAll('.main-content-page').forEach(page => { | |
| page.classList.remove('active'); | |
| }); | |
| document.getElementById(pageId).classList.add('active'); | |
| // Actualiza los enlaces activos en la barra lateral | |
| document.querySelectorAll('.sidebar-link').forEach(link => { | |
| link.classList.remove('active'); | |
| }); | |
| const activeLink = document.querySelector(`.sidebar-link[data-page="${pageId}"]`); | |
| if (activeLink) { | |
| activeLink.classList.add('active'); | |
| } | |
| // Acciones específicas al cambiar de página | |
| if (pageId === 'stockReportsPage') { | |
| generateStockReports(); | |
| // Asegurarse de que los event listeners se adjunten solo una vez o se remuevan antes de re-adjuntar | |
| document.getElementById('stock-search-out-of-stock').removeEventListener('input', (e) => filterStockTable('outOfStockList', e.target.value)); // Remove previous | |
| document.getElementById('stock-search-low-stock').removeEventListener('input', (e) => filterStockTable('lowStockList', e.target.value)); // Remove previous | |
| document.getElementById('stock-search-out-of-stock').addEventListener('input', (e) => filterStockTable('outOfStockList', e.target.value)); | |
| document.getElementById('stock-search-low-stock').addEventListener('input', (e) => filterStockTable('lowStockList', e.target.value)); | |
| } else if (pageId === 'salesHistoryPage') { | |
| const today = new Date(); | |
| const yesterday = new Date(today); | |
| yesterday.setDate(today.getDate() - 1); | |
| const formatForInput = (date) => { | |
| const y = date.getFullYear(); | |
| const m = String(date.getMonth() + 1).padStart(2, '0'); | |
| const d = String(date.getDate()).padStart(2, '0'); | |
| return `${y}-${m}-${d}`; | |
| }; | |
| document.getElementById('endDate').value = formatForInput(today); | |
| document.getElementById('startDate').value = formatForInput(yesterday); | |
| displaySalesHistory(); | |
| } else if (pageId === 'analyticsPage') { | |
| renderAnalytics(); | |
| } else if (pageId === 'productManagementPage') { | |
| toggleLoadingModal(true, 'Cargando productos...'); | |
| setTimeout(() => { | |
| renderProductManagementList(allProducts); | |
| document.getElementById('product-management-search').removeEventListener('input', handleProductManagementSearch); // Remove previous | |
| document.getElementById('product-management-search').addEventListener('input', handleProductManagementSearch); | |
| toggleLoadingModal(false); | |
| }, 100); | |
| } else if (pageId === 'grammageProductsPage') { | |
| toggleLoadingModal(true, 'Cargando productos por peso...'); | |
| setTimeout(() => { | |
| renderGrammageProductsList(); | |
| document.getElementById('grammage-product-search').removeEventListener('input', (e) => filterGrammageProductsTable(e.target.value)); // Remove previous | |
| document.getElementById('grammage-product-search').addEventListener('input', (e) => filterGrammageProductsTable(e.target.value)); | |
| toggleLoadingModal(false); | |
| }, 100); | |
| } else if (pageId === 'expensesPage') { | |
| renderExpensesList(); | |
| document.getElementById('expense-search').removeEventListener('input', (e) => filterExpensesTable(e.target.value)); // Remove previous | |
| document.getElementById('expense-search').addEventListener('input', (e) => filterExpensesTable(e.target.value)); | |
| } else if (pageId === 'notesPage') { | |
| renderNotesList(); | |
| } else if (pageId === 'ordersPage') { | |
| fetchOrdersFromAPI(); | |
| document.getElementById('order-search').removeEventListener('input', handleOrderSearch); // Remove previous | |
| document.getElementById('order-search').addEventListener('input', handleOrderSearch); | |
| } | |
| else if (pageId === 'subscriptionPage') { | |
| updateSubscriptionStatusUI(); | |
| } else if (pageId === 'settingsPage') { | |
| renderSettingsPage(); | |
| } else if (pageId === 'puntoVentaPage') { | |
| document.getElementById('product-search').focus(); | |
| handleCatalogFilterInput(); | |
| } | |
| } | |
| // --- Inicialización al cargar la página --- | |
| document.addEventListener('DOMContentLoaded', () => { | |
| console.log('DOM Content Loaded. Initializing app...'); | |
| // Cargar productos | |
| const storedProducts = localStorage.getItem(PRODUCTS_STORAGE_KEY); | |
| if (storedProducts) { | |
| try { | |
| allProducts = JSON.parse(storedProducts); | |
| addDefaultAndDummyProducts(); | |
| buildProductLookupMap(); | |
| if (allProducts.length > 0) { | |
| displayProducts(allProducts); | |
| mostrarMensaje(`✔️ Se cargaron ${allProducts.length} productos de la última sesión.`, 'success'); | |
| } else { | |
| document.getElementById('product-list').innerHTML = '<p class="text-center text-muted">No hay productos disponibles. Carga un Excel desde "Cargar Excel".</p>'; | |
| mostrarMensaje('⚠️ No se encontraron productos guardados.', 'warning'); | |
| } | |
| } catch (e) { | |
| console.error("Error parsing localStorage data for products:", e); | |
| mostrarMensaje("❌ Error al cargar productos guardados. Por favor, vuelve a cargar el Excel.", "danger"); | |
| localStorage.removeItem(PRODUCTS_STORAGE_KEY); | |
| document.getElementById('product-list').innerHTML = '<p class="text-center text-muted">Error al cargar productos. Carga un Excel desde "Cargar Excel".</p>'; | |
| } | |
| } else { | |
| addDefaultAndDummyProducts(); | |
| buildProductLookupMap(); | |
| displayProducts(allProducts); | |
| mostrarMensaje('ℹ️ No hay productos guardados. Se agregaron productos de ejemplo. Carga un archivo Excel desde la página anterior.', 'info'); | |
| } | |
| // Cargar carrito guardado | |
| const storedCart = localStorage.getItem(CART_STORAGE_KEY); | |
| if (storedCart) { | |
| try { | |
| cart = JSON.parse(storedCart); | |
| updateCartDisplay(); | |
| mostrarMensaje('🛒 Carrito restaurado de la última sesión.', 'info'); | |
| } catch (e) { | |
| console.error("Error parsing localStorage data for cart:", e); | |
| mostrarMensaje("❌ Error al cargar el carrito guardado.", "danger"); | |
| localStorage.removeItem(CART_STORAGE_KEY); | |
| } | |
| } | |
| // Cargar historial de ventas | |
| const storedSalesHistory = localStorage.getItem(SALES_HISTORY_STORAGE_KEY); | |
| if (storedSalesHistory) { | |
| try { | |
| salesHistory = JSON.parse(storedSalesHistory); | |
| } catch (e) { | |
| console.error("Error parsing localStorage data for sales history:", e); | |
| mostrarMensaje("❌ Error al cargar el historial de ventas.", "danger"); | |
| localStorage.removeItem(SALES_HISTORY_STORAGE_KEY); | |
| } | |
| } | |
| // Cargar gastos | |
| const storedExpenses = localStorage.getItem(EXPENSES_STORAGE_KEY); | |
| if (storedExpenses) { | |
| try { | |
| expenses = JSON.parse(storedExpenses); | |
| } catch (e) { | |
| console.error("Error parsing localStorage data for expenses:", e); | |
| mostrarMensaje("❌ Error al cargar los gastos guardados.", "danger"); | |
| localStorage.removeItem(EXPENSES_STORAGE_KEY); | |
| } | |
| } | |
| // Cargar notas | |
| const storedNotes = localStorage.getItem(NOTES_STORAGE_KEY); | |
| if (storedNotes) { | |
| try { | |
| notes = JSON.parse(storedNotes); | |
| } catch (e) { | |
| console.error("Error parsing localStorage data for notes:", e); | |
| mostrarMensaje("❌ Error al cargar las notas guardadas.", "danger"); | |
| localStorage.removeItem(NOTES_STORAGE_KEY); | |
| } | |
| } | |
| // Load previous order IDs for comparison | |
| const storedPreviousOrderIds = localStorage.getItem(ORDERS_STORAGE_KEY); | |
| if (storedPreviousOrderIds) { | |
| try { | |
| previousOrderIds = new Set(JSON.parse(storedPreviousOrderIds)); | |
| } catch (e) { | |
| console.error("Error parsing localStorage data for previous order IDs:", e); | |
| previousOrderIds = new Set(); | |
| } | |
| } | |
| // Load and apply settings | |
| loadAndApplySettings(); | |
| // --- Event Listeners Globales --- | |
| const productSearchInput = document.getElementById('product-search'); | |
| productSearchInput.addEventListener('keydown', handleBarcodeScan); | |
| productSearchInput.addEventListener('input', handleScannerInputDisplay); | |
| console.log('Event listener attached to product-search (keydown, input)'); | |
| const catalogFilterSearchInput = document.getElementById('catalog-filter-search'); | |
| if (catalogFilterSearchInput) { | |
| catalogFilterSearchInput.addEventListener('input', handleCatalogFilterInput); | |
| console.log('Event listener attached to catalog-filter-search (input)'); | |
| } | |
| document.getElementById('btn-limpiar-carrito').addEventListener('click', async () => { | |
| console.log('btn-limpiar-carrito clicked'); | |
| const confirmed = await showCustomConfirm("¿Estás seguro de que quieres limpiar todo el carrito?"); | |
| if (confirmed) { | |
| clearCart(); | |
| } | |
| }); | |
| console.log('Event listener attached to btn-limpiar-carrito (click)'); | |
| document.getElementById('amountReceived').addEventListener('input', calculateChange); | |
| console.log('Event listener attached to amountReceived (input)'); | |
| document.getElementById('btn-cobrar').addEventListener('click', () => { | |
| console.log('btn-cobrar clicked'); | |
| const totalAmount = parseFloat(document.getElementById('cart-total').textContent); | |
| const amountReceivedInput = document.getElementById('amountReceived'); | |
| if (cart.length === 0) { | |
| mostrarMensaje('El carrito está vacío. No se puede cobrar.', 'warning'); | |
| return; | |
| } | |
| amountReceivedInput.value = totalAmount.toFixed(2); | |
| calculateChange(); | |
| amountReceivedInput.focus(); | |
| mostrarMensaje('Monto total pre-cargado. Presiona Enter para confirmar la venta.', 'info'); | |
| }); | |
| console.log('Event listener attached to btn-cobrar (click)'); | |
| document.getElementById('amountReceived').addEventListener('keydown', (event) => { | |
| console.log('amountReceived keydown:', event.key); | |
| if (event.key === 'Enter') { | |
| event.preventDefault(); | |
| finalizeSale(); | |
| } | |
| }); | |
| console.log('Event listener attached to amountReceived (keydown)'); | |
| // NEW: Global keydown listener for F3 to clear cart and focus scanner | |
| document.addEventListener('keydown', async (event) => { | |
| if (event.key === 'F3') { | |
| event.preventDefault(); | |
| console.log('F3 pressed, clearing cart and focusing scanner'); | |
| const confirmed = await showCustomConfirm("¿Estás seguro de que quieres limpiar todo el carrito?"); | |
| if (confirmed) { | |
| clearCart(); | |
| document.getElementById('product-search').focus(); | |
| mostrarMensaje('Carrito limpiado. Scanner listo.', 'info'); | |
| } | |
| } | |
| }); | |
| // NEW: Global keydown listener for ArrowUp, ArrowDown, +, -, and Enter when product-search is focused | |
| document.addEventListener('keydown', (event) => { | |
| const productSearchInput = document.getElementById('product-search'); | |
| const currentPageId = document.querySelector('.main-content-page.active')?.id; | |
| const rawSearchTerm = productSearchInput.value.trim(); // Get search term here for key logic | |
| // Only apply this complex navigation if on the Punto de Venta page | |
| if (currentPageId === 'puntoVentaPage' && document.activeElement === productSearchInput) { | |
| const productItems = Array.from(document.querySelectorAll('#product-list .product-item')); // Current filtered catalog items | |
| const cartItems = Array.from(document.querySelectorAll('#cart-list .cart-item')); | |
| // Handle ArrowUp/ArrowDown for navigation: ONLY navigate cart if search input is EMPTY | |
| if (['ArrowDown', 'ArrowUp'].includes(event.key) && rawSearchTerm === '') { | |
| event.preventDefault(); // Prevent page scrolling | |
| // If search input is empty, and cart has items, navigate the cart | |
| if (cartItems.length > 0) { | |
| let currentIndex = -1; | |
| if (highlightedCartItemCode) { | |
| currentIndex = cartItems.findIndex(item => item.dataset.code === highlightedCartItemCode); | |
| } | |
| let nextIndex = currentIndex; | |
| if (event.key === 'ArrowDown') { | |
| nextIndex = (currentIndex + 1) % cartItems.length; | |
| } else if (event.key === 'ArrowUp') { | |
| nextIndex = (currentIndex - 1 + cartItems.length) % cartItems.length; | |
| } | |
| if (cartItems[nextIndex]) { | |
| highlightCartItem(cartItems[nextIndex].dataset.code); | |
| cartItems[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } else { | |
| highlightCartItem(null); // Clear highlight | |
| } | |
| highlightProduct(null); // Ensure product catalog highlight is cleared | |
| } else { | |
| mostrarMensaje('No hay productos en el carrito para navegar.', 'info'); | |
| } | |
| } | |
| // Handle '+' and '-' for quantity adjustment / adding to cart | |
| // This will prioritize adding/modifying the HIGHLIGHTED product/cart item | |
| else if (['+', '-'].includes(event.key)) { | |
| event.preventDefault(); | |
| if (highlightedProductCode) { | |
| // If a product in the catalog is highlighted (e.g., by initial type-ahead) | |
| const productCode = highlightedProductCode; | |
| if (event.key === '+') { | |
| addProductToCart(productCode); | |
| } else if (event.key === '-') { | |
| const itemInCart = cart.find(item => item.Cdigo === productCode); | |
| if (itemInCart) { | |
| updateCartQuantity(productCode, -1); | |
| } else { | |
| mostrarMensaje(`El producto "${productLookupMap.get(productCode)?.Producto || productCode}" no está en el carrito para restar.`, 'info'); | |
| } | |
| } | |
| } else if (highlightedCartItemCode) { | |
| // If a product in the cart is highlighted | |
| const productCode = highlightedCartItemCode; | |
| if (event.key === '+') { | |
| updateCartQuantity(productCode, 1); | |
| } else if (event.key === '-') { | |
| updateCartQuantity(productCode, -1); | |
| } | |
| } else { | |
| mostrarMensaje('Primero selecciona un producto en el catálogo o en el carrito con las flechas.', 'warning'); | |
| } | |
| } | |
| // --- MODIFIED: Handle Enter key. Primarily for adding from scanner/manual input. --- | |
| else if (event.key === 'Enter') { | |
| event.preventDefault(); // Prevent form submission | |
| if (rawSearchTerm.length > 0) { | |
| // Determine the primary type of the search term | |
| const isLikelyNumeric = /^\d+$/.test(rawSearchTerm); | |
| const isLikelyAlphabetic = /^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/.test(rawSearchTerm); | |
| let foundProduct = null; | |
| // Search `allProducts` for the best match based on the input field's content | |
| for (const producto of allProducts) { | |
| const nombreProducto = normalizeString(producto.Producto); | |
| const codigoProducto = normalizeString(producto.Cdigo); | |
| const departamentoProducto = normalizeString(producto.Departamento); | |
| if (isLikelyNumeric) { | |
| // For numeric, prefer exact code match, then startsWith | |
| if (codigoProducto === normalizedSearchTerm) { // Exact match is strongest | |
| foundProduct = producto; | |
| break; | |
| } | |
| if (codigoProducto.startsWith(normalizedSearchTerm)) { // Then startsWith | |
| if (!foundProduct) foundProduct = producto; // Take the first startsWith match if no exact | |
| } | |
| } else if (isLikelyAlphabetic) { | |
| // For alphabetic, prefer startsWith for names | |
| if (nombreProducto.startsWith(normalizedSearchTerm)) { | |
| foundProduct = producto; | |
| break; | |
| } | |
| if (departamentoProducto.startsWith(normalizedSearchTerm)) { // Also check department if name doesn't match | |
| if (!foundProduct) foundProduct = producto; | |
| } | |
| } else { | |
| // Mixed or unclear input: search all fields with startsWith, prioritizing code/name | |
| if (codigoProducto.startsWith(normalizedSearchTerm) || | |
| nombreProducto.startsWith(normalizedSearchTerm) || | |
| departamentoProducto.startsWith(normalizedSearchTerm)) { | |
| foundProduct = producto; | |
| break; // Take the first match encountered | |
| } | |
| } | |
| } | |
| if (foundProduct) { | |
| addProductToCart(foundProduct.Cdigo); | |
| productSearchInput.value = ''; // Clear input after adding | |
| highlightProduct(null); // Clear catalog highlight | |
| highlightCartItem(foundProduct.Cdigo); // Optionally highlight the *newly added* item in cart | |
| // Scroll cart to the new item | |
| const addedCartItemElement = document.querySelector(`#cart-list .cart-item[data-code="${foundProduct.Cdigo}"]`); | |
| if (addedCartItemElement) { | |
| addedCartItemElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| } else { | |
| mostrarMensaje('Producto no encontrado con el término de búsqueda actual.', 'warning'); | |
| productSearchInput.value = ''; // Clear input even if not found, to reset | |
| highlightProduct(null); | |
| } | |
| } else { | |
| // If Enter is pressed with an empty search input, and there's a highlighted cart item, increase its quantity | |
| if (highlightedCartItemCode) { | |
| updateCartQuantity(highlightedCartItemCode, 1); | |
| } else { | |
| // mostrarMensaje('Por favor, ingresa un código o nombre de producto para buscar, o selecciona un item del carrito para ajustar.', 'info'); | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // Event listeners for Weight Entry Modal | |
| document.getElementById('addWeightedProductBtn').addEventListener('click', addWeightedProductToCart); | |
| document.getElementById('closeWeightEntryModalBtn').addEventListener('click', closeWeightEntryModal); | |
| console.log('Event listeners attached to weight entry modal buttons'); | |
| document.getElementById('weightProductCode').addEventListener('input', updateWeightedProductInfo); | |
| document.getElementById('weightProductCode').addEventListener('keydown', (event) => { | |
| if (event.key === 'Enter') { | |
| event.preventDefault(); | |
| console.log('weightProductCode Enter pressed'); | |
| const productCode = document.getElementById('weightProductCode').value.trim(); | |
| const product = productLookupMap.get(productCode); | |
| // --- INICIO DE LA LÓGICA AGREGADA (ya existente) --- | |
| const searchResultsContainer = document.getElementById('weightProductSearchResults'); | |
| const searchResults = searchResultsContainer.querySelectorAll('[data-code]'); | |
| if (searchResults.length === 1) { | |
| // Si hay un único resultado, lo seleccionamos automáticamente | |
| const uniqueProductCode = searchResults[0].dataset.code; | |
| document.getElementById('weightProductCode').value = uniqueProductCode; | |
| searchResultsContainer.innerHTML = ''; // Limpiamos los resultados de búsqueda | |
| updateWeightedProductInfo(); // Actualizamos la información del producto seleccionado | |
| // *** MODIFICACIÓN AQUÍ *** | |
| document.getElementById('weightInput').focus(); | |
| document.getElementById('weightInput').select(); // <--- ¡Esta es la clave! | |
| // ************************ | |
| return; // Salimos de la función para no procesar el resto de la lógica | |
| } | |
| // --- FIN DE LA LÓGICA AGREGADA --- | |
| if (product && product.UnitType === 'kg') { | |
| // *** MODIFICACIÓN AQUÍ *** | |
| document.getElementById('weightInput').focus(); | |
| document.getElementById('weightInput').select(); // <--- ¡Y aquí también! | |
| // ************************ | |
| } else { | |
| if (!product) { | |
| mostrarMensaje('Producto no encontrado. Selecciona de la lista o verifica el código.', 'warning'); | |
| } else if (product.UnitType !== 'kg') { | |
| mostrarMensaje(`"${product.Producto}" no es un producto por peso.`, 'warning'); | |
| } | |
| } | |
| } | |
| }); | |
| console.log('Event listeners attached to weightProductCode (input, keydown)'); | |
| document.getElementById('weightInput').addEventListener('input', updateWeightedProductInfo); | |
| document.getElementById('weightInput').addEventListener('keydown', (event) => { | |
| if (event.key === 'Enter') { | |
| event.preventDefault(); | |
| console.log('weightInput Enter pressed'); | |
| addWeightedProductToCart(); | |
| } | |
| }); | |
| console.log('Event listeners attached to weightInput (input, keydown)'); | |
| document.getElementById('weightProductSearchResults').addEventListener('click', (e) => { | |
| console.log('weightProductSearchResults clicked'); | |
| const selectedCode = e.target.dataset.code; | |
| if (selectedCode) { | |
| document.getElementById('weightProductCode').value = selectedCode; | |
| document.getElementById('weightProductSearchResults').innerHTML = ''; | |
| updateWeightedProductInfo(); | |
| // *** MODIFICACIÓN AQUÍ *** | |
| document.getElementById('weightInput').focus(); | |
| document.getElementById('weightInput').select(); // <--- ¡Y también aquí! | |
| // ************************ | |
| } | |
| }); | |
| console.log('Event listener attached to weightProductSearchResults (click)'); | |
| // Product Management Form Buttons | |
| document.getElementById('btnSaveProduct').addEventListener('click', saveProduct); | |
| document.getElementById('btnClearProductForm').addEventListener('click', clearProductForm); | |
| console.log('Event listeners attached to product management form buttons'); | |
| // Expense Form Buttons | |
| document.getElementById('btnAddExpense').addEventListener('click', addExpense); | |
| document.getElementById('btnClearExpenseForm').addEventListener('click', clearExpenseForm); | |
| // Moved expense search listener to showPage for dynamic attachment | |
| console.log('Event listeners attached to expense form buttons'); | |
| // Notes Form Buttons | |
| document.getElementById('btnAddNote').addEventListener('click', addNote); | |
| document.getElementById('btnClearNoteForm').addEventListener('click', clearNoteForm); | |
| console.log('Event listeners attached to notes form buttons'); | |
| // Sales History Filter Button | |
| document.getElementById('filterSalesBtn').addEventListener('click', () => { | |
| console.log('filterSalesBtn clicked'); | |
| displaySalesHistory(); | |
| }); | |
| console.log('Event listener attached to filterSalesBtn'); | |
| // Handle clicks on the sidebar to change pages | |
| document.querySelectorAll('.sidebar-link').forEach(link => { | |
| link.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| console.log('Sidebar link clicked:', e.currentTarget.dataset.page); | |
| showPage(e.currentTarget.dataset.page); | |
| }); | |
| }); | |
| console.log('Event listeners attached to sidebar links'); | |
| // Settings page save button | |
| document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); | |
| console.log('Event listener attached to saveSettingsBtn'); | |
| // Shortcuts sidebar link | |
| document.getElementById('shortcutsLinkSidebar').addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| console.log('shortcutsLinkSidebar clicked'); | |
| showPage('shortcutsPage'); | |
| }); | |
| console.log('Event listener attached to shortcutsLinkSidebar'); | |
| // Header "Nuevos Pedidos" link | |
| document.getElementById('newOrdersNotification').addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| console.log('newOrdersNotification (header) clicked'); | |
| showPage('ordersPage'); // Navigate to orders page | |
| }); | |
| updateSubscriptionStatusUI(); | |
| // Ensure product search input is focused after all initializations | |
| productSearchInput.focus(); | |
| // Set default payment method | |
| document.getElementById('paymentCash').checked = true; | |
| // Initial fetch for orders and set interval | |
| fetchOrdersFromAPI(); | |
| setInterval(fetchOrdersFromAPI, 100000); | |
| console.log('Order fetching interval set.'); | |
| console.log('App initialization complete.'); | |
| }); | |
| /** | |
| * Builds a map for quick product lookup by code. | |
| */ | |
| function buildProductLookupMap() { | |
| console.log('buildProductLookupMap called'); | |
| productLookupMap.clear(); | |
| allProducts.forEach(p => { | |
| productLookupMap.set(p.Cdigo, p); | |
| }); | |
| } | |
| /** | |
| * Muestra los productos en el catálogo del Punto de Venta. | |
| * @param {Array<Object>} productsToDisplay - Array de productos a mostrar. | |
| */ | |
| function displayProducts(productsToDisplay) { | |
| console.log('displayProducts called with', productsToDisplay.length, 'products.'); | |
| const productListDiv = document.getElementById('product-list'); | |
| let html = ''; | |
| if (productsToDisplay.length === 0) { | |
| productListDiv.innerHTML = '<p class="text-center text-muted">No se encontraron productos con esa búsqueda.</p>'; | |
| highlightProduct(null); // Clear any existing highlight | |
| return; | |
| } | |
| productsToDisplay.forEach((producto, index) => { | |
| const nombreProducto = String(producto.Producto || producto.Cdigo || 'Producto Desconocido').trim(); | |
| const precioVenta = typeof producto.PVenta === 'number' ? producto.PVenta : parseFloat(producto.PVenta) || 0; | |
| const existencia = typeof producto.Existencia === 'number' ? producto.Existencia : parseFloat(producto.Existencia) || 0; | |
| const unitType = producto.UnitType || 'unit'; | |
| html += ` | |
| <div class="product-item" data-code="${producto.Cdigo}" data-index="${index}"> | |
| <div class="product-info"> | |
| <div class="product-name">${nombreProducto}</div> | |
| <div class="product-code text-muted">Cód: ${producto.Cdigo || 'N/A'}</div> | |
| <div class="product-price">Precio: ${currentCurrencySymbol}${precioVenta.toFixed(2)} ${unitType === 'kg' ? '/ kg' : ''}</div> | |
| <div class="product-stock ${existencia <= 0 ? 'text-danger font-weight-bold' : (existencia < 10 ? 'text-warning' : '')}"> | |
| Exist: ${existencia.toFixed(unitType === 'kg' ? 3 : 0)} ${unitType === 'kg' ? 'kg' : 'uds'} ${existencia <= 0 ? '(AGOTADO)' : (existencia < 10 ? '(BAJO)' : '')} | |
| </div> | |
| </div> | |
| <button class="btn btn-primary btn-sm add-to-cart-btn" data-code="${producto.Cdigo}"> | |
| <i class="fas fa-cart-plus"></i> | |
| </button> | |
| </div> | |
| `; | |
| }); | |
| productListDiv.innerHTML = html; | |
| // After rendering, try to re-highlight the previously highlighted product if it's still in the list | |
| if (highlightedProductCode) { | |
| const currentHighlightedElement = document.querySelector(`.product-item[data-code="${highlightedProductCode}"]`); | |
| if (currentHighlightedElement) { | |
| highlightProduct(highlightedProductCode); // Re-apply highlight to the new DOM element | |
| } else { | |
| highlightProduct(null); // Clear if no longer in displayed list | |
| } | |
| } | |
| // Remove existing listeners before adding new ones to prevent duplicates | |
| document.querySelectorAll('.add-to-cart-btn').forEach(button => { | |
| const newButton = button.cloneNode(true); | |
| button.parentNode.replaceChild(newButton, button); | |
| }); | |
| document.querySelectorAll('.add-to-cart-btn').forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| console.log('Add to cart button clicked for code:', e.currentTarget.dataset.code); | |
| const productCode = e.currentTarget.dataset.code; | |
| addProductToCart(productCode); | |
| }); | |
| }); | |
| console.log('Event listeners attached to .add-to-cart-btn'); | |
| } | |
| /** | |
| * Highlights a product element in the list and scrolls it into view. | |
| * Now takes a productCode (string) and finds the corresponding element. | |
| * @param {string | null} productCode - The code of the product to highlight, or null to clear. | |
| */ | |
| function highlightProduct(productCode) { | |
| // Clear previous highlight | |
| if (highlightedProductCode) { | |
| const prevHighlightedElement = document.querySelector(`.product-item[data-code="${highlightedProductCode}"]`); | |
| if (prevHighlightedElement) { | |
| prevHighlightedElement.classList.remove('highlighted'); | |
| } | |
| } | |
| // Apply new highlight | |
| if (productCode) { | |
| const newHighlightedElement = document.querySelector(`.product-item[data-code="${productCode}"]`); | |
| if (newHighlightedElement) { | |
| newHighlightedElement.classList.add('highlighted'); | |
| newHighlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| highlightedProductCode = productCode; | |
| } else { | |
| highlightedProductCode = null; // Product not found in current view | |
| } | |
| } else { | |
| highlightedProductCode = null; // Clear highlight | |
| } | |
| } | |
| /** | |
| * NEW: Highlights a cart item element in the list and scrolls it into view. | |
| * Now takes an itemCode (string) and finds the corresponding element. | |
| * @param {string | null} itemCode - The code of the cart item to highlight, or null to clear. | |
| */ | |
| function highlightCartItem(itemCode) { | |
| // Clear previous highlight | |
| if (highlightedCartItemCode) { | |
| const prevHighlightedElement = document.querySelector(`.cart-item[data-code="${highlightedCartItemCode}"]`); | |
| if (prevHighlightedElement) { | |
| prevHighlightedElement.classList.remove('highlighted'); | |
| } | |
| } | |
| // Apply new highlight | |
| if (itemCode) { | |
| const newHighlightedElement = document.querySelector(`.cart-item[data-code="${itemCode}"]`); | |
| if (newHighlightedElement) { | |
| newHighlightedElement.classList.add('highlighted'); | |
| newHighlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| highlightedCartItemCode = itemCode; | |
| } else { | |
| highlightedCartItemCode = null; // Item not found in current cart view | |
| } | |
| } else { | |
| highlightedCartItemCode = null; // Clear highlight | |
| } | |
| } | |
| /** | |
| * Handles keyboard input to simulate barcode scanning. | |
| * Uses productLookupMap for O(1) lookup, which is very fast. | |
| * @param {Event} event - The keyboard event. | |
| */ | |
| function handleBarcodeScan(event) { | |
| console.log('handleBarcodeScan called, key:', event.key); | |
| const searchInput = document.getElementById('product-search'); | |
| const currentTime = Date.now(); | |
| // Reset buffer if significant time has passed since last key press | |
| if (currentTime - lastScanTime > 50 && barcodeScanBuffer.length > 0) { | |
| barcodeScanBuffer = ''; | |
| } | |
| lastScanTime = currentTime; | |
| if (event.key === 'Enter') { | |
| event.preventDefault(); | |
| const scannedCode = searchInput.value.trim(); | |
| console.log('Enter pressed, scannedCode:', scannedCode); | |
| if (scannedCode.length > 0) { | |
| const productFound = productLookupMap.get(scannedCode); | |
| if (productFound) { | |
| if (productFound.UnitType === 'kg') { | |
| openWeightEntryModal(productFound.Cdigo, false); | |
| } else { | |
| addProductToCart(productFound.Cdigo); | |
| } | |
| searchInput.value = ''; | |
| // No focus to catalog filter here, focus remains on product-search | |
| searchInput.focus(); | |
| } else { | |
| mostrarMensaje(`Producto no encontrado con el código exacto: "${scannedCode}".`, 'danger'); | |
| searchInput.value = ''; | |
| searchInput.focus(); | |
| } | |
| } | |
| barcodeScanBuffer = ''; // Clear buffer after processing Enter | |
| } else { | |
| barcodeScanBuffer += event.key; | |
| } | |
| } | |
| /** | |
| * Handles input in the scanner search field to update its display. | |
| */ | |
| function handleScannerInputDisplay() { | |
| // console.log('handleScannerInputDisplay called'); // Too verbose, keep commented | |
| } | |
| /** | |
| * Handles input in the new catalog filter search field. | |
| */ | |
| function handleCatalogFilterInput() { | |
| console.log('handleCatalogFilterInput called'); | |
| const catalogFilterInput = document.getElementById('catalog-filter-search'); | |
| const rawSearchTerm = catalogFilterInput.value.trim(); | |
| const normalizedSearchTerm = normalizeString(rawSearchTerm); // Assuming normalizeString handles accents, case, etc. | |
| if (rawSearchTerm.length > 0) { | |
| // Determine the primary type of the search term | |
| const isLikelyNumeric = /^\d+$/.test(rawSearchTerm); // Entirely digits | |
| const isLikelyAlphabetic = /^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/.test(rawSearchTerm); // Primarily letters and spaces | |
| const filteredProducts = allProducts.filter(producto => { | |
| const nombreProducto = normalizeString(producto.Producto); | |
| const codigoProducto = normalizeString(producto.Cdigo); | |
| const departamentoProducto = normalizeString(producto.Departamento); | |
| // --- Prioritize search based on detected input type --- | |
| if (isLikelyNumeric) { | |
| // If the input is primarily numeric, prioritize searching in Cdigo | |
| // and ensure it starts with the search term. | |
| return codigoProducto.startsWith(normalizedSearchTerm); | |
| } else if (isLikelyAlphabetic) { | |
| // If the input is primarily alphabetic, prioritize searching in Nombre and Departamento | |
| // and ensure it starts with the search term. | |
| return nombreProducto.startsWith(normalizedSearchTerm) || | |
| departamentoProducto.startsWith(normalizedSearchTerm); | |
| } else { | |
| // If it's mixed or hard to classify (e.g., "AB123"), search across all fields | |
| // using startsWith, giving preference to the beginning of the string. | |
| return nombreProducto.startsWith(normalizedSearchTerm) || | |
| codigoProducto.startsWith(normalizedSearchTerm) || | |
| departamentoProducto.startsWith(normalizedSearchTerm); | |
| } | |
| }); | |
| displayProducts(filteredProducts); | |
| } else { | |
| // If the search term is empty, display all products | |
| displayProducts(allProducts); | |
| } | |
| } | |
| /** | |
| * Adds a product to the cart or increments its quantity if it already exists. | |
| * This function handles unit-based products. For 'kg' products, it directs to the weight entry modal. | |
| * @param {string} productCode - The code of the product to add. | |
| * @param {number} [quantityToAdd=1] - The quantity to add (defaults to 1 for unit products). | |
| */ | |
| function addProductToCart(productCode, quantityToAdd = 1) { // Added quantityToAdd for flexibility | |
| console.log('addProductToCart called with code:', productCode, 'quantity:', quantityToAdd); | |
| const productToAdd = productLookupMap.get(productCode); | |
| // 1. Validar si el producto existe | |
| if (!productToAdd) { | |
| mostrarMensaje('Producto no encontrado. Asegúrate de que el código sea exacto.', 'danger'); | |
| return; | |
| } | |
| // 2. Manejar productos por peso (redirigir al modal específico) | |
| if (productToAdd.UnitType === 'kg') { | |
| mostrarMensaje('Este producto se vende por peso. Abriendo la ventana de peso...', 'info'); | |
| // Usar 'true' para enfocar directamente el input de peso, más fluido para escaner/atajos | |
| openWeightEntryModal(productCode, true); | |
| return; | |
| } | |
| // Asegurar que la cantidad a añadir sea válida y positiva | |
| if (isNaN(quantityToAdd) || quantityToAdd <= 0) { | |
| console.warn('Intento de añadir cantidad inválida:', quantityToAdd); | |
| mostrarMensaje('Cantidad a añadir no válida.', 'warning'); | |
| return; | |
| } | |
| const precioVenta = typeof productToAdd.PVenta === 'number' ? productToAdd.PVenta : parseFloat(productToAdd.PVenta) || 0; | |
| const existenciaActual = typeof productToAdd.Existencia === 'number' ? productToAdd.Existencia : parseFloat(productToAdd.Existencia) || 0; | |
| const existingCartItemIndex = cart.findIndex(item => item.Cdigo === productCode); | |
| let currentItemQuantityInCart = 0; | |
| if (existingCartItemIndex !== -1) { | |
| currentItemQuantityInCart = cart[existingCartItemIndex].quantity; | |
| } | |
| const newQuantityInCart = currentItemQuantityInCart + quantityToAdd; | |
| // 3. **Consolidar la validación y mensajes de stock** | |
| // Esto evita múltiples llamadas a mostrarMensaje y asegura un único mensaje relevante. | |
| let finalMessage = ''; | |
| let messageType = 'success'; | |
| if (newQuantityInCart > existenciaActual) { | |
| if (existenciaActual <= 0) { | |
| finalMessage = `⚠️ ¡Alerta de Stock 0! El producto "${String(productToAdd.Producto || productToAdd.Cdigo).trim()}" no tiene existencia disponible.`; | |
| messageType = 'warning'; | |
| } else { | |
| finalMessage = `¡Sobregiro! Solo hay ${existenciaActual.toFixed(0)} unidades de "${String(productToAdd.Producto || productToAdd.Cdigo).trim()}" disponibles (ya tienes ${currentItemQuantityInCart.toFixed(0)} en el carrito).`; | |
| messageType = 'warning'; | |
| } | |
| } else if (existenciaActual < 10) { | |
| finalMessage = `🔔 "${String(productToAdd.Producto || productToAdd.Cdigo).trim()}" añadido. ¡Stock bajo: ${existenciaActual.toFixed(0)} unidades restantes!`; | |
| messageType = 'info'; | |
| } else { | |
| // Mensaje estándar si no hay alertas de stock | |
| finalMessage = `"${String(productToAdd.Producto || productToAdd.Cdigo).trim()}" añadido al carrito.`; | |
| messageType = 'success'; | |
| } | |
| // 4. Actualizar el array del carrito (añadir nuevo ítem o actualizar existente) | |
| if (existingCartItemIndex !== -1) { | |
| cart[existingCartItemIndex].quantity = newQuantityInCart; | |
| } else { | |
| cart.unshift({ | |
| Cdigo: productToAdd.Cdigo, | |
| Producto: productToAdd.Producto, | |
| Departamento: productToAdd.Departamento, | |
| quantity: newQuantityInCart, | |
| precioUnitario: precioVenta, | |
| UnitType: productToAdd.UnitType | |
| }); | |
| } | |
| // 5. **Actualizar el stock en `allProducts` (para feedback visual inmediato)** | |
| // Esta es la parte que decrementa el stock de tu inventario en memoria. | |
| // Como hemos hablado, si deseas una gestión de inventario estricta solo al finalizar la venta, | |
| // esta sección debería ir en la función `finalizeSale`. | |
| // Si la mantienes aquí, se verá reflejado inmediatamente en el catálogo. | |
| const productInAllProductsIndex = allProducts.findIndex(p => p.Cdigo === productCode); | |
| if (productInAllProductsIndex !== -1) { | |
| allProducts[productInAllProductsIndex].Existencia -= quantityToAdd; | |
| if (allProducts[productInAllProductsIndex].Existencia < 0) { | |
| allProducts[productInAllProductsIndex].Existencia = 0; // Evitar stock negativo en la interfaz | |
| } | |
| } | |
| // Mostrar el mensaje consolidado al final | |
| mostrarMensaje(finalMessage, messageType); | |
| // 6. **Actualizaciones de la Interfaz de Usuario (UI)** | |
| // Estas son las llamadas más críticas para la fluidez. | |
| // Es vital que estas funciones (especialmente `updateCartDisplay` y `handleCatalogFilterInput`) | |
| // estén optimizadas para minimizar el redibujado del DOM. | |
| updateCartDisplay(); | |
| handleCatalogFilterInput(); // Re-renderiza el catálogo para reflejar cambios de stock | |
| generateStockReports(); // Asegura que los reportes de stock también se actualicen | |
| renderGrammageProductsList(); // Actualiza la lista de productos por peso si aplica | |
| } | |
| /** | |
| * Updates the quantity of a product in the cart. | |
| * @param {string} productCode - The product code. | |
| * @param {number} change - The quantity to add or subtract (e.g., 1 or -1). | |
| */ | |
| function updateCartQuantity(productCode, change) { | |
| console.log('updateCartQuantity called for:', productCode, 'change:', change); | |
| // Find the item in the cart and the product in the lookup map | |
| const itemIndex = cart.findIndex(item => item.Cdigo === productCode); | |
| const productInAllProducts = productLookupMap.get(productCode); | |
| // If product not found in allProducts, we can't update stock accurately. | |
| // Log an error and exit, or handle as appropriate for your application. | |
| if (!productInAllProducts) { | |
| console.error(`Product with code ${productCode} not found in productLookupMap.`); | |
| return; // Exit if product data is missing | |
| } | |
| // Ensure Existencia is a number | |
| let existenciaActual = typeof productInAllProducts.Existencia === 'number' | |
| ? productInAllProducts.Existencia | |
| : parseFloat(productInAllProducts.Existencia) || 0; | |
| let oldQuantity = 0; // Initialize oldQuantity for new items | |
| let newQuantity; | |
| if (itemIndex > -1) { | |
| const item = cart[itemIndex]; | |
| oldQuantity = item.quantity; | |
| newQuantity = oldQuantity + change; | |
| // Prevent quantity from going below 0, especially for manual changes | |
| if (newQuantity < 0) { | |
| newQuantity = 0; | |
| } | |
| // Calculate the proposed stock after this change | |
| const stockAfterChange = existenciaActual + (oldQuantity - newQuantity); | |
| // *** MODIFICACIÓN CLAVE AQUÍ: Permite la adición, pero alerta si el stock es negativo *** | |
| if (change > 0 && stockAfterChange < 0) { | |
| mostrarMensaje(`¡Atención! Has añadido "${item.Nombre}" y el stock disponible es: ${existenciaActual}. La venta se realizará sin inventario físico.`, 'warning'); | |
| // No retornamos, la operación continúa y el stock se volverá negativo. | |
| } | |
| // Apply the changes to stock and quantity | |
| if (newQuantity <= 0) { | |
| // Item fully removed | |
| removeFromCart(productCode); // This function should also update UI/DOM | |
| // Add back the old quantity to stock | |
| productInAllProducts.Existencia = existenciaActual + oldQuantity; | |
| } else { | |
| // Update quantity | |
| item.quantity = newQuantity; | |
| // Update stock based on the calculated stockAfterChange | |
| productInAllProducts.Existencia = stockAfterChange; | |
| // No forzamos que el stock sea 0 aquí si se desea permitir stock negativo. | |
| // Si realmente quieres que el stock no baje de 0, vuelve a añadir: | |
| // if (productInAllProducts.Existencia < 0) { productInAllProducts.Existencia = 0; } | |
| // Pero el requisito es permitir la venta sin stock, lo que implica stock negativo. | |
| } | |
| } else if (change > 0) { | |
| // If the item isn't in the cart but we're trying to add (change > 0) | |
| // This scenario typically handled by addProductToCart, but if this function | |
| // is also used for initial adds, we need to handle it. | |
| // Assuming 'change' here is typically 1 for an initial add. | |
| newQuantity = change; // Starting quantity for a new item is 'change' (e.g., 1) | |
| // Calculate proposed stock for a new item being added | |
| const stockAfterChange = existenciaActual - newQuantity; | |
| // *** MODIFICACIÓN CLAVE AQUÍ PARA NUEVOS PRODUCTOS: Alerta si el stock es negativo *** | |
| if (stockAfterChange < 0) { | |
| mostrarMensaje(`¡Atención! Estás añadiendo "${productInAllProducts.Nombre}" y el stock disponible es: ${existenciaActual}. La venta se realizará sin inventario físico.`, 'warning'); | |
| } | |
| // Add the new item to the cart | |
| cart.push({ | |
| Cdigo: productCode, | |
| Nombre: productInAllProducts.Nombre, // Assuming productInAllProducts has Nombre | |
| Precio: productInAllProducts.Precio, // Assuming productInAllProducts has Precio | |
| quantity: newQuantity | |
| }); | |
| // Update stock based on the calculated stockAfterChange | |
| productInAllProducts.Existencia = stockAfterChange; | |
| } else { | |
| // If item not found and change is negative (e.g., trying to remove a non-existent item) | |
| console.warn(`Attempted to remove non-existent item ${productCode} from cart.`); | |
| return; | |
| } | |
| // --- Single UI Update Point --- | |
| // Only update the display and storage once per function call. | |
| updateCartDisplay(); // Re-render the cart display | |
| localStorage.setItem(PRODUCTS_STORAGE_KEY, JSON.stringify(allProducts)); // Save updated stock | |
| handleCatalogFilterInput(); // Re-render catalog to show stock changes (if necessary) | |
| } | |
| /** | |
| * Handles cell editing in stock tables. | |
| * @param {Event} event - The blur or keydown event. | |
| */ | |
| function handleStockCellEdit(event) { | |
| console.log('handleStockCellEdit called'); | |
| const cell = event.target; | |
| const row = cell.closest('tr'); | |
| const productCode = row.dataset.code; | |
| const field = cell.dataset.field; | |
| let newValue = cell.textContent.trim(); | |
| const productIndex = allProducts.findIndex(p => p.Cdigo === productCode); | |
| if (productIndex === -1) { | |
| mostrarMensaje('Error: Producto no encontrado para actualizar.', 'danger'); | |
| return; | |
| } | |
| if (field === 'Existencia') { | |
| newValue = parseFloat(newValue); | |
| if (isNaN(newValue) || newValue < 0) { | |
| mostrarMensaje('Error: La existencia debe ser un número positivo.', 'danger'); | |
| cell.textContent = allProducts[productIndex].Existencia.toFixed(allProducts[productIndex].UnitType === 'kg' ? 3 : 0); | |
| return; | |
| } | |
| } | |
| allProducts[productIndex][field] = newValue; | |
| localStorage.setItem(PRODUCTS_STORAGE_KEY, JSON.stringify(allProducts)); | |
| buildProductLookupMap(); | |
| mostrarMensaje(`Producto "${allProducts[productIndex].Producto}" actualizado: ${field} a ${newValue}.`, 'success'); | |
| generateStockReports(); | |
| handleCatalogFilterInput(); | |
| renderProductManagementList(allProducts); | |
| renderGrammageProductsList(); | |
| } | |
| function renderProductManagementList(productsToDisplay) { | |
| console.log('renderProductManagementList called with', productsToDisplay.length, 'products.'); | |
| const productManagementListDiv = document.getElementById('productManagementList'); | |
| let html = ''; | |
| if (productsToDisplay.length === 0) { | |
| productManagementListDiv.innerHTML = '<p class="text-center text-muted">No se encontraron productos.</p>'; | |
| return; | |
| } | |
| productsToDisplay.forEach(producto => { | |
| const nombreProducto = String(producto.Producto || 'Producto Desconocido').trim(); | |
| const precioVenta = typeof producto.PVenta === 'number' ? producto.PVenta : parseFloat(producto.PVenta) || 0; | |
| const existencia = typeof producto.Existencia === 'number' ? producto.Existencia : parseFloat(producto.Existencia) || 0; | |
| const departamento = String(producto.Departamento || 'N/A').trim(); | |
| const unitType = producto.UnitType || 'unit'; | |
| html += ` | |
| <div class="product-management-item" data-code="${producto.Cdigo}"> | |
| <div class="product-info"> | |
| <div class="product-name"> | |
| <input type="text" value="${nombreProducto}" class="form-control-plaintext product-name-input" data-field="Producto" readonly> | |
| </div> | |
| <div class="product-code text-muted"> | |
| Cód: <input type="text" value="${producto.Cdigo || 'N/A'}" class="form-control-plaintext product-code-input" data-field="Cdigo" readonly> | |
| </div> | |
| <div class="product-department text-muted"> | |
| Depto: <input type="text" value="${departamento}" class="form-control-plaintext product-department-input" data-field="Departamento" readonly> | |
| </div> | |
| <div class="product-price"> | |
| Precio: ${currentCurrencySymbol}<input type="number" value="${precioVenta.toFixed(2)}" step="0.01" class="form-control-plaintext product-price-input" data-field="PVenta" readonly> | |
| </div> | |
| <div class="product-stock"> | |
| Exist: <input type="number" value="${existencia.toFixed(unitType === 'kg' ? 3 : 0)}" step="${unitType === 'kg' ? '0.001' : '1'}" class="form-control-plaintext product-stock-input ${existencia <= 0 ? 'text-danger font-weight-bold' : (existencia < 10 ? 'text-warning' : '')}" data-field="Existencia" readonly> | |
| </div> | |
| <div class="product-unit-type"> | |
| Unidad: | |
| <select class="form-control-plaintext product-unit-type-select" data-field="UnitType" disabled> | |
| <option value="unit" ${unitType === 'unit' ? 'selected' : ''}>Unidad</option> | |
| <option value="kg" ${unitType === 'kg' ? 'selected' : ''}>Kilogramo (kg)</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="product-actions"> | |
| <button class="btn btn-sm btn-info edit-product-btn" data-code="${producto.Cdigo}"> | |
| <i class="fas fa-edit"></i> | |
| </button> | |
| <button class="btn btn-sm btn-success save-product-changes-btn d-none" data-code="${producto.Cdigo}"> | |
| <i class="fas fa-check"></i> | |
| </button> | |
| <button class="btn btn-sm btn-danger delete-product-btn" data-code="${producto.Cdigo}"> | |
| <i class="fas fa-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| productManagementListDiv.innerHTML = html; | |
| // Remove existing listeners before adding new ones to prevent duplicates | |
| document.querySelectorAll('.edit-product-btn').forEach(button => { | |
| const newButton = button.cloneNode(true); | |
| button.parentNode.replaceChild(newButton, button); | |
| }); | |
| document.querySelectorAll('.save-product-changes-btn').forEach(button => { | |
| const newButton = button.cloneNode(true); | |
| button.parentNode.replaceChild(newButton, button); | |
| }); | |
| document.querySelectorAll('.delete-product-btn').forEach(button => { | |
| const newButton = button.cloneNode(true); | |
| button.parentNode.replaceChild(newButton, button); | |
| }); | |
| document.querySelectorAll('.edit-product-btn').forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| console.log('Edit product button clicked for code:', e.currentTarget.dataset.code); | |
| editProductInList(e.currentTarget.dataset.code); | |
| }); | |
| }); | |
| document.querySelectorAll('.save-product-changes-btn').forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| console.log('Save product changes button clicked for code:', e.currentTarget.dataset.code); | |
| saveProductChangesInList(e.currentTarget.dataset.code); | |
| }); | |
| }); | |
| document.querySelectorAll('.delete-product-btn').forEach(button => { | |
| button.addEventListener('click', async (e) => { | |
| console.log('Delete product button clicked for code:', e.currentTarget.dataset.code); | |
| const productCode = e.currentTarget.dataset.code; | |
| const confirmed = await showCustomConfirm(`¿Estás seguro de que quieres eliminar el producto con código "${productCode}"? Esta acción no se puede deshacer.`); | |
| if (confirmed) { | |
| deleteProduct(productCode); | |
| } | |
| }); | |
| }); | |
| } | |
| // --- Functions for Grammage Products List --- | |
| function renderGrammageProductsList(filteredProducts = null) { | |
| console.log('renderGrammageProductsList called'); | |
| const grammageProductListDiv = document.getElementById('grammageProductList'); | |
| if (!isPremiumUser || !currentSettings.showGrammageProductsPage) { | |
| grammageProductListDiv.innerHTML = '<p class="text-center text-muted">La función de "Productos por Peso" es una característica Premium. ¡Actualiza tu suscripción!</p>'; | |
| return; | |
| } | |
| const kgProducts = allProducts.filter(p => p.UnitType === 'kg'); | |
| let productsToDisplay = filteredProducts || kgProducts; | |
| let html = '<table class="report-table"><thead><tr><th>Código</th><th>Producto</th><th>Departamento</th><th>Precio/kg</th><th>Existencia (kg)</th></tr></thead><tbody>'; | |
| if (productsToDisplay.length === 0) { | |
| html += '<tr><td colspan="5" class="text-center text-muted">No hay productos configurados para venta por peso que coincidan con la búsqueda.</td></tr>'; | |
| } else { | |
| productsToDisplay.forEach(p => { | |
| const existenciaClass = p.Existencia <= 0 ? 'text-danger font-weight-bold' : (p.Existencia < 10 ? 'text-warning' : ''); | |
| html += ` | |
| <tr data-code="${p.Cdigo}"> | |
| <td>${p.Cdigo || 'N/A'}</td> | |
| <td>${String(p.Producto || 'Producto Desconocido').trim()}</td> | |
| <td>${p.Departamento || 'N/A'}</td> | |
| <td>${currentCurrencySymbol}${p.PVenta.toFixed(2)}</td> | |
| <td class="${existenciaClass}">${p.Existencia.toFixed(3)}</td> | |
| </tr> | |
| `; | |
| }); | |
| } | |
| html += '</tbody></table>'; | |
| grammageProductListDiv.innerHTML = html; | |
| } | |
| function filterGrammageProductsTable(searchTerm) { | |
| console.log('filterGrammageProductsTable called with term:', searchTerm); | |
| const normalizedSearchTerm = normalizeString(searchTerm); | |
| const kgProducts = allProducts.filter(p => p.UnitType === 'kg'); | |
| if (normalizedSearchTerm) { | |
| const filtered = kgProducts.filter(p => { | |
| const nombreProducto = normalizeString(p.Producto); | |
| const codigoProducto = normalizeString(p.Cdigo); | |
| const departamentoProducto = normalizeString(p.Departamento); | |
| return nombreProducto.includes(normalizedSearchTerm) || | |
| codigoProducto.includes(normalizedSearchTerm) || | |
| departamentoProducto.includes(normalizedSearchTerm); | |
| }); | |
| renderGrammageProductsList(filtered); | |
| } else { | |
| renderGrammageProductsList(kgProducts); | |
| } | |
| } | |
| function downloadExpensesReport(format = 'csv') { | |
| console.log('downloadExpensesReport called, format:', format); | |
| if (expenses.length === 0) { | |
| mostrarMensaje('No hay gastos para descargar.', 'warning'); | |
| return; | |
| } | |
| const dataToDownload = expenses.map(e => ({ | |
| "Fecha": e.date, | |
| "Descripción": e.description, | |
| "Categoría": e.category, | |
| "Monto": `${e.currency === 'USD' ? 'US$' : '$'}${parseFloat(e.amount).toFixed(2)}`, | |
| "Moneda": e.currency | |
| })); | |
| if (format === 'csv') { | |
| downloadCSV(dataToDownload, 'reporte_gastos.csv'); | |
| } else if (format === 'xlsx') { | |
| downloadXLSX(dataToDownload, 'reporte_gastos.xlsx'); | |
| } | |
| } | |
| function renderSettingsPage() { | |
| console.log('renderSettingsPage called'); | |
| document.getElementById('toggleProductCatalog').checked = currentSettings.showProductCatalog; | |
| document.getElementById('toggleReportsSection').checked = currentSettings.showReportsSection; | |
| document.getElementById('toggleManagementSection').checked = currentSettings.showManagementSection; | |
| document.getElementById('toggleConfigSection').checked = currentSettings.showConfigSection; | |
| document.getElementById('toggleSubscriptionPage').checked = currentSettings.showSubscriptionPage; | |
| document.getElementById('toggleUserManagementPage').checked = currentSettings.showUserManagementPage; | |
| const toggleGrammageProductsPage = document.getElementById('toggleGrammageProductsPage'); | |
| if (toggleGrammageProductsPage) { | |
| toggleGrammageProductsPage.checked = currentSettings.showGrammageProductsPage; | |
| toggleGrammageProductsPage.disabled = !isPremiumUser; // Disable if not premium | |
| } | |
| const toggleCurrencyManagement = document.getElementById('toggleCurrencyManagement'); | |
| if (toggleCurrencyManagement) { | |
| toggleCurrencyManagement.checked = currentSettings.enableCurrencyManagement; | |
| toggleCurrencyManagement.disabled = !isPremiumUser; // Disable if not premium | |
| } | |
| const toggleAdvancedAnalytics = document.getElementById('toggleAdvancedAnalytics'); | |
| if (toggleAdvancedAnalytics) { | |
| toggleAdvancedAnalytics.checked = currentSettings.showAnalyticsPage; | |
| toggleAdvancedAnalytics.disabled = !isPremiumUser; // Disable if not premium | |
| } | |
| const toggleOrdersPage = document.getElementById('toggleOrdersPage'); | |
| if (toggleOrdersPage) { | |
| toggleOrdersPage.checked = currentSettings.showOrdersPage; | |
| toggleOrdersPage.disabled = !isPremiumUser; // Disable if not premium | |
| } | |
| // Disable currency options if currency management is not enabled or not premium | |
| document.getElementById('currencyMXN').disabled = !(currentSettings.enableCurrencyManagement && isPremiumUser); | |
| document.getElementById('currencyUSD').disabled = !(currentSettings.enableCurrencyManagement && isPremiumUser); | |
| document.getElementById('exchangeRateInput').disabled = !(currentSettings.enableCurrencyManagement && isPremiumUser); | |
| document.getElementById('currencyMXN').checked = currentSettings.currency === 'MXN'; | |
| document.getElementById('currencyUSD').checked = currentSettings.currency === 'USD'; | |
| document.getElementById('exchangeRateInput').value = currentSettings.exchangeRateUSDToMXN.toFixed(2); | |
| } | |
| const style = getComputedStyle(document.body); | |
| const varOfficePrimaryBlue = style.getPropertyValue('--office-primary-blue').trim(); | |
| function copyToClipboard(text) { | |
| console.log('copyToClipboard called'); | |
| const textarea = document.createElement('textarea'); | |
| textarea.value = text; | |
| document.body.appendChild(textarea); | |
| textarea.select(); | |
| try { | |
| document.execCommand('copy'); | |
| mostrarMensaje('Copiado al portapapeles.', 'success'); | |
| } catch (err) { | |
| console.error('Error al copiar al portapapeles:', err); | |
| mostrarMensaje('Error al copiar al portapapeles. Intenta copiar manualmente.', 'danger'); | |
| } finally { | |
| document.body.removeChild(textarea); | |
| } | |
| } | |
| /** | |
| * Opens the modal for weight-based product entry. | |
| * @param {string} [initialProductCode=''] - Optional initial product code to pre-fill. | |
| * @param {boolean} [focusWeightInputDirectly=false] - If true, focuses directly on the weight input. | |
| */ | |
| function openWeightEntryModal(initialProductCode = '', focusWeightInputDirectly = false) { | |
| console.log('openWeightEntryModal called with code:', initialProductCode, 'focusWeight:', focusWeightInputDirectly); | |
| if (!isPremiumUser || !currentSettings.showGrammageProductsPage) { | |
| mostrarMensaje('La función de "Productos por Gramaje" es una característica Premium. ¡Actualiza tu suscripción!', 'warning'); | |
| return; | |
| } | |
| const weightEntryModal = document.getElementById('weightEntryModal'); | |
| const weightProductCodeInput = document.getElementById('weightProductCode'); | |
| const weightInput = document.getElementById('weightInput'); | |
| const selectedWeightedProductInfo = document.getElementById('selectedWeightedProductInfo'); | |
| const calculatedWeightedPrice = document.getElementById('calculatedWeightedPrice'); | |
| const addWeightedProductBtn = document.getElementById('addWeightedProductBtn'); | |
| const weightProductSearchResults = document.getElementById('weightProductSearchResults'); | |
| weightProductCodeInput.value = initialProductCode; | |
| weightInput.value = '0.000'; | |
| selectedWeightedProductInfo.textContent = ''; | |
| calculatedWeightedPrice.textContent = ''; | |
| weightProductSearchResults.innerHTML = ''; // Clear previous search results | |
| weightEntryModal.classList.add('show'); | |
| updateWeightedProductInfo(); // Update info based on initial product code | |
| if (focusWeightInputDirectly) { | |
| const product = productLookupMap.get(initialProductCode); | |
| if (product && product.UnitType === 'kg') { | |
| weightInput.focus(); | |
| weightInput.select(); | |
| } else { | |
| weightProductCodeInput.focus(); | |
| weightProductCodeInput.select(); | |
| if (!product) mostrarMensaje('Producto no encontrado con el código proporcionado.', 'warning'); | |
| else mostrarMensaje(`"${product.Producto}" no es un producto por peso.`, 'warning'); | |
| } | |
| } else { | |
| weightProductCodeInput.focus(); | |
| weightProductCodeInput.select(); | |
| } | |
| } | |
| /** | |
| * Closes the modal for weight-based product entry. | |
| */ | |
| function closeWeightEntryModal() { | |
| console.log('closeWeightEntryModal called'); | |
| document.getElementById('weightEntryModal').classList.remove('show'); | |
| document.getElementById('product-search').focus(); // Return focus to main scanner input | |
| } | |
| /** | |
| * Displays search results for weighted products in the modal. | |
| * @param {Array<Object>} results - Array of product objects to display. | |
| */ | |
| function displayWeightedSearchResults(results) { | |
| const weightProductSearchResults = document.getElementById('weightProductSearchResults'); | |
| let html = ''; | |
| if (results.length > 0) { | |
| html += '<ul class="list-group mt-2">'; | |
| results.forEach(p => { | |
| html += `<li class="list-group-item list-group-item-action" data-code="${p.Cdigo}">${p.Producto} (${p.Cdigo}) - ${currentCurrencySymbol}${p.PVenta.toFixed(2)}/kg</li>`; | |
| }); | |
| html += '</ul>'; | |
| } else { | |
| html += '<p class="text-muted text-center mt-2">No se encontraron productos por peso.</p>'; | |
| } | |
| weightProductSearchResults.innerHTML = html; | |
| } | |
| /** | |
| * Updates product info and calculated price in the weight entry modal. | |
| */ | |
| function updateWeightedProductInfo() { | |
| console.log('updateWeightedProductInfo called'); | |
| const productCodeInput = document.getElementById('weightProductCode'); | |
| const productCode = productCodeInput.value.trim(); | |
| const weight = parseFloat(document.getElementById('weightInput').value); | |
| const selectedWeightedProductInfo = document.getElementById('selectedWeightedProductInfo'); | |
| const calculatedWeightedPrice = document.getElementById('calculatedWeightedPrice'); | |
| const addWeightedProductBtn = document.getElementById('addWeightedProductBtn'); | |
| const weightProductSearchResults = document.getElementById('weightProductSearchResults'); | |
| selectedWeightedProductInfo.textContent = ''; | |
| calculatedWeightedPrice.textContent = ''; | |
| addWeightedProductBtn.disabled = true; | |
| weightProductSearchResults.innerHTML = ''; // Clear previous search results | |
| if (productCode) { | |
| const product = productLookupMap.get(productCode); | |
| if (product) { | |
| if (product.UnitType !== 'kg') { | |
| selectedWeightedProductInfo.textContent = `Error: "${product.Producto}" no es un producto por peso.`; | |
| calculatedWeightedPrice.textContent = ''; | |
| return; | |
| } | |
| selectedWeightedProductInfo.textContent = `Producto: ${product.Producto} (Precio/kg: ${currentCurrencySymbol}${product.PVenta.toFixed(2)})`; | |
| if (!isNaN(weight) && weight > 0) { | |
| const totalPrice = product.PVenta * weight; | |
| calculatedWeightedPrice.textContent = `Precio Total: ${currentCurrencySymbol}${totalPrice.toFixed(2)}`; | |
| addWeightedProductBtn.disabled = false; | |
| } else { | |
| calculatedWeightedPrice.textContent = 'Ingrese un peso válido (Ej: 0.5 para 500g).'; | |
| } | |
| } else { | |
| // If product not found by exact code, search for it | |
| const normalizedSearchTerm = normalizeString(productCode); | |
| const results = allProducts.filter(p => | |
| p.UnitType === 'kg' && // Only show kg products | |
| (normalizeString(p.Cdigo).includes(normalizedSearchTerm) || normalizeString(p.Producto).includes(normalizedSearchTerm)) | |
| ); | |
| displayWeightedSearchResults(results); | |
| selectedWeightedProductInfo.textContent = 'Producto no encontrado. Selecciona de la lista o verifica el código.'; | |
| } | |
| } | |
| } | |
| /** | |
| * Adds the weighted product to the cart. | |
| */ | |
| function addWeightedProductToCart() { | |
| console.log('addWeightedProductToCart called'); | |
| const productCode = document.getElementById('weightProductCode').value.trim(); | |
| const weight = parseFloat(document.getElementById('weightInput').value); | |
| if (!productCode || isNaN(weight) || weight <= 0) { | |
| mostrarMensaje('Por favor, ingresa un código de producto y un peso válido.', 'danger'); | |
| return; | |
| } | |
| const productToAdd = productLookupMap.get(productCode); | |
| if (!productToAdd) { | |
| mostrarMensaje('Producto no encontrado.', 'danger'); | |
| return; | |
| } | |
| if (productToAdd.UnitType !== 'kg') { | |
| mostrarMensaje(`El producto "${productToAdd.Producto}" no está configurado para venta por peso.`, 'warning'); | |
| return; | |
| } | |
| const totalPrice = productToAdd.PVenta * weight; | |
| const existenciaActual = typeof productToAdd.Existencia === 'number' ? productToAdd.Existencia : parseFloat(productToAdd.Existencia) || 0; | |
| if (existenciaActual < weight) { | |
| mostrarMensaje(`No hay suficiente existencia de "${productToAdd.Producto}". Disponible: ${existenciaActual.toFixed(3)} kg.`, 'danger'); | |
| return; | |
| } | |
| const existingCartItemIndex = cart.findIndex(item => item.Cdigo === productCode); | |
| if (existingCartItemIndex !== -1) { | |
| const existingItem = cart[existingCartItemIndex]; | |
| existingItem.quantity += weight; | |
| mostrarMensaje(`"${String(productToAdd.Producto || productToAdd.Cdigo).trim()}" cantidad aumentada por ${weight.toFixed(3)} kg.`, 'info'); | |
| } else { | |
| // Insert at the beginning of the cart array for most recent on top | |
| cart.unshift({ | |
| Cdigo: productToAdd.Cdigo, | |
| Producto: productToAdd.Producto, | |
| Departamento: productToAdd.Departamento, | |
| quantity: weight, | |
| precioUnitario: productToAdd.PVenta, | |
| UnitType: 'kg' | |
| }); | |
| mostrarMensaje(`"${String(productToAdd.Producto || productToAdd.Cdigo).trim()}" (${weight.toFixed(3)} kg) añadido al carrito.`, 'success'); | |
| } | |
| // Update stock | |
| const productInAllProductsIndex = allProducts.findIndex(p => p.Cdigo === productCode); | |
| if (productInAllProductsIndex !== -1) { | |
| allProducts[productInAllProductsIndex].Existencia -= weight; | |
| if (allProducts[productInAllProductsIndex].Existencia < 0) { | |
| allProducts[productInAllProductsIndex].Existencia = 0; | |
| } | |
| } | |
| updateCartDisplay(); | |
| localStorage.setItem(PRODUCTS_STORAGE_KEY, JSON.stringify(allProducts)); // Save updated stock | |
| handleCatalogFilterInput(); // Re-render products to reflect updated stock in catalog | |
| closeWeightEntryModal(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| <?php | |
| // Flush the output buffer and send content to the browser | |
| ob_end_flush(); | |
| ?> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment