Implemented sales dashboard
This commit is contained in:
@@ -84,6 +84,7 @@ python app.py
|
|||||||
|
|
||||||
## 📋 TODOs?
|
## 📋 TODOs?
|
||||||
- Some form of user registration(?)
|
- Some form of user registration(?)
|
||||||
|
- Major refactoring of the codebase
|
||||||
|
|
||||||
## 🥼 Food Datasets
|
## 🥼 Food Datasets
|
||||||
- https://www.ifpsglobal.com/plu-codes-search
|
- https://www.ifpsglobal.com/plu-codes-search
|
||||||
|
|||||||
91
app.py
91
app.py
@@ -47,6 +47,23 @@ def init_db():
|
|||||||
stock REAL DEFAULT 0,
|
stock REAL DEFAULT 0,
|
||||||
unit_type TEXT DEFAULT 'unit')''')
|
unit_type TEXT DEFAULT 'unit')''')
|
||||||
|
|
||||||
|
# Add these two tables for sales history
|
||||||
|
conn.execute('''CREATE TABLE IF NOT EXISTS sales
|
||||||
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
total REAL,
|
||||||
|
payment_method TEXT)''')
|
||||||
|
|
||||||
|
conn.execute('''CREATE TABLE IF NOT EXISTS sale_items
|
||||||
|
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sale_id INTEGER,
|
||||||
|
barcode TEXT,
|
||||||
|
name TEXT,
|
||||||
|
price REAL,
|
||||||
|
quantity REAL,
|
||||||
|
subtotal REAL,
|
||||||
|
FOREIGN KEY(sale_id) REFERENCES sales(id))''')
|
||||||
|
|
||||||
# Default user logic remains same...
|
# Default user logic remains same...
|
||||||
user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone()
|
user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -326,6 +343,80 @@ def update_scale_weight():
|
|||||||
|
|
||||||
return jsonify({"status": "received"}), 200
|
return jsonify({"status": "received"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/checkout', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def process_checkout():
|
||||||
|
data = request.get_json()
|
||||||
|
cart = data.get('cart', [])
|
||||||
|
payment_method = data.get('payment_method', 'efectivo')
|
||||||
|
|
||||||
|
if not cart:
|
||||||
|
return jsonify({"error": "Cart is empty"}), 400
|
||||||
|
|
||||||
|
# Recalculate total on the server because trusting the frontend is a security risk
|
||||||
|
total = sum(item.get('subtotal', 0) for item in cart)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Create the main sale record
|
||||||
|
cur.execute('INSERT INTO sales (total, payment_method) VALUES (?, ?)', (total, payment_method))
|
||||||
|
sale_id = cur.lastrowid
|
||||||
|
|
||||||
|
# 2. Record each item and deduct stock
|
||||||
|
for item in cart:
|
||||||
|
cur.execute('''INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)''',
|
||||||
|
(sale_id, item['barcode'], item['name'], item['price'], item['qty'], item['subtotal']))
|
||||||
|
|
||||||
|
# Deduct from inventory
|
||||||
|
cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?', (item['qty'], item['barcode']))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify({"status": "success", "sale_id": sale_id}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Checkout Error: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/sales')
|
||||||
|
@login_required
|
||||||
|
def sales():
|
||||||
|
selected_date = request.args.get('date')
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Determine the target date for the "Daily" stat
|
||||||
|
target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0]
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"daily": cur.execute("SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?", (target_date,)).fetchone()[0] or 0,
|
||||||
|
"week": cur.execute("SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')").fetchone()[0] or 0,
|
||||||
|
"month": cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')").fetchone()[0] or 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected_date:
|
||||||
|
sales_data = cur.execute('''SELECT id, date, total, payment_method FROM sales
|
||||||
|
WHERE date(date, 'localtime') = ?
|
||||||
|
ORDER BY date DESC''', (selected_date,)).fetchall()
|
||||||
|
else:
|
||||||
|
sales_data = cur.execute('SELECT id, date, total, payment_method FROM sales ORDER BY date DESC LIMIT 100').fetchall()
|
||||||
|
|
||||||
|
return render_template('sales.html', user=current_user, sales=sales_data, stats=stats, selected_date=selected_date)
|
||||||
|
|
||||||
|
@app.route('/api/sale/<int:sale_id>')
|
||||||
|
@login_required
|
||||||
|
def get_sale_details(sale_id):
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
items = conn.execute('SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall()
|
||||||
|
|
||||||
|
# Format it as a neat list of dictionaries for JavaScript to digest
|
||||||
|
item_list = [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items]
|
||||||
|
return jsonify(item_list), 200
|
||||||
|
|
||||||
# @app.route('/process_payment', methods=['POST'])
|
# @app.route('/process_payment', methods=['POST'])
|
||||||
# @login_required
|
# @login_required
|
||||||
# def process_payment():
|
# def process_payment():
|
||||||
|
|||||||
@@ -241,14 +241,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="executeCheckout('efectivo')">
|
||||||
|
<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')" disabled>
|
||||||
|
<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>
|
||||||
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
||||||
<span class="navbar-brand">SekiPOS <small class="text-muted fw-normal"
|
<span class="navbar-brand">SekiPOS <small class="text-muted fw-normal"
|
||||||
style="font-size:0.65rem;">Caja</small></span>
|
style="font-size:0.65rem;">Caja</small></span>
|
||||||
|
|
||||||
<div class="ms-3">
|
<div class="ms-3 gap-2 d-flex">
|
||||||
<a href="/" class="btn btn-outline-primary btn-sm">
|
<a href="/" class="btn btn-outline-primary btn-sm">
|
||||||
<i class="bi bi-box-seam me-1"></i>Inventario
|
<i class="bi bi-box-seam me-1"></i>Inventario
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/sales" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-receipt me-1"></i>Ventas
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-auto">
|
<div class="ms-auto">
|
||||||
@@ -442,41 +467,53 @@
|
|||||||
bootstrap.Modal.getInstance(document.getElementById('clearCartModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('clearCartModal')).hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processSale() {
|
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;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
|
||||||
|
|
||||||
// Disable button to prevent spam
|
|
||||||
const btn = document.querySelector('button[onclick="processSale()"]');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Procesando...';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
alert("total: " + total)
|
const response = await fetch('/api/checkout', {
|
||||||
// const response = await fetch('/process_payment', {
|
method: 'POST',
|
||||||
// method: 'POST',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ cart: cart, payment_method: method })
|
||||||
// body: JSON.stringify({ total: total })
|
});
|
||||||
// });
|
|
||||||
|
|
||||||
// const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// if (response.ok) {
|
if (response.ok) {
|
||||||
// const modal = new bootstrap.Modal(document.getElementById('successModal'));
|
// Hide the payment modal
|
||||||
// modal.show();
|
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
|
||||||
// cart = [];
|
|
||||||
// renderCart();
|
// Show the success checkmark
|
||||||
// } else {
|
const successModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal'));
|
||||||
// alert("Error en el pago: " + (result.message || "Error desconocido"));
|
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) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
alert("Error de conexión con el servidor.");
|
alert("Error de conexión con el servidor.");
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="bi bi-cash-coin"></i> COBRAR';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmWeight() {
|
function confirmWeight() {
|
||||||
const weightInput = document.getElementById('weight-input');
|
const weightInput = document.getElementById('weight-input');
|
||||||
const weightGrams = parseInt(weightInput.value, 10);
|
const weightGrams = parseInt(weightInput.value, 10);
|
||||||
|
|||||||
@@ -278,9 +278,12 @@
|
|||||||
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
||||||
<span class="navbar-brand">SekiPOS <small class="text-muted fw-normal"
|
<span class="navbar-brand">SekiPOS <small class="text-muted fw-normal"
|
||||||
style="font-size:0.65rem;">v1.6</small></span>
|
style="font-size:0.65rem;">v1.6</small></span>
|
||||||
<div class="ms-3">
|
<div class="ms-3 gap-2 d-flex">
|
||||||
<a href="/checkout" class="btn btn-outline-primary btn-sm">
|
<a href="/checkout" class="btn btn-outline-primary btn-sm">
|
||||||
<i class="bi bi-cart-fill me-1"></i>Ir a Caja
|
<i class="bi bi-cart-fill me-1"></i>Caja
|
||||||
|
</a>
|
||||||
|
<a href="/sales" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-receipt me-1"></i>Ventas
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- Always-visible dropdown on the right -->
|
<!-- Always-visible dropdown on the right -->
|
||||||
@@ -664,7 +667,7 @@
|
|||||||
// Force integers here to nuke the decimals once and for all
|
// Force integers here to nuke the decimals once and for all
|
||||||
document.getElementById('form-price').value = p ? parseInt(p, 10) : '';
|
document.getElementById('form-price').value = p ? parseInt(p, 10) : '';
|
||||||
document.getElementById('form-stock').value = stock ? parseInt(stock, 10) : 0;
|
document.getElementById('form-stock').value = stock ? parseInt(stock, 10) : 0;
|
||||||
|
|
||||||
document.getElementById('form-unit-type').value = unit || 'unit';
|
document.getElementById('form-unit-type').value = unit || 'unit';
|
||||||
document.getElementById('form-image').value = i || '';
|
document.getElementById('form-image').value = i || '';
|
||||||
document.getElementById('form-title').innerText = t;
|
document.getElementById('form-title').innerText = t;
|
||||||
|
|||||||
271
templates/sales.html
Normal file
271
templates/sales.html
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SekiPOS - Ventas</title>
|
||||||
|
<link rel="shortcut icon" href="./static/favicon.png" type="image/x-icon">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* Reusing your standard theme colors */
|
||||||
|
:root {
|
||||||
|
--bg: #ebedef; --card-bg: #ffffff; --text-main: #2e3338;
|
||||||
|
--text-muted: #4f5660; --border: #e3e5e8; --navbar-bg: #ffffff;
|
||||||
|
--accent: #5865f2; --accent-hover: #4752c4; --input-bg: #e3e5e8;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #36393f; --card-bg: #2f3136; --text-main: #dcddde;
|
||||||
|
--text-muted: #b9bbbe; --border: #202225; --navbar-bg: #202225;
|
||||||
|
--input-bg: #202225;
|
||||||
|
}
|
||||||
|
body { background: var(--bg); color: var(--text-main); font-family: "gg sans", sans-serif; min-height: 100vh; }
|
||||||
|
.navbar { background: var(--navbar-bg) !important; border-bottom: 1px solid var(--border); }
|
||||||
|
.navbar-brand, .nav-link, .dropdown-item { color: var(--text-main) !important; }
|
||||||
|
.dropdown-menu { background: var(--card-bg); border: 1px solid var(--border); }
|
||||||
|
.dropdown-item:hover { background: var(--input-bg); }
|
||||||
|
.discord-card, .modal-content { background: var(--card-bg) !important; border: 1px solid var(--border); border-radius: 8px; }
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tables --- */
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-color: var(--text-main); /* This forces Bootstrap to obey */
|
||||||
|
--bs-table-border-color: var(--border);
|
||||||
|
--bs-table-hover-color: var(--text-main);
|
||||||
|
--bs-table-hover-bg: var(--input-bg);
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
}
|
||||||
|
.table thead th {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.table td, .table th {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons --- */
|
||||||
|
.btn-accent { background: var(--accent); color: #fff; border: none; }
|
||||||
|
.btn-accent:hover { background: var(--accent-hover); color: #fff; }
|
||||||
|
|
||||||
|
/* --- Dark Mode Overrides --- */
|
||||||
|
[data-theme="dark"] .modal-content,
|
||||||
|
[data-theme="dark"] .modal-body { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .table { --bs-table-hover-bg: rgba(255, 255, 255, 0.05); }
|
||||||
|
[data-theme="dark"] .table thead th { background: #292b2f; color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .btn-close { filter: invert(1) grayscale(100%) brightness(200%); }
|
||||||
|
[data-theme="dark"] .text-muted { color: var(--text-muted) !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
||||||
|
<span class="navbar-brand">SekiPOS <small class="text-muted fw-normal" style="font-size:0.65rem;">Ventas</small></span>
|
||||||
|
<div class="ms-3 gap-2 d-flex">
|
||||||
|
<a href="/" class="btn btn-outline-primary btn-sm"><i class="bi bi-box-seam me-1"></i>Inventario</a>
|
||||||
|
<a href="/checkout" class="btn btn-outline-primary btn-sm"><i class="bi bi-cart-fill me-1"></i>Caja</a>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-accent dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-person-circle me-1"></i> <span class="d-none d-sm-inline">{{ user.username }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end shadow">
|
||||||
|
<li><button class="dropdown-item" onclick="toggleTheme()"><i class="bi bi-moon-stars me-2" id="theme-icon"></i><span id="theme-label">Modo Oscuro</span></button></li>
|
||||||
|
<li><hr class="dropdown-divider" style="border-color: var(--border);"></li>
|
||||||
|
<li><a class="dropdown-item text-danger" href="/logout"><i class="bi bi-box-arrow-right me-2"></i>Salir</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="discord-card p-3 shadow-sm text-center">
|
||||||
|
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">
|
||||||
|
{% if selected_date %}Día Seleccionado{% else %}Ventas de Hoy{% endif %}
|
||||||
|
</h6>
|
||||||
|
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.daily }}"></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="discord-card p-3 shadow-sm text-center">
|
||||||
|
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Últimos 7 Días</h6>
|
||||||
|
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.week }}"></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="discord-card p-3 shadow-sm text-center">
|
||||||
|
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Este Mes</h6>
|
||||||
|
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.month }}"></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="discord-card p-3 shadow-sm">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-receipt-cutoff me-2"></i>Historial</h4>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label for="date-filter" class="form-label mb-0 text-muted small d-none d-sm-block">Filtrar Día:</label>
|
||||||
|
<input type="date" id="date-filter" class="form-control form-control-sm"
|
||||||
|
style="background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
|
||||||
|
value="{{ selected_date or '' }}" onchange="filterByDate(this.value)">
|
||||||
|
{% if selected_date %}
|
||||||
|
<a href="/sales" class="btn btn-sm btn-outline-secondary" title="Limpiar filtro"><i class="bi bi-x-lg"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nº Ticket</th>
|
||||||
|
<th>Fecha y Hora</th>
|
||||||
|
<th>Método</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sales %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-monospace text-muted">#{{ s[0] }}</td>
|
||||||
|
<td class="utc-date">{{ s[1] }}</td>
|
||||||
|
<td class="text-capitalize">{{ s[3] }}</td>
|
||||||
|
<td class="price-cell fw-bold" data-value="{{ s[2] }}"></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="viewSale({{ s[0] }}, '{{ s[1] }}', {{ s[2] }})">
|
||||||
|
<i class="bi bi-eye"></i> Ver Detalle
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="receiptModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Detalle de Venta <span id="modal-ticket-id" class="text-muted small"></span></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted small mb-2" id="modal-date"></p>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Producto</th>
|
||||||
|
<th>Cant</th>
|
||||||
|
<th class="text-end">Subtotal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="receipt-items">
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" class="text-end">TOTAL:</th>
|
||||||
|
<th class="text-end fs-5" id="modal-total" style="color: var(--accent);"></th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
// Format raw UTC dates from DB into friendly local time
|
||||||
|
document.querySelectorAll('.utc-date').forEach(el => {
|
||||||
|
const date = new Date(el.innerText + " UTC");
|
||||||
|
if (!isNaN(date)) {
|
||||||
|
el.innerText = date.toLocaleString('es-CL', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute:'2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format all prices
|
||||||
|
document.querySelectorAll('.price-cell').forEach(td => {
|
||||||
|
td.innerText = clp.format(td.getAttribute('data-value'));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function viewSale(id, rawDate, total) {
|
||||||
|
document.getElementById('modal-ticket-id').innerText = `#${id}`;
|
||||||
|
document.getElementById('modal-total').innerText = clp.format(total);
|
||||||
|
|
||||||
|
const localDate = new Date(rawDate + " UTC").toLocaleString('es-CL');
|
||||||
|
document.getElementById('modal-date').innerText = localDate !== "Invalid Date" ? localDate : rawDate;
|
||||||
|
|
||||||
|
const tbody = document.getElementById('receipt-items');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">Cargando...</td></tr>';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('receiptModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sale/${id}`);
|
||||||
|
const items = await res.json();
|
||||||
|
|
||||||
|
tbody.innerHTML = items.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
${item.name}<br>
|
||||||
|
<small class="text-muted font-monospace" style="font-size: 0.7rem;">${item.barcode}</small>
|
||||||
|
</td>
|
||||||
|
<td>${item.qty}</td>
|
||||||
|
<td class="text-end">${clp.format(item.subtotal)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-danger">Error cargando productos</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByDate(dateVal) {
|
||||||
|
if (dateVal) {
|
||||||
|
window.location.href = `/sales?date=${dateVal}`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `/sales`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Management */
|
||||||
|
function applyTheme(t) {
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
const isDark = t === 'dark';
|
||||||
|
const icon = document.getElementById('theme-icon');
|
||||||
|
const label = document.getElementById('theme-label');
|
||||||
|
if (icon) icon.className = isDark ? 'bi bi-sun me-2' : 'bi bi-moon-stars me-2';
|
||||||
|
if (label) label.innerText = isDark ? 'Modo Claro' : 'Modo Oscuro';
|
||||||
|
localStorage.setItem('theme', t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
(function initTheme() {
|
||||||
|
applyTheme(localStorage.getItem('theme') || 'light');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user