Various chechout improvements: subtotal rounding, placeholder color, and weight input now in grams for better UX.
This commit is contained in:
5
app.py
5
app.py
@@ -138,7 +138,10 @@ def index():
|
|||||||
@app.route("/checkout")
|
@app.route("/checkout")
|
||||||
@login_required
|
@login_required
|
||||||
def checkout():
|
def checkout():
|
||||||
return render_template("checkout.html", user=current_user)
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
# Fetching the same columns the scanner expects
|
||||||
|
products = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products').fetchall()
|
||||||
|
return render_template("checkout.html", user=current_user, products=products)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/upsert", methods=["POST"])
|
@app.route("/upsert", methods=["POST"])
|
||||||
|
|||||||
@@ -106,17 +106,22 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control,
|
||||||
background: var(--input-bg);
|
.form-control:focus {
|
||||||
color: var(--text-main);
|
background-color: var(--input-bg) !important;
|
||||||
border: none;
|
color: var(--text-main) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus {
|
.form-control:focus {
|
||||||
background: var(--input-bg);
|
border-color: var(--accent) !important;
|
||||||
color: var(--text-main);
|
outline: none !important;
|
||||||
outline: 2px solid var(--accent);
|
}
|
||||||
box-shadow: none;
|
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
opacity: 1; /* Forces Firefox to respect the color */
|
||||||
}
|
}
|
||||||
|
|
||||||
#grand-total {
|
#grand-total {
|
||||||
@@ -278,6 +283,18 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="cart-card p-3 shadow-sm">
|
<div class="cart-card p-3 shadow-sm">
|
||||||
<h4><i class="bi bi-cart3"></i> Carrito</h4>
|
<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()">
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="dropdown-menu w-100 shadow-lg position-absolute mt-1"
|
||||||
|
style="display: none; max-height: 300px; overflow-y: auto; z-index: 1000;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<table class="table mt-3" id="cart-table">
|
<table class="table mt-3" id="cart-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -321,11 +338,11 @@
|
|||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5>Ingresar Peso (kg)</h5>
|
<h5>Ingresar Peso (Gramos)</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="number" id="weight-input" class="form-control form-control-lg" step="0.001"
|
<input type="number" id="weight-input" class="form-control form-control-lg" step="1"
|
||||||
placeholder="0.000">
|
placeholder="Ej: 400">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-primary w-100" onclick="confirmWeight()">Agregar</button>
|
<button class="btn btn-primary w-100" onclick="confirmWeight()">Agregar</button>
|
||||||
@@ -361,15 +378,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function confirmWeight() {
|
|
||||||
const weight = parseFloat(document.getElementById('weight-input').value);
|
|
||||||
if (weight > 0) {
|
|
||||||
addToCart(pendingProduct, weight);
|
|
||||||
bootstrap.Modal.getInstance('#weightModal').hide();
|
|
||||||
document.getElementById('weight-input').value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCart() {
|
function renderCart() {
|
||||||
const tbody = document.getElementById('cart-items');
|
const tbody = document.getElementById('cart-items');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
@@ -463,12 +471,16 @@
|
|||||||
|
|
||||||
function confirmWeight() {
|
function confirmWeight() {
|
||||||
const weightInput = document.getElementById('weight-input');
|
const weightInput = document.getElementById('weight-input');
|
||||||
const weight = parseFloat(weightInput.value);
|
const weightGrams = parseInt(weightInput.value, 10);
|
||||||
if (weight > 0) {
|
|
||||||
// For weighted items, we usually append new entries or you can sum them.
|
if (weightGrams > 0) {
|
||||||
// Here we append to allow different weighings of the same product type.
|
// Convert back to kg for the cart's subtotal math
|
||||||
addToCart(pendingProduct, weight);
|
const weightKg = weightGrams / 1000;
|
||||||
bootstrap.Modal.getInstance('#weightModal').hide();
|
|
||||||
|
addToCart(pendingProduct, weightKg);
|
||||||
|
|
||||||
|
// Hide modal and clear input
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('weightModal')).hide();
|
||||||
weightInput.value = '';
|
weightInput.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,9 +491,9 @@
|
|||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
cart[existingIndex].qty += qty;
|
cart[existingIndex].qty += qty;
|
||||||
cart[existingIndex].subtotal = cart[existingIndex].qty * cart[existingIndex].price;
|
cart[existingIndex].subtotal = calculateSubtotal(cart[existingIndex].price, cart[existingIndex].qty);
|
||||||
} else {
|
} else {
|
||||||
const subtotal = product.price * qty;
|
const subtotal = calculateSubtotal(product.price, qty);
|
||||||
cart.push({ ...product, qty, subtotal });
|
cart.push({ ...product, qty, subtotal });
|
||||||
}
|
}
|
||||||
renderCart();
|
renderCart();
|
||||||
@@ -575,6 +587,106 @@
|
|||||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||||
applyTheme(savedTheme);
|
applyTheme(savedTheme);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// 1. Load all products from Python into a JavaScript array safely
|
||||||
|
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';
|
||||||
|
|
||||||
|
if (product.unit === 'kg') {
|
||||||
|
pendingProduct = product;
|
||||||
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('weightModal'));
|
||||||
|
modal.show();
|
||||||
|
setTimeout(() => document.getElementById('weight-input').focus(), 500);
|
||||||
|
} else {
|
||||||
|
addToCart(product, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update your existing socket listener to use the new helper
|
||||||
|
socket.on('new_scan', (product) => {
|
||||||
|
handleProductScan(product);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function calculateSubtotal(price, qty) {
|
||||||
|
const rawTotal = price * qty;
|
||||||
|
// Ley del Redondeo: rounds to the nearest 10
|
||||||
|
return Math.round(rawTotal / 10) * 10;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user