Files
SekiPOS/templates/checkout.html
SekiDesu0 47cc480cf5 feat: add expenses module, restaurant mode, and dynamic sales filters
- Gastos (Expenses): Added `/gastos` route, auto-creation of `expenses` DB table, and `gastos.html` to track net profit with split month/year dropdowns.
- Sales & Filters: Overhauled `/sales` backend to use pagination. Top summary cards now accurately reflect the selected payment method filter.
- Checkout Improvements:
  - Added "Transferencia" as a payment option with numpad shortcuts.
  - Built a "Pinned Products" quick-access grid using localStorage.
  - Implemented a global processing lock to prevent duplicate sales on double-clicks.
  - Burned the default HTML number arrows with custom CSS.
- Global Settings & Receipts:
  - Created a global settings modal accessible from the navbar.
  - Added localStorage toggles for custom business name and auto-print.
  - Added "Restaurant Mode" toggle to prompt for Client Name and Pickup Time, which now dynamically prints on the receipt.
- Bug Fixes: Resolved Jinja `TemplateSyntaxError` crash and removed the duplicate search bar in the checkout view.
2026-04-15 22:58:12 -04:00

1255 lines
57 KiB
HTML

{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal, scanner_modal, render_receipt %}
{% block title %}Caja{% endblock %}
{% block head %}
<style>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.crt-cursor {
animation: blink-step 1s step-end infinite;
border-bottom: 3px solid #ed4245;
display: inline-block;
width: 14px;
height: 1.2em;
vertical-align: bottom;
margin-left: 2px;
}
@keyframes blink-step { 50% { opacity: 0; } }
.boot-btn {
background: transparent;
transition: all 0.1s;
}
.boot-btn:hover {
background: #ed4245 !important;
color: #000 !important;
text-shadow: none !important;
cursor: pointer;
}
</style>
{% endblock %}
{% block content %}
{{ render_receipt() }}
{% call confirm_modal('removeConfirmModal', 'Quitar Producto', 'btn-danger-discord', 'Quitar', 'executeRemoveItem()') %}
¿Estás seguro de que quieres quitar <strong id="removeItemName"></strong> del carrito?
{% endcall %}
{% call confirm_modal('clearCartModal', 'Vaciar Carrito', 'btn-danger-discord', 'Sí, vaciar', 'executeClearCart()') %}
<div class="text-center">
<i class="bi bi-cart-x text-danger" style="font-size: 3rem;"></i>
<p class="mt-3">¿Seguro que quieres eliminar todos los productos del carrito?</p>
</div>
{% endcall %}
<div class="modal fade" id="customProductModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Agregar Producto Manual</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label text-muted small mb-1">Descripción</label>
<input type="text" id="custom-name" class="form-control" placeholder="Ej: Varios, Bolsa, etc."
onkeydown="if(event.key === 'Enter') document.getElementById('custom-price').focus()">
</div>
<div class="row g-2">
<div class="col-8">
<label class="form-label text-muted small mb-1">Precio Unitario</label>
<input type="number" id="custom-price" class="form-control" placeholder="Ej: 1500"
onkeydown="if(event.key === 'Enter') addCustomProduct()">
</div>
<div class="col-4">
<label class="form-label text-muted small mb-1">Tipo</label>
<select id="custom-unit" class="form-select">
<option value="unit">Unidad</option>
<option value="kg">Kg</option>
</select>
</div>
</div>
</div>
<div class="modal-footer d-flex">
<button class="btn btn-secondary flex-grow-1" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-accent flex-grow-1" onclick="addCustomProduct()">Agregar</button>
</div>
</div>
</div>
</div>
<div class="modal" id="variosModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem;">
Producto Varios
</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<div class="mb-3 text-start">
<label class="text-muted small mb-1">Precio (CLP)</label>
<input type="text" inputmode="numeric" id="varios-price-input" class="form-control form-control-lg text-center fw-bold fs-4"
placeholder="$0"
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : '';"
onkeydown="if(event.key === 'Enter') addVariosToCart()">
</div>
<button class="btn btn-warning w-100 py-3 fw-bold" onclick="addVariosToCart()">
<i class="bi bi-cart-plus me-1"></i> Agregar al Carrito
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="weightModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5>Ingresar Peso (Gramos)</h5>
</div>
<div class="modal-body">
<input type="number" id="weight-input" class="form-control form-control-lg" step="1"
placeholder="Ej: 500" onkeydown="if(event.key === 'Enter') confirmWeight()">
</div>
<div class="modal-footer d-flex">
<button class="btn btn-secondary flex-grow-1" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-accent flex-grow-1" onclick="confirmWeight()">Agregar</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="orderDetailsModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">
Detalles del Pedido
</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2 pb-4">
<div class="mb-3">
<label class="form-label text-muted small mb-1">Nombre del Cliente</label>
<input type="text" id="order-client-name" class="form-control" placeholder="Ej: Juan Pérez" autocomplete="off" onkeydown="if(event.key === 'Enter') document.getElementById('order-pickup-time').focus()">
</div>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Hora de Retiro (Opcional)</label>
<input type="time" id="order-pickup-time" class="form-control" onkeydown="if(event.key === 'Enter') document.getElementById('order-notes').focus()">
</div>
<div class="mb-4">
<label class="form-label text-muted small mb-1">Detalles / Notas</label>
<input type="text" id="order-notes" class="form-control" placeholder="Ej: Para llevar, Sin cebolla" autocomplete="off" onkeydown="if(event.key === 'Enter') confirmOrderDetails()">
</div>
<button class="btn btn-primary w-100 py-2 fw-bold" onclick="confirmOrderDetails()">
Continuar al Pago <i class="bi bi-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="paymentModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">
Total a Pagar
</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<h1 id="payment-modal-total" class="mb-4" style="color: var(--accent); font-weight: 800; font-size: 3rem">$0</h1>
<div class="d-grid gap-3 px-3">
<button class="btn btn-lg btn-success py-3" onclick="openVueltoModal()">
<i class="bi bi-cash-coin me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Efectivo (1)
</button>
<button class="btn btn-lg btn-secondary py-3" onclick="executeCheckout('tarjeta')">
<i class="bi bi-credit-card me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Tarjeta (2)
</button>
<button class="btn btn-lg btn-info py-3 text-white" onclick="executeCheckout('transferencia')">
<i class="bi bi-bank me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Transferencia (3)
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal" id="vueltoModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">Pago en Efectivo</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<div class="mb-3">
<span class="text-muted small">Total a Pagar:</span><br>
<span id="vuelto-total-display" class="fs-4 fw-bold" style="color: var(--text-main)">$0</span>
</div>
<div class="mb-3 text-start">
<label class="text-muted small mb-1">Monto Recibido</label>
<input type="text" inputmode="numeric" id="monto-recibido" class="form-control form-control-lg text-center fw-bold fs-4"
placeholder="$0"
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : ''; calculateVuelto();"
onkeydown="if(event.key === 'Enter' && !document.getElementById('btn-confirm-vuelto').disabled) executeCheckout('efectivo')">
</div>
<div class="d-flex flex-wrap justify-content-center gap-2 mb-3" id="vuelto-quick-buttons"></div>
<div class="p-3 mb-3" style="background: var(--input-bg); border-radius: 8px">
<span class="text-muted small text-uppercase fw-bold">Vuelto a Entregar</span><br>
<span id="vuelto-amount" class="fs-1 fw-bold text-muted">$0</span>
</div>
<button id="btn-confirm-vuelto" class="btn btn-success w-100 py-3 fw-bold" onclick="executeCheckout('efectivo')" disabled>Confirmar Venta</button>
</div>
</div>
</div>
</div>
<div class="modal" id="quickSaleModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem;">Venta Rápida</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<div class="mb-3 text-start">
<label class="text-muted small mb-1">Monto (CLP)</label>
<input type="text" inputmode="numeric" id="quick-sale-amount" class="form-control form-control-lg text-center fw-bold fs-4"
placeholder="$0"
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : '';"
onkeydown="if(event.key === 'Enter') processQuickSale()">
</div>
<button class="btn btn-primary w-100 py-3 fw-bold" onclick="processQuickSale()">
<i class="bi bi-lightning-charge me-1"></i> Finalizar Venta
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="notFoundModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-warning">
<div class="modal-header border-0 pb-0">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-0 pb-4">
<i class="bi bi-question-circle text-warning mb-3" style="font-size: 3rem"></i>
<h4 class="mb-2">Producto No Registrado</h4>
<p class="text-muted px-3 small">El código <strong id="not-found-barcode" style="color: var(--text-main);"></strong> no existe.</p>
<div class="d-flex flex-column gap-2 px-3 mt-4">
<button class="btn btn-accent w-100 py-2" onclick="goToInventory()">
<i class="bi bi-database-add me-2"></i>Registrar en Inventario
</button>
<button class="btn btn-outline-secondary w-100 py-2" onclick="openTempProduct()">
<i class="bi bi-cart-plus me-2"></i>Venta Temporal (Solo por esta vez)
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="successModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-success">
<div class="modal-body text-center py-4">
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
<h4 class="mt-3">¡Venta Exitosa!</h4>
<p class="text-muted">El carrito se ha procesado correctamente.</p>
<button class="btn btn-accent px-5" data-bs-dismiss="modal">Listo</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="doomModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered" style="max-width: 90vw;">
<div class="modal-content border-danger" style="background: #000;">
<div class="modal-header border-0 pb-0 d-flex justify-content-between">
<h5 class="modal-title text-danger font-monospace">E1M1: Hangar</h5>
<div>
<span class="text-muted small me-3 align-middle">Haz clic en el juego para sonido</span>
<button class="btn btn-sm btn-outline-danger me-3" onclick="toggleDoomFullscreen()" title="Pantalla Completa">
<i class="bi bi-arrows-fullscreen"></i>
</button>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body text-center p-0 d-flex justify-content-center bg-black position-relative" style="min-height: 80vh;">
<div id="doom-boot-screen" class="position-absolute w-100 h-100 flex-column align-items-start p-5 d-none" style="z-index: 10; background: #050505; font-family: 'Courier New', monospace; text-transform: uppercase; cursor: pointer;" onclick="startDoom()">
<div class="text-start" style="color: #4af626; text-shadow: 0 0 4px rgba(74, 246, 38, 0.5);">
<p class="mb-1">UAC_BIOS v1.9.9.3</p>
<div id="boot-loading" class="mt-3">
<span id="boot-status">CARGANDO SISTEMA MS-DOS...</span><span class="crt-cursor"></span>
</div>
<div id="boot-ready" class="d-none mt-3">
<p class="mb-1">HIMEM IS TESTING EXTENDED MEMORY... [OK]</p>
<p class="mb-1">SND_INIT: SOUNDBLASTER 16 ... [OK]</p>
<p class="mb-4 text-warning" style="text-shadow: 0 0 4px rgba(255, 193, 7, 0.5);">WARNING: NON-STANDARD SEKIPOS HARDWARE DETECTED.</p>
<div class="fs-4 text-danger d-flex align-items-center" style="text-shadow: 0 0 5px rgba(237, 66, 69, 0.6);">
<span>C:\UAC\SEKIPOS> DOOM.EXE</span><span class="crt-cursor"></span>
</div>
<p class="mt-5 text-muted small" style="animation: blink-step 2s step-end infinite;">[ HAZ CLIC PARA INICIAR ]</p>
</div>
</div>
</div>
<canvas id="jsdos" style="width: 100%; height: 80vh; object-fit: contain; image-rendering: pixelated;"></canvas>
</div>
</div>
</div>
</div>
<div class="container-fluid">
<div class="row g-3">
<div class="col-md-8">
<div class="discord-card p-3">
<h4><i class="bi bi-cart3"></i> Carrito</h4>
<div class="position-relative mb-4">
<div class="input-group">
<span class="input-group-text border-0 position-absolute" style="background: transparent; z-index: 10">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" id="manual-search" class="form-control ps-5 py-2 rounded"
placeholder="Buscar producto por nombre o código..." autocomplete="off" onkeyup="filterSearch()">
<button class="btn btn-warning px-3 fw-bold" type="button" onclick="openVariosModal()" title="Agregar Varios rápido">
<i class="bi bi-asterisk"></i> <span class="d-none d-sm-inline ms-1">Varios</span>
</button>
<button class="btn btn-accent px-3" type="button" onclick="openCustomProductModal()" title="Agregar manual detallado">
<i class="bi bi-plus-lg"></i> <span class="d-none d-sm-inline ms-1">Manual</span>
</button>
</div>
<div id="search-results" class="dropdown-menu w-100 shadow-sm mt-1"
style="display: none; position: absolute; top: 100%; left: 0; z-index: 1000; max-height: 300px; overflow-y: auto"></div>
</div>
<div id="pinned-products-container" class="d-flex flex-wrap gap-2 mb-3"></div>
<div class="table-responsive">
<table class="table mt-3" id="cart-table">
<thead>
<tr>
<th>Código</th>
<th>Producto</th>
<th>Precio/U</th>
<th>Cant/Peso</th>
<th>Subtotal</th>
<th></th>
</tr>
</thead>
<tbody id="cart-items"></tbody>
</table>
</div>
</div>
</div>
<div class="col-md-4">
<div class="discord-card p-3 mb-3 text-center shadow-sm">
<p class="mb-1 fw-semibold text-uppercase" style="color:var(--text-muted); font-size:0.7rem">Último Escaneado</p>
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
<h6 id="display-name" class="mb-0 text-truncate">Esperando scan...</h6>
<small id="display-barcode" class="text-muted font-monospace" style="font-size: 0.7rem"></small>
<div class="total-banner text-center mb-3 mt-3">
<h2 class="mb-0">TOTAL</h2>
<h1 id="grand-total" style="font-size: 3.5rem; font-weight: 800;">$0</h1>
</div>
<button class="btn btn-primary w-100 btn-lg mb-2" onclick="openQuickSaleModal()">
<i class="bi bi-lightning-charge"></i> VENTA RÁPIDA
</button>
<button class="btn btn-success w-100 btn-lg mb-2" onclick="processSale()">
<i class="bi bi-cash-coin"></i> COBRAR
</button>
<button class="btn btn-danger w-100 btn-lg" onclick="clearCart()">
<i class="bi bi-trash3"></i> VACIAR
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
/* =========================================
1. GLOBAL STATE & FORMATTERS
========================================= */
let cart = [];
let pendingProduct = null;
let missingProductData = null;
let tempBarcode = null;
let editingCartIndex = null;
let itemIndexToRemove = null;
let currentClientName = '';
let currentOrderNotes = '';
let currentPickupTime = '';
// The lock to prevent duplicate sales
let isProcessing = false;
// Fetch the pinned items from local storage
let pinnedBarcodes = JSON.parse(localStorage.getItem('seki_pinned_products')) || [];
let socket = io();
const clp = new Intl.NumberFormat('es-CL', {
style: 'currency',
currency: 'CLP',
minimumFractionDigits: 0
});
const allProducts = [
{% for p in products %}
{
barcode: {{ p[0] | tojson }},
name: {{ p[1] | tojson }},
price: {{ p[2] | int }},
image: {{ p[3] | tojson }},
stock: {{ p[4] | int }},
unit: {{ p[5] | tojson }}
},
{% endfor %}
];
/* =========================================
2. SOCKET LISTENERS (BARCODE / SCALE)
========================================= */
socket.on('scan_error', (data) => {
missingProductData = data;
document.getElementById('not-found-barcode').innerText = data.barcode;
bootstrap.Modal.getOrCreateInstance(document.getElementById('notFoundModal')).show();
});
socket.on('new_scan', (product) => {
handleProductScan(product);
});
socket.on('scale_update', function (data) {
const weightModal = document.getElementById('weightModal');
if (weightModal.classList.contains('show')) {
document.getElementById('weight-input').value = data.grams;
}
});
/* =========================================
3. CORE CART & UI LOGIC
========================================= */
function renderCart() {
const tbody = document.getElementById('cart-items');
tbody.innerHTML = '';
let total = 0;
cart.forEach((item, index) => {
total += item.subtotal;
const row = document.createElement('tr');
let qtyControls;
if (item.unit === 'kg') {
qtyControls = `
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="editWeight(${index})">
${item.qty.toFixed(3)} kg <i class="bi bi-pencil ms-1" style="font-size: 0.7rem;"></i>
</button>`;
} else {
qtyControls = `
<div class="d-flex align-items-center gap-1">
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="updateQty(${index}, -1)">-</button>
<input type="number" class="form-control form-control-sm text-center p-0" style="width: 50px;" value="${item.qty}" onchange="manualQty(${index}, this.value)">
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="updateQty(${index}, 1)">+</button>
</div>`;
}
row.innerHTML = `
<td class="font-monospace small text-muted">${item.barcode}</td>
<td>${item.name}</td>
<td>${clp.format(item.price)}</td>
<td>${qtyControls}</td>
<td>${clp.format(item.subtotal)}</td>
<td>
<button class="btn btn-danger-discord btn-sm" onclick="removeItem(${index})">
<i class="bi bi-trash"></i>
</button>
</td>`;
tbody.appendChild(row);
});
document.getElementById('grand-total').innerText = clp.format(total);
saveCart();
}
function addToCart(product, qty) {
const existingIndex = cart.findIndex(item => item.barcode === product.barcode && item.unit !== 'kg');
if (existingIndex !== -1) {
cart[existingIndex].qty += qty;
cart[existingIndex].subtotal = calculateSubtotal(cart[existingIndex].price, cart[existingIndex].qty);
} else {
cart.push({ ...product, qty, subtotal: calculateSubtotal(product.price, qty) });
}
renderCart();
}
function updateQty(index, delta) {
if (cart[index].unit === 'kg') return;
cart[index].qty += delta;
if (cart[index].qty <= 0) {
removeItem(index, cart[index].name);
} else {
cart[index].subtotal = calculateSubtotal(cart[index].price, cart[index].qty);
renderCart();
}
}
function manualQty(index, val) {
const newQty = parseFloat(val);
if (isNaN(newQty) || newQty <= 0) return;
cart[index].qty = newQty;
cart[index].subtotal = calculateSubtotal(cart[index].price, cart[index].qty);
renderCart();
}
function removeItem(idx) {
itemIndexToRemove = idx;
document.getElementById('removeItemName').innerText = cart[idx].name;
bootstrap.Modal.getOrCreateInstance(document.getElementById('removeConfirmModal')).show();
}
function executeRemoveItem() {
if (itemIndexToRemove !== null) {
cart.splice(itemIndexToRemove, 1);
renderCart();
bootstrap.Modal.getInstance(document.getElementById('removeConfirmModal')).hide();
itemIndexToRemove = null;
}
}
function clearCart() {
if (cart.length === 0) return;
bootstrap.Modal.getOrCreateInstance(document.getElementById('clearCartModal')).show();
}
function executeClearCart() {
cart = [];
renderCart();
clearLastScanned();
bootstrap.Modal.getInstance(document.getElementById('clearCartModal')).hide();
}
function calculateSubtotal(price, qty) {
return Math.round((price * qty) / 10) * 10;
}
function saveCart() { localStorage.setItem('seki_cart', JSON.stringify(cart)); }
function loadCart() {
const saved = localStorage.getItem('seki_cart');
if (saved) {
try { cart = JSON.parse(saved); renderCart(); }
catch (e) { console.error(e); cart = []; }
}
}
/* =========================================
4. PRODUCT SCANNING & SEARCH
========================================= */
function handleProductScan(product) {
document.getElementById('display-name').innerText = product.name;
document.getElementById('display-barcode').innerText = product.barcode;
document.getElementById('display-img').src = product.image || './static/placeholder.png';
const actualUnit = product.unit || product.unit_type;
if (actualUnit === 'kg') {
pendingProduct = product;
pendingProduct.unit = 'kg';
bootstrap.Modal.getOrCreateInstance(document.getElementById('weightModal')).show();
setTimeout(() => document.getElementById('weight-input').focus(), 500);
} else {
product.unit = 'unit';
addToCart(product, 1);
}
}
function filterSearch() {
const query = document.getElementById('manual-search').value.toLowerCase().trim();
const resultsBox = document.getElementById('search-results');
if (query.length < 2) {
resultsBox.style.display = 'none';
return;
}
const matches = allProducts.filter(p => p.name.toLowerCase().includes(query) || p.barcode.includes(query)).slice(0, 10);
if (matches.length === 0) {
resultsBox.innerHTML = '<div class="p-3 text-muted text-center">No se encontraron productos</div>';
} else {
resultsBox.innerHTML = matches.map(p => {
// Check if it's pinned to color the icon
const isPinned = pinnedBarcodes.includes(p.barcode);
const pinIcon = isPinned ? 'bi-pin-angle-fill text-warning' : 'bi-pin-angle text-muted';
return `
<a href="#" class="dropdown-item d-flex justify-content-between align-items-center py-2" onclick="selectSearchResult('${p.barcode}')">
<div class="d-flex align-items-center gap-3">
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="togglePin('${p.barcode}', event)" title="Fijar producto">
<i class="bi ${pinIcon} fs-5"></i>
</button>
<div>
<strong>${p.name}</strong><br>
<small class="text-muted font-monospace">${p.barcode}</small>
</div>
</div>
<div class="text-end">
<span style="color: var(--accent); font-weight: bold;">${clp.format(p.price)}</span><br>
<small class="text-muted">${p.unit === 'kg' ? 'Kg' : 'Unidad'}</small>
</div>
</a>
`}).join('');
}
resultsBox.style.display = 'block';
}
function togglePin(barcode, event) {
event.stopPropagation(); // Stop the row from adding to cart when clicking the pin
if (pinnedBarcodes.includes(barcode)) {
pinnedBarcodes = pinnedBarcodes.filter(b => b !== barcode);
} else {
pinnedBarcodes.push(barcode);
}
localStorage.setItem('seki_pinned_products', JSON.stringify(pinnedBarcodes));
renderPinnedProducts();
filterSearch(); // Re-render the search dropdown so the pin icon updates
document.getElementById('manual-search').focus(); // Keep focus on search input
}
function renderPinnedProducts() {
const container = document.getElementById('pinned-products-container');
const pinnedProducts = allProducts.filter(p => pinnedBarcodes.includes(p.barcode));
if (pinnedProducts.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = pinnedProducts.map(p => `
<button class="btn btn-outline-secondary text-start p-2 shadow-sm d-flex flex-column justify-content-between"
style="width: 110px; height: 75px; border-color: var(--border); background: var(--input-bg); color: var(--text-main);"
onclick="handleProductScan(allProducts.find(x => x.barcode === '${p.barcode}'))">
<span class="small fw-bold text-truncate w-100 mb-1" title="${p.name}">${p.name}</span>
<span class="small" style="color: var(--accent); font-weight: 800;">${clp.format(p.price)}</span>
</button>
`).join('');
}
function selectSearchResult(barcode) {
const product = allProducts.find(p => p.barcode === barcode);
if (product) handleProductScan(product);
const searchInput = document.getElementById('manual-search');
searchInput.value = '';
document.getElementById('search-results').style.display = 'none';
searchInput.focus();
}
function clearLastScanned() {
document.getElementById('display-img').src = './static/placeholder.png';
document.getElementById('display-name').innerText = 'Esperando scan...';
document.getElementById('display-barcode').innerText = '';
}
/* =========================================
5. MANUAL & TEMPORARY PRODUCT INPUTS
========================================= */
function openTempProduct() {
bootstrap.Modal.getInstance(document.getElementById('notFoundModal')).hide();
tempBarcode = missingProductData.barcode;
document.getElementById('custom-name').value = missingProductData.name || '';
document.getElementById('custom-price').value = '';
document.getElementById('custom-unit').value = 'unit';
bootstrap.Modal.getOrCreateInstance(document.getElementById('customProductModal')).show();
setTimeout(() => {
if (missingProductData.name) document.getElementById('custom-price').focus();
else document.getElementById('custom-name').focus();
}, 500);
}
function openCustomProductModal() {
tempBarcode = null;
document.getElementById('custom-name').value = '';
document.getElementById('custom-price').value = '';
document.getElementById('custom-unit').value = 'unit';
bootstrap.Modal.getOrCreateInstance(document.getElementById('customProductModal')).show();
setTimeout(() => document.getElementById('custom-name').focus(), 500);
}
function addCustomProduct() {
const nameInput = document.getElementById('custom-name').value.trim();
const priceInput = parseInt(document.getElementById('custom-price').value, 10);
const unitInput = document.getElementById('custom-unit').value;
if (!nameInput || isNaN(priceInput) || priceInput <= 0) {
alert("Por favor ingresa un nombre y un precio válido.");
return;
}
const customProduct = {
barcode: tempBarcode ? tempBarcode : `MANUAL-${Date.now().toString().slice(-6)}`,
name: `* ${nameInput}`,
price: priceInput,
image: '',
stock: 0,
unit: unitInput
};
if (unitInput === 'kg') {
pendingProduct = customProduct;
bootstrap.Modal.getInstance(document.getElementById('customProductModal')).hide();
bootstrap.Modal.getOrCreateInstance(document.getElementById('weightModal')).show();
setTimeout(() => document.getElementById('weight-input').focus(), 500);
} else {
addToCart(customProduct, 1);
bootstrap.Modal.getInstance(document.getElementById('customProductModal')).hide();
}
tempBarcode = null;
}
function openVariosModal() {
const input = document.getElementById('varios-price-input');
input.value = '';
bootstrap.Modal.getOrCreateInstance(document.getElementById('variosModal')).show();
input.focus(); // Instant focus
}
function addVariosToCart() {
// Grab the value and strip the dots before parsing
const rawValue = document.getElementById('varios-price-input').value.replace(/\./g, '');
const price = parseInt(rawValue, 10);
if (isNaN(price) || price <= 0) return alert("Ingresa un precio válido.");
addToCart({
barcode: `VARIOS-${Date.now().toString().slice(-6)}`,
name: '* Varios',
price: price,
qty: 1,
subtotal: price,
image: '',
stock: 0,
unit: 'unit'
}, 1);
bootstrap.Modal.getInstance(document.getElementById('variosModal')).hide();
}
/* =========================================
6. WEIGHT LOGIC (GRAMS TO KG)
========================================= */
function editWeight(index) {
editingCartIndex = index;
const weightInput = document.getElementById('weight-input');
weightInput.value = Math.round(cart[index].qty * 1000);
bootstrap.Modal.getOrCreateInstance(document.getElementById('weightModal')).show();
setTimeout(() => { weightInput.focus(); weightInput.select(); }, 500);
}
function confirmWeight() {
const weightGrams = parseInt(document.getElementById('weight-input').value, 10);
if (weightGrams > 0) {
const weightKg = weightGrams / 1000;
if (editingCartIndex !== null) {
cart[editingCartIndex].qty = weightKg;
cart[editingCartIndex].subtotal = calculateSubtotal(cart[editingCartIndex].price, weightKg);
renderCart();
} else {
addToCart(pendingProduct, weightKg);
}
bootstrap.Modal.getInstance(document.getElementById('weightModal')).hide();
editingCartIndex = null;
}
}
document.getElementById('weightModal').addEventListener('hidden.bs.modal', function () {
document.getElementById('weight-input').value = '';
pendingProduct = null;
editingCartIndex = null;
});
/* =========================================
7. CHECKOUT & PAYMENT LOGIC
========================================= */
function processSale() {
if (cart.length === 0) return;
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
if (total === 666) { triggerDoom(); return; }
// Intercept checkout if Restaurant mode is active
if (localStorage.getItem('seki_ask_order_details') === 'true') {
document.getElementById('order-client-name').value = '';
document.getElementById('order-pickup-time').value = ''; // <-- ADD THIS
document.getElementById('order-notes').value = '';
bootstrap.Modal.getOrCreateInstance(document.getElementById('orderDetailsModal')).show();
setTimeout(() => document.getElementById('order-client-name').focus(), 500);
return;
}
showPaymentModal(total);
}
function confirmOrderDetails() {
currentClientName = document.getElementById('order-client-name').value.trim();
currentPickupTime = document.getElementById('order-pickup-time').value;
currentOrderNotes = document.getElementById('order-notes').value.trim();
bootstrap.Modal.getInstance(document.getElementById('orderDetailsModal')).hide();
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
showPaymentModal(total);
}
function showPaymentModal(total) {
document.getElementById('payment-modal-total').innerText = clp.format(total);
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
}
function openVueltoModal() {
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
document.getElementById('vuelto-total-display').innerText = clp.format(total);
const input = document.getElementById('monto-recibido');
input.value = '';
document.getElementById('vuelto-amount').innerText = clp.format(0);
document.getElementById('vuelto-amount').className = "fs-1 fw-bold text-success";
document.getElementById('btn-confirm-vuelto').disabled = false;
const quickBox = document.getElementById('vuelto-quick-buttons');
quickBox.innerHTML = `<button class="btn btn-sm btn-outline-secondary fw-bold" onclick="setMonto(${total})">Exacto</button>`;
[2000, 5000, 10000, 20000].forEach(bill => {
if (bill > total && (bill - total) <= 20000) {
quickBox.innerHTML += `<button class="btn btn-sm btn-outline-secondary fw-bold" onclick="setMonto(${bill})">${clp.format(bill)}</button>`;
}
});
bootstrap.Modal.getOrCreateInstance(document.getElementById('vueltoModal')).show();
input.focus(); // Instant focus
}
function setMonto(amount) {
// Formats the quick-select buttons so they have dots too
document.getElementById('monto-recibido').value = amount.toLocaleString('es-CL');
calculateVuelto();
}
function calculateVuelto() {
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
// Strip the dots before parsing the math
const rawRecibido = document.getElementById('monto-recibido').value.replace(/\./g, '');
const recibido = parseInt(rawRecibido, 10);
const vueltoDisplay = document.getElementById('vuelto-amount');
const confirmBtn = document.getElementById('btn-confirm-vuelto');
if (isNaN(recibido) || recibido === 0) {
vueltoDisplay.innerText = clp.format(0);
vueltoDisplay.className = "fs-1 fw-bold text-success";
confirmBtn.disabled = false;
} else if (recibido < total) {
vueltoDisplay.innerText = "Falta Dinero";
vueltoDisplay.className = "fs-4 fw-bold text-danger mt-2 d-block";
confirmBtn.disabled = true;
} else {
vueltoDisplay.innerText = clp.format(recibido - total);
vueltoDisplay.className = "fs-1 fw-bold text-success";
confirmBtn.disabled = false;
}
}
async function executeCheckout(method) {
if (cart.length === 0 || isProcessing) return;
isProcessing = true;
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
let paidAmount = total;
const confirmBtn = document.getElementById('btn-confirm-vuelto');
const originalBtnText = confirmBtn.innerHTML;
if (method === 'efectivo') {
// Strip the dots here too
const rawVal = document.getElementById('monto-recibido').value.replace(/\./g, '');
const inputVal = parseInt(rawVal, 10);
if (!isNaN(inputVal) && inputVal > 0) paidAmount = inputVal;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Procesando...';
}
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cart: cart, payment_method: method })
});
const result = await response.json();
if (response.ok) {
const pModal = bootstrap.Modal.getInstance(document.getElementById('paymentModal'));
const vModal = bootstrap.Modal.getInstance(document.getElementById('vueltoModal'));
if (pModal) pModal.hide();
if (vModal) vModal.hide();
printReceipt(total, result.sale_id, paidAmount);
bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal')).show();
cart = [];
renderCart();
clearLastScanned();
setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000);
} else {
alert("Error: " + (result.error || "Error desconocido"));
}
} catch (err) {
alert("Error de conexión.");
} finally {
isProcessing = false;
confirmBtn.innerHTML = originalBtnText;
}
}
function openQuickSaleModal() {
const input = document.getElementById('quick-sale-amount');
input.value = '';
bootstrap.Modal.getOrCreateInstance(document.getElementById('quickSaleModal')).show();
input.focus(); // Instant focus
}
async function processQuickSale() {
if (isProcessing) return;
const rawValue = document.getElementById('quick-sale-amount').value.replace(/\./g, '');
const amount = parseInt(rawValue, 10);
if (isNaN(amount) || amount <= 0) return alert("Ingresa un monto válido.");
if (amount === 666) {
bootstrap.Modal.getInstance(document.getElementById('quickSaleModal')).hide();
triggerDoom(); return;
}
isProcessing = true;
const quickBtn = document.querySelector('#quickSaleModal .btn-primary');
const originalBtnText = quickBtn.innerHTML;
quickBtn.disabled = true;
quickBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Procesando...';
const quickCart = [{
barcode: `RAPIDA-${Date.now().toString().slice(-6)}`,
name: '* Varios', price: amount, qty: 1, subtotal: amount, unit: 'unit'
}];
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cart: quickCart, payment_method: 'efectivo' })
});
const result = await response.json();
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('quickSaleModal')).hide();
const originalCart = [...cart];
cart = quickCart;
printReceipt(amount, result.sale_id, amount);
cart = originalCart;
bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal')).show();
setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000);
} else {
alert("Error: " + (result.error || "Error desconocido"));
}
} catch (err) {
alert("Error de conexión.");
} finally {
isProcessing = false;
quickBtn.disabled = false;
quickBtn.innerHTML = originalBtnText;
}
}
function printReceipt(total, saleId, paidAmount = 0) {
const tbody = document.getElementById('receipt-items-print');
tbody.innerHTML = '';
cart.forEach(item => {
const qtyStr = item.unit === 'kg' ? item.qty.toFixed(3) : item.qty;
tbody.innerHTML += `
<tr>
<td>${qtyStr}</td>
<td style="padding-left: 5px; padding-right: 5px; word-break: break-word;">${item.name}</td>
<td style="text-align: right;">${clp.format(item.subtotal)}</td>
</tr>`;
});
const finalPaid = paidAmount > 0 ? paidAmount : total;
document.getElementById('receipt-ticket-id').innerText = saleId || "N/A";
document.getElementById('receipt-total-print').innerText = clp.format(total);
document.getElementById('receipt-paid-print').innerText = clp.format(finalPaid);
document.getElementById('receipt-change-print').innerText = clp.format(finalPaid - total);
document.getElementById('receipt-date').innerText = new Date().toLocaleString('es-CL');
// NEW: Fill in Restaurant Info if it exists
const orderInfoDiv = document.getElementById('receipt-order-info');
if (currentClientName || currentOrderNotes || currentPickupTime) {
orderInfoDiv.style.display = 'block';
document.getElementById('receipt-client-name').innerText = currentClientName || '-';
document.getElementById('receipt-order-notes').innerText = currentOrderNotes || '-';
const pickupContainer = document.getElementById('receipt-pickup-container');
if (currentPickupTime) {
pickupContainer.style.display = 'block';
document.getElementById('receipt-pickup-time').innerText = currentPickupTime;
} else {
pickupContainer.style.display = 'none';
}
} else {
orderInfoDiv.style.display = 'none';
}
// Wipe the memory for the next sale
currentClientName = '';
currentOrderNotes = '';
currentPickupTime = '';
// Check the setting before printing
setTimeout(() => {
if (localStorage.getItem('seki_auto_print') !== 'false') {
window.print();
}
}, 250);
}
/* =========================================
8. GLOBAL EVENT LISTENERS
========================================= */
document.addEventListener('click', function (e) {
const searchArea = document.getElementById('manual-search');
const resultsBox = document.getElementById('search-results');
if (e.target !== searchArea && !resultsBox.contains(e.target)) {
resultsBox.style.display = 'none';
}
});
document.addEventListener('keydown', function(event) {
const activeTag = document.activeElement ? document.activeElement.tagName.toLowerCase() : '';
const isTyping = activeTag === 'input' || activeTag === 'textarea';
// Find modals that are fully open OR in the middle of their fade-in animation
const openModal = document.querySelector('.modal.show, .modal[style*="display: block"]');
// 1. The Eject Button: NumpadDecimal (.) or Delete
if (event.code === 'NumpadDecimal' || event.key === 'Delete') {
if (isTyping && event.key === 'Delete') return;
if (openModal) {
event.preventDefault();
const modalInstance = bootstrap.Modal.getInstance(openModal) || bootstrap.Modal.getOrCreateInstance(openModal);
if (modalInstance) modalInstance.hide();
return;
}
}
// 2. Payment Modal Selection: 1 for Efectivo, 2 for Tarjeta, 3 for Transferencia
if (openModal && openModal.id === 'paymentModal') {
if (event.code === 'Numpad1' || event.key === '1') {
event.preventDefault();
openVueltoModal();
return;
}
if (event.code === 'Numpad2' || event.key === '2') {
event.preventDefault();
executeCheckout('tarjeta');
return;
}
// NEW TRANSFER SHORTCUT
if (event.code === 'Numpad3' || event.key === '3') {
event.preventDefault();
executeCheckout('transferencia');
return;
}
}
// 3. The Money Button: Enter
if (event.code === 'NumpadEnter' || event.key === 'Enter') {
if (itemIndexToRemove !== null) {
event.preventDefault();
executeRemoveItem();
return;
}
const clearModal = document.getElementById('clearCartModal');
if (clearModal && (clearModal.classList.contains('show') || clearModal.style.display === 'block')) {
event.preventDefault();
executeClearCart();
return;
}
if (!openModal && !isTyping && cart.length > 0) {
event.preventDefault();
processSale();
return;
}
}
// 4. The Varios Button: +
if (event.code === 'NumpadAdd' || event.key === '+') {
if (isTyping) return;
event.preventDefault();
openVariosModal();
return;
}
// 5. The Oops Button: - (Remove last item)
if (event.code === 'NumpadSubtract' || event.key === '-') {
if (isTyping) return;
if (!openModal && cart.length > 0) {
event.preventDefault();
removeItem(cart.length - 1);
return;
}
}
// 6. The Speedrun Button: * (Venta Rápida)
if (event.code === 'NumpadMultiply' || event.key === '*') {
if (isTyping) return;
event.preventDefault();
openQuickSaleModal();
return;
}
});
// Safety cleanup: If you close the remove modal with ESC or the Eject button, clear the memory
document.getElementById('removeConfirmModal').addEventListener('hidden.bs.modal', function () {
itemIndexToRemove = null;
});
// Safety cleanup: If you close the remove modal with ESC or the Eject button, clear the memory
document.getElementById('removeConfirmModal').addEventListener('hidden.bs.modal', function () {
itemIndexToRemove = null;
});
/* =========================================
9. DOOM EASTER EGG LOGIC
========================================= */
let doomMainFn = null;
let titleDefender = null;
let dosInstance = null;
const posTitle = "SekiPOS - Caja";
function triggerDoom() {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('doomModal'));
document.getElementById('boot-loading').classList.remove('d-none');
document.getElementById('boot-ready').classList.add('d-none');
document.getElementById('boot-status').innerHTML = "INICIANDO EMULADOR...<br>";
const bootScreen = document.getElementById('doom-boot-screen');
bootScreen.classList.remove('d-none');
bootScreen.classList.add('d-flex');
titleDefender = setInterval(() => {
if (document.title === "DOSBox") document.title = posTitle;
}, 50);
modal.show();
if (!document.getElementById('js-dos-script')) {
const script = document.createElement('script');
script.id = 'js-dos-script';
script.src = 'https://js-dos.com/6.22/current/js-dos.js';
script.onload = prepDoomEnvironment;
document.body.appendChild(script);
} else {
prepDoomEnvironment();
}
}
function prepDoomEnvironment() {
const bootText = document.getElementById('boot-status');
bootText.innerHTML += "MONTANDO UNIDAD VIRTUAL C:\\...<br>";
Dos(document.getElementById("jsdos"), {
wdosboxUrl: "https://js-dos.com/6.22/current/wdosbox.js"
}).ready((fs, main) => {
bootText.innerHTML += "DESCARGANDO ARCHIVOS WAD...<br>";
const bypassCache = new Date().getTime();
fs.extract(`/static/doom.zip?t=${bypassCache}`).then(() => {
const configData = "snd_channels 4\nsnd_musicdevice 3\nsnd_sfxdevice 3\nsnd_sbport 544\nsnd_sbirq 7\nsnd_sbdma 1\nsnd_mport 816\nuse_mouse 1\n";
const configBytes = new Uint8Array(new TextEncoder().encode(configData));
fs.createFile("fdoom.cfg", configBytes);
fs.createFile("default.cfg", configBytes);
doomMainFn = main;
document.getElementById('boot-loading').classList.add('d-none');
document.getElementById('boot-ready').classList.remove('d-none');
});
});
}
function startDoom() {
if (!doomMainFn) return;
const bootScreen = document.getElementById('doom-boot-screen');
bootScreen.classList.remove('d-flex');
bootScreen.classList.add('d-none');
if (window.SDL && window.SDL.audioContext && window.SDL.audioContext.state === 'suspended') {
window.SDL.audioContext.resume();
}
doomMainFn(["-c", "DOOM.EXE"]).then((ci) => { dosInstance = ci; });
}
function toggleDoomFullscreen() {
const canvas = document.getElementById('jsdos');
if (!document.fullscreenElement) {
if (canvas.requestFullscreen) canvas.requestFullscreen();
else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen();
} else {
if (document.exitFullscreen) document.exitFullscreen();
}
}
// The Nuclear Cleanup Crew
document.getElementById('doomModal').addEventListener('hidden.bs.modal', function () {
window.location.reload();
});
/* =========================================
10. INIT
========================================= */
loadCart();
renderPinnedProducts();
</script>
{% endblock %}