905 lines
37 KiB
HTML
905 lines
37 KiB
HTML
{% extends "macros/base.html" %}
|
|
{% from 'macros/modals.html' import confirm_modal, scanner_modal %}
|
|
|
|
{% block title %}Caja{% endblock %}
|
|
|
|
{% block head %}
|
|
<!--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;
|
|
}
|
|
|
|
@media print {
|
|
body * {
|
|
visibility: hidden;
|
|
}
|
|
|
|
#receipt-print-zone, #receipt-print-zone * {
|
|
visibility: visible;
|
|
}
|
|
|
|
#receipt-print-zone {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 58mm;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: block !important;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 10px;
|
|
color: #000;
|
|
}
|
|
|
|
.container-fluid, .main, body {
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
background: #fff !important;
|
|
}
|
|
}
|
|
|
|
.receipt-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.receipt-header {
|
|
text-align: center;
|
|
margin-bottom: 10px;
|
|
border-bottom: 1px dashed #000;
|
|
padding-bottom: 5px;
|
|
}
|
|
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{% 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 id="receipt-print-zone" class="d-none d-print-block">
|
|
<div class="receipt-header">
|
|
<h3 style="margin: 0; font-weight: 800;">SekiPOS</h3>
|
|
<div style="font-size: 10px; margin-bottom: 5px;">Comprobante de Venta</div>
|
|
<div style="font-size: 11px; font-weight: bold;">
|
|
Ticket Nº <span id="receipt-ticket-id"></span>
|
|
</div>
|
|
<div id="receipt-date" style="font-size: 11px;"></div>
|
|
</div>
|
|
<table class="receipt-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 15%;">Cant</th>
|
|
<th style="width: 60%; padding-left: 5px;">Desc</th>
|
|
<th style="width: 25%; text-align: right;">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="receipt-items-print">
|
|
</tbody>
|
|
</table>
|
|
<div class="receipt-total-row d-flex justify-content-between pt-2">
|
|
<span>TOTAL:</span>
|
|
<span id="receipt-total-print"></span>
|
|
</div>
|
|
<div style="text-align: center; margin-top: 20px; font-size: 10px;">¡Gracias por su compra!</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="paymentModal" 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">
|
|
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
|
|
</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 (Pronto)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal fade"
|
|
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="number"
|
|
id="monto-recibido"
|
|
class="form-control form-control-lg text-center fw-bold fs-4"
|
|
placeholder="$0"
|
|
onkeyup="calculateVuelto()"
|
|
onchange="calculateVuelto()"
|
|
onkeydown="if(event.key === 'Enter' && !document.getElementById('btn-confirm-vuelto').disabled) executeCheckout('efectivo')">
|
|
</div>
|
|
<div class="d-flex 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 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 en la base de datos.
|
|
</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="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-accent px-3"
|
|
type="button"
|
|
onclick="openCustomProductModal()"
|
|
title="Agregar manual">
|
|
<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 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">
|
|
<div class="total-banner text-center mb-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-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" onclick="clearCart()">Vaciar</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>
|
|
{% endblock %}
|
|
{% block scripts %}
|
|
<script>
|
|
let editingCartIndex = null;
|
|
let itemIndexToRemove = null;
|
|
let missingProductData = null;
|
|
let tempBarcode = null;
|
|
let cart = [];
|
|
let pendingProduct = null;
|
|
|
|
let socket = io();
|
|
|
|
const clp = new Intl.NumberFormat('es-CL', {
|
|
style: 'currency',
|
|
currency: 'CLP',
|
|
minimumFractionDigits: 0
|
|
});
|
|
|
|
socket.on('scan_error', (data) => {
|
|
missingProductData = data;
|
|
document.getElementById('not-found-barcode').innerText = data.barcode;
|
|
|
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('notFoundModal'));
|
|
modal.show();
|
|
});
|
|
|
|
socket.on('new_scan', (product) => {
|
|
handleProductScan(product);
|
|
});
|
|
|
|
function goToInventory() {
|
|
if (missingProductData) {
|
|
window.location.href = `/?barcode=${missingProductData.barcode}`;
|
|
}
|
|
}
|
|
|
|
function openTempProduct() {
|
|
bootstrap.Modal.getInstance(document.getElementById('notFoundModal')).hide();
|
|
|
|
// Save the actual scanned barcode so it prints on the receipt instead of "MANUAL-XXX"
|
|
tempBarcode = missingProductData.barcode;
|
|
|
|
// Pre-fill the name if the OpenFoodFacts API managed to find it
|
|
document.getElementById('custom-name').value = missingProductData.name || '';
|
|
document.getElementById('custom-price').value = '';
|
|
document.getElementById('custom-unit').value = 'unit';
|
|
|
|
const customModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('customProductModal'));
|
|
customModal.show();
|
|
|
|
// Auto-focus the price if we already have a name, otherwise focus the name
|
|
setTimeout(() => {
|
|
if (missingProductData.name) document.getElementById('custom-price').focus();
|
|
else document.getElementById('custom-name').focus();
|
|
}, 500);
|
|
}
|
|
|
|
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);
|
|
|
|
// This will actually run now
|
|
saveCart();
|
|
}
|
|
|
|
function clearCart() {
|
|
if (cart.length === 0) return;
|
|
const modal = new bootstrap.Modal(document.getElementById('clearCartModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function executeRemoveItem() {
|
|
if (itemIndexToRemove !== null) {
|
|
cart.splice(itemIndexToRemove, 1);
|
|
renderCart();
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('removeConfirmModal'));
|
|
if (modal) modal.hide();
|
|
itemIndexToRemove = null;
|
|
}
|
|
}
|
|
|
|
function executeClearCart() {
|
|
cart = [];
|
|
renderCart();
|
|
bootstrap.Modal.getInstance(document.getElementById('clearCartModal')).hide();
|
|
}
|
|
|
|
function processSale() {
|
|
if (cart.length === 0) return;
|
|
|
|
// Calculate total and show the payment modal
|
|
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
|
document.getElementById('payment-modal-total').innerText = clp.format(total);
|
|
|
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function executeCheckout(method) {
|
|
if (cart.length === 0) return;
|
|
|
|
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) {
|
|
|
|
// Safely hide whichever modal was open
|
|
const pModal = bootstrap.Modal.getInstance(document.getElementById('paymentModal'));
|
|
if (pModal) pModal.hide();
|
|
|
|
const vModal = bootstrap.Modal.getInstance(document.getElementById('vueltoModal'));
|
|
if (vModal) vModal.hide();
|
|
|
|
// Pass the new sale_id from the result to the printer
|
|
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
|
printReceipt(total, result.sale_id);
|
|
|
|
// Show the success checkmark
|
|
const successModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal'));
|
|
successModal.show();
|
|
|
|
// Nuke the cart and auto-save the empty state
|
|
cart = [];
|
|
renderCart();
|
|
|
|
// Auto-hide the success modal after 2 seconds so you don't have to click it
|
|
setTimeout(() => successModal.hide(), 2000);
|
|
|
|
} else {
|
|
alert("Error en la venta: " + (result.error || "Error desconocido"));
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Error de conexión con el servidor.");
|
|
}
|
|
}
|
|
|
|
function confirmWeight() {
|
|
const weightInput = document.getElementById('weight-input');
|
|
const weightGrams = parseInt(weightInput.value, 10);
|
|
|
|
if (weightGrams > 0) {
|
|
const weightKg = weightGrams / 1000;
|
|
|
|
if (editingCartIndex !== null) {
|
|
// We are editing an existing row
|
|
cart[editingCartIndex].qty = weightKg;
|
|
cart[editingCartIndex].subtotal = calculateSubtotal(cart[editingCartIndex].price, weightKg);
|
|
renderCart();
|
|
} else {
|
|
// We are adding a new scan/search
|
|
addToCart(pendingProduct, weightKg);
|
|
}
|
|
|
|
bootstrap.Modal.getInstance(document.getElementById('weightModal')).hide();
|
|
weightInput.value = '';
|
|
editingCartIndex = null; // Clear the tracker
|
|
}
|
|
}
|
|
|
|
function addToCart(product, qty) {
|
|
// Check if product (unit-based only) already exists in cart
|
|
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 {
|
|
const subtotal = calculateSubtotal(product.price, qty);
|
|
cart.push({ ...product, qty, subtotal });
|
|
}
|
|
renderCart();
|
|
}
|
|
|
|
function updateQty(index, delta) {
|
|
if (cart[index].unit === 'kg') return; // Delta buttons disabled for weighted items
|
|
|
|
cart[index].qty += delta;
|
|
if (cart[index].qty <= 0) {
|
|
removeItem(index, cart[index].name);
|
|
} else {
|
|
cart[index].subtotal = cart[index].qty * cart[index].price;
|
|
renderCart();
|
|
}
|
|
}
|
|
|
|
function manualQty(index, val) {
|
|
const newQty = parseFloat(val);
|
|
if (isNaN(newQty) || newQty <= 0) return;
|
|
|
|
cart[index].qty = newQty;
|
|
cart[index].subtotal = cart[index].qty * cart[index].price;
|
|
renderCart();
|
|
// Don't re-render immediately to avoid losing input focus while typing
|
|
}
|
|
|
|
function editWeight(index) {
|
|
editingCartIndex = index;
|
|
const item = cart[index];
|
|
const weightInput = document.getElementById('weight-input');
|
|
|
|
// Convert current kg back to grams for the input
|
|
weightInput.value = Math.round(item.qty * 1000);
|
|
|
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('weightModal'));
|
|
modal.show();
|
|
|
|
// Auto-focus and highlight the existing number so you can just type over it
|
|
setTimeout(() => {
|
|
weightInput.focus();
|
|
weightInput.select();
|
|
}, 500);
|
|
}
|
|
|
|
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. Extracted this into a helper so both Scanner and Search can use it
|
|
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';
|
|
|
|
// Standardize the unit key because Python sends 'unit_type' but the JS array uses 'unit'
|
|
const actualUnit = product.unit || product.unit_type;
|
|
|
|
if (actualUnit === 'kg') {
|
|
pendingProduct = product;
|
|
pendingProduct.unit = 'kg'; // Force it to match the cart's expected format
|
|
|
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('weightModal'));
|
|
modal.show();
|
|
setTimeout(() => document.getElementById('weight-input').focus(), 500);
|
|
} else {
|
|
// Ensure unit products also get the standardized key
|
|
product.unit = 'unit';
|
|
addToCart(product, 1);
|
|
}
|
|
}
|
|
|
|
// 4. The Search Logic
|
|
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;
|
|
}
|
|
|
|
// Find matches by name or barcode
|
|
const matches = allProducts.filter(p =>
|
|
p.name.toLowerCase().includes(query) || p.barcode.includes(query)
|
|
).slice(0, 10); // Limit to 10 results so it doesn't lag out
|
|
|
|
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 => `
|
|
<a href="#" class="dropdown-item d-flex justify-content-between align-items-center py-2" onclick="selectSearchResult('${p.barcode}')">
|
|
<div>
|
|
<strong>${p.name}</strong><br>
|
|
<small class="text-muted font-monospace">${p.barcode}</small>
|
|
</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 selectSearchResult(barcode) {
|
|
const product = allProducts.find(p => p.barcode === barcode);
|
|
if (product) {
|
|
handleProductScan(product);
|
|
}
|
|
|
|
// Clean up UI after selection
|
|
const searchInput = document.getElementById('manual-search');
|
|
searchInput.value = '';
|
|
document.getElementById('search-results').style.display = 'none';
|
|
searchInput.focus(); // Keep focus for fast back-to-back searching
|
|
}
|
|
|
|
// Close the search dropdown if user clicks outside of it
|
|
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';
|
|
}
|
|
});
|
|
|
|
// Make sure we clear the tracker if the user hits Cancel
|
|
document.getElementById('weightModal').addEventListener('hidden.bs.modal', function () {
|
|
document.getElementById('weight-input').value = '';
|
|
pendingProduct = null;
|
|
editingCartIndex = null;
|
|
});
|
|
|
|
function calculateSubtotal(price, qty) {
|
|
const rawTotal = price * qty;
|
|
// Ley del Redondeo: rounds to the nearest 10
|
|
return Math.round(rawTotal / 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("Error cargando el carrito", e);
|
|
cart = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeItem(idx) {
|
|
itemIndexToRemove = idx;
|
|
// Look up the name safely from the array
|
|
document.getElementById('removeItemName').innerText = cart[idx].name;
|
|
|
|
// getOrCreateInstance prevents UI-breaking ghost backdrops
|
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('removeConfirmModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function printReceipt(total, saleId) {
|
|
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>
|
|
`;
|
|
});
|
|
|
|
document.getElementById('receipt-ticket-id').innerText = saleId || "N/A";
|
|
document.getElementById('receipt-total-print').innerText = clp.format(total);
|
|
document.getElementById('receipt-date').innerText = new Date().toLocaleString('es-CL');
|
|
|
|
setTimeout(() => {
|
|
window.print();
|
|
}, 250);
|
|
}
|
|
|
|
function openCustomProductModal() {
|
|
|
|
tempBarcode = null; // Clear any leftover scanned barcodes
|
|
|
|
// Scrub the inputs clean
|
|
document.getElementById('custom-name').value = '';
|
|
document.getElementById('custom-price').value = '';
|
|
document.getElementById('custom-unit').value = 'unit';
|
|
|
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('customProductModal'));
|
|
modal.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;
|
|
}
|
|
|
|
// Use the scanned barcode if it exists, otherwise generate a fake one
|
|
const finalBarcode = tempBarcode ? tempBarcode : `MANUAL-${Date.now().toString().slice(-6)}`;
|
|
|
|
const customProduct = {
|
|
barcode: finalBarcode,
|
|
name: `* ${nameInput}`,
|
|
price: priceInput,
|
|
image: '',
|
|
stock: 0,
|
|
unit: unitInput
|
|
};
|
|
|
|
if (unitInput === 'kg') {
|
|
pendingProduct = customProduct;
|
|
bootstrap.Modal.getInstance(document.getElementById('customProductModal')).hide();
|
|
|
|
const weightModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('weightModal'));
|
|
weightModal.show();
|
|
setTimeout(() => document.getElementById('weight-input').focus(), 500);
|
|
} else {
|
|
addToCart(customProduct, 1);
|
|
bootstrap.Modal.getInstance(document.getElementById('customProductModal')).hide();
|
|
}
|
|
|
|
tempBarcode = null; // Reset it after adding
|
|
}
|
|
|
|
function openVueltoModal() {
|
|
// Hide the main payment selection modal
|
|
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 = '$0';
|
|
document.getElementById('vuelto-amount').className = "fs-1 fw-bold text-muted";
|
|
document.getElementById('btn-confirm-vuelto').disabled = true;
|
|
|
|
// Generate smart quick-buttons (Exacto, 5k, 10k, 20k)
|
|
const quickBox = document.getElementById('vuelto-quick-buttons');
|
|
quickBox.innerHTML = `<button class="btn btn-sm btn-outline-secondary fw-bold" onclick="setMonto(${total})">Exacto</button>`;
|
|
|
|
[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>`;
|
|
}
|
|
});
|
|
|
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('vueltoModal'));
|
|
modal.show();
|
|
|
|
// Auto-focus input so you can start typing immediately
|
|
setTimeout(() => input.focus(), 500);
|
|
}
|
|
|
|
function setMonto(amount) {
|
|
document.getElementById('monto-recibido').value = amount;
|
|
calculateVuelto();
|
|
}
|
|
|
|
function calculateVuelto() {
|
|
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
|
const recibido = parseInt(document.getElementById('monto-recibido').value, 10);
|
|
const vueltoDisplay = document.getElementById('vuelto-amount');
|
|
const confirmBtn = document.getElementById('btn-confirm-vuelto');
|
|
|
|
if (isNaN(recibido) || recibido < total) {
|
|
vueltoDisplay.innerText = "Falta Dinero";
|
|
vueltoDisplay.className = "fs-4 fw-bold text-danger mt-2 d-block";
|
|
confirmBtn.disabled = true;
|
|
} else {
|
|
const vuelto = recibido - total;
|
|
vueltoDisplay.innerText = clp.format(vuelto);
|
|
vueltoDisplay.className = "fs-1 fw-bold text-success";
|
|
confirmBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
loadCart();
|
|
</script>
|
|
{% endblock %}
|