WIP rewrite with macros

This commit is contained in:
2026-03-10 20:15:58 -03:00
parent ef9a9296dd
commit cb2aa89b16
11 changed files with 1837 additions and 2517 deletions

81
app.py
View File

@@ -143,7 +143,7 @@ def login():
user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone() user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone()
if user and check_password_hash(user[2], pass_in): if user and check_password_hash(user[2], pass_in):
login_user(User(user[0], user[1])) login_user(User(user[0], user[1]))
return redirect(url_for('index')) return redirect(url_for('inventory'))
flash('Invalid credentials.') flash('Invalid credentials.')
return render_template('login.html') return render_template('login.html')
@@ -155,10 +155,15 @@ def logout():
@app.route('/') @app.route('/')
@login_required @login_required
def index(): def defaultRoute():
return redirect(url_for('inventory'))
@app.route('/inventory')
@login_required
def inventory():
with sqlite3.connect(DB_FILE) as conn: with sqlite3.connect(DB_FILE) as conn:
products = conn.execute('SELECT * FROM products').fetchall() products = conn.execute('SELECT * FROM products').fetchall()
return render_template('index.html', products=products, user=current_user) return render_template('inventory.html', active_page='inventory', products=products, user=current_user)
@app.route("/checkout") @app.route("/checkout")
@login_required @login_required
@@ -166,7 +171,39 @@ def checkout():
with sqlite3.connect(DB_FILE) as conn: with sqlite3.connect(DB_FILE) as conn:
# Fetching the same columns the scanner expects # Fetching the same columns the scanner expects
products = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products').fetchall() 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) return render_template("checkout.html", active_page='checkout', user=current_user, products=products)
@app.route('/dicom')
@login_required
def dicom():
with sqlite3.connect(DB_FILE) as conn:
debtors = conn.execute('SELECT id, name, amount, notes, datetime(last_updated, "localtime") FROM dicom ORDER BY amount DESC').fetchall()
return render_template('dicom.html', active_page='dicom', user=current_user, debtors=debtors)
@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', active_page='sales', user=current_user, sales=sales_data, stats=stats, selected_date=selected_date)
@app.route("/upsert", methods=["POST"]) @app.route("/upsert", methods=["POST"])
@@ -197,7 +234,7 @@ def upsert():
unit_type=excluded.unit_type''', unit_type=excluded.unit_type''',
(barcode, d['name'], price, final_image_path, stock, unit_type)) (barcode, d['name'], price, final_image_path, stock, unit_type))
conn.commit() conn.commit()
return redirect(url_for('index')) return redirect(url_for('inventory'))
@app.route('/delete/<barcode>', methods=['POST']) @app.route('/delete/<barcode>', methods=['POST'])
@login_required @login_required
@@ -209,7 +246,7 @@ def delete(barcode):
img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg") img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg")
if os.path.exists(img_p): os.remove(img_p) if os.path.exists(img_p): os.remove(img_p)
socketio.emit('product_deleted', {"barcode": barcode}) socketio.emit('product_deleted', {"barcode": barcode})
return redirect(url_for('index')) return redirect(url_for('inventory'))
@app.route('/scan', methods=['GET']) @app.route('/scan', methods=['GET'])
def scan(): def scan():
@@ -390,31 +427,6 @@ def process_checkout():
print(f"Checkout Error: {e}") print(f"Checkout Error: {e}")
return jsonify({"error": str(e)}), 500 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>') @app.route('/api/sale/<int:sale_id>')
@login_required @login_required
def get_sale_details(sale_id): def get_sale_details(sale_id):
@@ -452,12 +464,7 @@ def reverse_sale(sale_id):
print(f"Reverse Sale Error: {e}") print(f"Reverse Sale Error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/dicom')
@login_required
def dicom():
with sqlite3.connect(DB_FILE) as conn:
debtors = conn.execute('SELECT id, name, amount, notes, datetime(last_updated, "localtime") FROM dicom ORDER BY amount DESC').fetchall()
return render_template('dicom.html', user=current_user, debtors=debtors)
@app.route('/api/dicom/update', methods=['POST']) @app.route('/api/dicom/update', methods=['POST'])
@login_required @login_required

View File

@@ -30,7 +30,6 @@
transition: background 0.2s, color 0.2s; transition: background 0.2s, color 0.2s;
} }
/* ── Navbar ── */
.navbar { .navbar {
background: var(--navbar-bg) !important; background: var(--navbar-bg) !important;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -107,16 +106,12 @@
color: #fff; color: #fff;
} }
/* ── Price tag ── */
.price-tag { .price-tag {
font-size: 2.8rem; font-size: 2.8rem;
/* Slightly larger for the wider card */
font-weight: 800; font-weight: 800;
color: var(--accent); color: var(--accent);
/* Optional: uses your accent color for better visibility */
} }
/* ── Table ── */
.table { .table {
color: var(--text-main); color: var(--text-main);
--bs-table-color: var(--text-main); --bs-table-color: var(--text-main);
@@ -124,7 +119,6 @@
--bs-table-border-color: var(--border); --bs-table-border-color: var(--border);
} }
/* -- Checkbox Size Fix -- */
#select-all { #select-all {
transform: scale(1.3); transform: scale(1.3);
margin-top: 2px; margin-top: 2px;
@@ -166,31 +160,24 @@
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
} }
/* ── New-product prompt ── */
.new-product-prompt { .new-product-prompt {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: 8px; border-radius: 8px;
} }
/* ── Product image ── */
#display-img { #display-img {
width: 100%; width: 100%;
/* Allows it to fill the new width */
max-width: 250px; max-width: 250px;
/* Increased from 160px */
height: auto; height: auto;
max-height: 250px; max-height: 250px;
/* Increased from 160px */
object-fit: contain; object-fit: contain;
} }
/* ── Checkbox ── */
input[type="checkbox"] { input[type="checkbox"] {
cursor: pointer; cursor: pointer;
} }
/* ── Mobile: hide barcode column ── */
@media (max-width: 576px) { @media (max-width: 576px) {
.col-barcode { .col-barcode {
display: none; display: none;
@@ -215,7 +202,6 @@
} }
.btn-close { .btn-close {
/* Makes the X button visible in dark mode */
filter: var(--bs-theme-placeholder, invert(0.7) grayscale(100%) brightness(200%)); filter: var(--bs-theme-placeholder, invert(0.7) grayscale(100%) brightness(200%));
} }

View File

@@ -1,297 +1,24 @@
<!DOCTYPE html> {% extends "macros/base.html" %}
<html lang="es" data-theme="light"> {% from 'macros/modals.html' import confirm_modal, scanner_modal %}
<head> {% block title %}Caja{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SekiPOS - Caja</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">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<style>
: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"] { {% block head %}
--bg: #36393f; <!--HEAD-->
--card-bg: #2f3136; {% endblock %}
--text-main: #dcddde;
--text-muted: #b9bbbe;
--border: #202225;
--navbar-bg: #202225;
--input-bg: #202225;
}
body { {% block content %}
background: var(--bg);
color: var(--text-main);
font-family: "gg sans", "Segoe UI", sans-serif;
transition: background 0.2s, color 0.2s;
min-height: 100vh;
}
/* ── Navbar ── */ {% call confirm_modal('removeConfirmModal', 'Quitar Producto', 'btn-danger-discord', 'Quitar', 'executeRemoveItem()') %}
.navbar { ¿Estás seguro de que quieres quitar <strong id="removeItemName"></strong> del carrito?
background: var(--navbar-bg) !important; {% endcall %}
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
.navbar-brand { {% call confirm_modal('clearCartModal', 'Vaciar Carrito', 'btn-danger-discord', 'Sí, vaciar', 'executeClearCart()') %}
color: var(--text-main) !important; <div class="text-center">
font-weight: 700; <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>
/* ── Cards ── */ {% endcall %}
.cart-card,
.discord-card,
.modal-content {
background: var(--card-bg) !important;
color: var(--text-main);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* ── Product Preview (Matching Index) ── */
#display-img {
width: 100%;
max-width: 250px;
height: 250px;
object-fit: contain;
background: var(--bg);
border-radius: 8px;
padding: 10px;
}
/* ── Table Styling ── */
.table {
color: var(--text-main) !important;
--bs-table-bg: transparent;
--bs-table-border-color: var(--border);
}
.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 {
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
/* ── UI Elements ── */
.btn-accent {
background: var(--accent);
color: #fff;
border: none;
}
.btn-accent:hover {
background: var(--accent-hover);
color: #fff;
}
.form-control,
.form-control:focus {
background-color: var(--input-bg) !important;
color: var(--text-main) !important;
border: 1px solid var(--border) !important;
box-shadow: none !important;
}
.form-control:focus {
border-color: var(--accent) !important;
outline: none !important;
}
.form-control::placeholder {
color: var(--text-muted) !important;
opacity: 1;
/* Forces Firefox to respect the color */
}
#grand-total {
color: var(--accent);
font-family: "gg sans", sans-serif;
}
.dropdown-menu {
background: var(--card-bg);
border: 1px solid var(--border);
}
.dropdown-item {
color: var(--text-main) !important;
}
.dropdown-item:hover {
background: var(--input-bg);
}
.btn-danger-discord {
background: var(--danger, #ed4245);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
transition: background 0.2s;
}
.btn-danger-discord:hover {
background: #c23235;
color: #fff;
}
[data-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
[data-theme="dark"] .table {
--bs-table-color: var(--text-main);
color: var(--text-main) !important;
}
[data-theme="dark"] .table thead th {
background: #292b2f;
color: var(--text-muted);
}
[data-theme="dark"] .text-muted {
color: var(--text-muted) !important;
}
/* Fix for the weight modal text */
[data-theme="dark"] .modal-body {
color: var(--text-main);
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
/* ── Thermal Printer Styles (80mm) ── */
@media print {
/* Kill all animations instantly so the printer doesn't photograph a mid-fade background */
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
}
/* Force true white background */
html,
body {
background: #ffffff !important;
color: #000000 !important;
margin: 0 !important;
padding: 0 !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.navbar,
.container-fluid,
.modal {
display: none !important;
}
#receipt-print-zone {
display: block !important;
width: 80mm;
padding: 0;
margin: 0;
font-family: "Courier New", Courier, monospace;
font-size: 12px;
background: #ffffff !important;
}
/* Force extra bold black text with no backgrounds */
#receipt-print-zone * {
background: transparent !important;
color: #000000 !important;
opacity: 1 !important;
font-weight: 800 !important;
text-shadow: none !important;
box-shadow: none !important;
}
@page {
margin: 0;
}
.receipt-header {
text-align: center;
margin-bottom: 10px;
}
.receipt-table {
width: 100%;
margin-bottom: 10px;
}
.receipt-table th {
text-align: left;
border-bottom: 1px dashed #000 !important;
padding-bottom: 3px;
}
.receipt-table td {
padding: 3px 0;
vertical-align: top;
}
.receipt-total-row {
border-top: 1px dashed #000 !important;
font-weight: 800 !important;
font-size: 14px;
}
}
/* ── Dropdown Select Fix ── */
.form-select,
.form-select:focus {
background-color: var(--input-bg) !important;
color: var(--text-main) !important;
border: 1px solid var(--border) !important;
box-shadow: none !important;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23adb5bd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
}
.form-select:focus {
border-color: var(--accent) !important;
}
[data-theme="dark"] .form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dcddde' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
}
</style>
</head>
<body>
{% with active_page='checkout' %}{% include 'navbar.html' %}{% endwith %}
<div class="modal fade" id="customProductModal" tabindex="-1" data-bs-backdrop="static"> <div class="modal fade" id="customProductModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@@ -357,42 +84,6 @@
¡Gracias por su compra! ¡Gracias por su compra!
</div> </div>
</div> </div>
<div class="modal fade" id="removeConfirmModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Quitar Producto</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
¿Estás seguro de que quieres quitar <strong id="removeItemName"></strong> del carrito?
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-danger-discord" id="btn-confirm-remove">Quitar</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="clearCartModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Vaciar Carrito</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body 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>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">No, volver</button>
<button class="btn btn-danger-discord" onclick="executeClearCart()">Sí, vaciar</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="successModal" tabindex="-1"> <div class="modal fade" id="successModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-success"> <div class="modal-content border-success">
@@ -498,7 +189,7 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-8"> <div class="col-md-8">
<div class="cart-card p-3 shadow-sm"> <div class="discord-card p-3">
<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="position-relative mb-4">
<div class="input-group"> <div class="input-group">
@@ -518,6 +209,7 @@
style="display: none; position: absolute; top: 100%; left: 0; z-index: 1000; max-height: 300px; overflow-y: auto;"> style="display: none; position: absolute; top: 100%; left: 0; z-index: 1000; max-height: 300px; overflow-y: auto;">
</div> </div>
</div> </div>
<div class="table-responsive">
<table class="table mt-3" id="cart-table"> <table class="table mt-3" id="cart-table">
<thead> <thead>
<tr> <tr>
@@ -534,6 +226,7 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<div class="col-md-4"> <div class="col-md-4">
<div class="discord-card p-3 mb-3 text-center shadow-sm"> <div class="discord-card p-3 mb-3 text-center shadow-sm">
@@ -542,7 +235,7 @@
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product"> <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> <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> <small id="display-barcode" class="text-muted font-monospace" style="font-size: 0.7rem;"></small>
</div>
<div class="total-banner text-center mb-3"> <div class="total-banner text-center mb-3">
<div class="total-banner text-center mb-3"> <div class="total-banner text-center mb-3">
@@ -556,6 +249,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="modal fade" id="weightModal" tabindex="-1" data-bs-backdrop="static"> <div class="modal fade" id="weightModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@@ -575,17 +269,26 @@
</div> </div>
</div> </div>
</div>
{% endblock %}
{% block scripts %}
<script> <script>
let editingCartIndex = null; let editingCartIndex = null;
let itemIndexToRemove = null; let itemIndexToRemove = null;
let missingProductData = null; let missingProductData = null;
let tempBarcode = null; // Stores the real barcode if we do a temp sale let tempBarcode = null;
const socket = io();
let cart = []; let cart = [];
let pendingProduct = null; let pendingProduct = null;
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
let socket = io();
const clp = new Intl.NumberFormat('es-CL', {
style: 'currency',
currency: 'CLP',
minimumFractionDigits: 0
});
socket.on('scan_error', (data) => { socket.on('scan_error', (data) => {
missingProductData = data; missingProductData = data;
@@ -595,6 +298,10 @@
modal.show(); modal.show();
}); });
socket.on('new_scan', (product) => {
handleProductScan(product);
});
function goToInventory() { function goToInventory() {
if (missingProductData) { if (missingProductData) {
window.location.href = `/?barcode=${missingProductData.barcode}`; window.location.href = `/?barcode=${missingProductData.barcode}`;
@@ -677,6 +384,16 @@
modal.show(); 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() { function executeClearCart() {
cart = []; cart = [];
renderCart(); renderCart();
@@ -817,18 +534,17 @@
} }
// 1. Load all products from Python into a JavaScript array safely // 1. Load all products from Python into a JavaScript array safely
const allProducts = [ const allProducts = {{ products | map('list') | list | tojson | safe }};
{% for p in products %}
{ // Map the array to objects so your existing code works (barcode, name, price, image, stock, unit)
barcode: { { p[0] | tojson } }, const formattedProducts = allProducts.map(p => ({
name: { { p[1] | tojson } }, barcode: p[0],
price: { { p[2] | int } }, name: p[1],
image: { { p[3] | tojson } }, price: parseInt(p[2]),
stock: { { p[4] | int } }, image: p[3] || '',
unit: { { p[5] | tojson } } stock: p[4] || 0,
}, unit: p[5] || 'unit'
{% endfor %} }));
];
// 2. Extracted this into a helper so both Scanner and Search can use it // 2. Extracted this into a helper so both Scanner and Search can use it
function handleProductScan(product) { function handleProductScan(product) {
@@ -853,11 +569,6 @@
} }
} }
// 3. Update your existing socket listener to use the new helper
socket.on('new_scan', (product) => {
handleProductScan(product);
});
// 4. The Search Logic // 4. The Search Logic
function filterSearch() { function filterSearch() {
const query = document.getElementById('manual-search').value.toLowerCase().trim(); const query = document.getElementById('manual-search').value.toLowerCase().trim();
@@ -1030,16 +741,6 @@
tempBarcode = null; // Reset it after adding tempBarcode = null; // Reset it after adding
} }
// Ensure the listener is intact
document.getElementById('btn-confirm-remove').addEventListener('click', () => {
if (itemIndexToRemove !== null) {
cart.splice(itemIndexToRemove, 1);
renderCart(); // This will auto-save the cart now!
bootstrap.Modal.getInstance(document.getElementById('removeConfirmModal')).hide();
itemIndexToRemove = null;
}
});
function openVueltoModal() { function openVueltoModal() {
// Hide the main payment selection modal // Hide the main payment selection modal
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
@@ -1095,10 +796,4 @@
loadCart(); loadCart();
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> {% endblock %}
<script src="./static/cookieStuff.js"></script>
<script src="./static/themeStuff.js"></script>
</body>
</html>

View File

@@ -1,143 +1,21 @@
<!DOCTYPE html> {% extends "macros/base.html" %}
<html lang="es" data-theme="light"> {% from 'macros/modals.html' import confirm_modal, scanner_modal %}
<head> {% block title %}Ventas{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SekiPOS - Dicom</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>
:root {
--bg: #ebedef;
--card-bg: #ffffff;
--text-main: #2e3338;
--text-muted: #4f5660;
--border: #e3e5e8;
--navbar-bg: #ffffff;
--accent: #5865f2;
--input-bg: #e3e5e8;
--danger: #ed4245;
}
[data-theme="dark"] { {% block head %}
--bg: #36393f; <!--HEAD-->
--card-bg: #2f3136; {% endblock %}
--text-main: #dcddde;
--text-muted: #b9bbbe;
--border: #202225;
--navbar-bg: #202225;
--input-bg: #202225;
}
body { {% block content %}
background: var(--bg);
color: var(--text-main);
font-family: "gg sans", "Segoe UI", sans-serif;
transition: background 0.2s;
}
.navbar {
background: var(--navbar-bg) !important;
border-bottom: 1px solid var(--border);
}
.navbar-brand {
color: var(--text-main) !important;
font-weight: 700;
}
.discord-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-control,
.form-control:focus {
background-color: var(--input-bg) !important;
color: var(--text-main) !important;
border: 1px solid var(--border) !important;
box-shadow: none !important;
}
.form-control:focus {
border-color: var(--accent) !important;
}
.form-control::placeholder {
color: var(--text-muted) !important;
opacity: 1;
}
.table {
color: var(--text-main) !important;
--bs-table-bg: transparent;
--bs-table-border-color: var(--border);
}
.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 {
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.btn-accent {
background: var(--accent);
color: #fff;
border: none;
}
.dropdown-menu {
background: var(--card-bg);
border: 1px solid var(--border);
}
.dropdown-item {
color: var(--text-main) !important;
}
.dropdown-item:hover {
background: var(--input-bg);
}
[data-theme="dark"] .table {
--bs-table-color: var(--text-main);
color: var(--text-main) !important;
}
[data-theme="dark"] .table thead th {
background: #292b2f;
color: var(--text-muted);
}
[data-theme="dark"] .text-muted {
color: var(--text-muted) !important;
}
</style>
</head>
<body>
{% with active_page='dicom' %}{% include 'navbar.html' %}{% endwith %}
<div class="container-fluid px-3">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<div class="discord-card p-3 mb-3"> <div class="discord-card p-3 mb-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold">Registrar Movimiento</h5> <h5 class="mb-0 fw-bold">Registrar Movimiento</h5>
<button class="btn btn-sm btn-outline-secondary" onclick="clearDicomForm()" <button class="btn btn-sm btn-outline-secondary" onclick="clearDicomForm()" title="Limpiar Formulario">
title="Limpiar Formulario">
<i class="bi bi-eraser"></i> Nuevo <i class="bi bi-eraser"></i> Nuevo
</button> </button>
</div> </div>
@@ -194,8 +72,8 @@
<td class="text-muted small">{{ d[3] }}</td> <td class="text-muted small">{{ d[3] }}</td>
<td class="text-muted small">{{ d[4] }}</td> <td class="text-muted small">{{ d[4] }}</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-outline-secondary" <button class="btn btn-sm btn-outline-secondary" onclick="selectClient('{{ d[1] }}')"
onclick="selectClient('{{ d[1] }}')" title="Seleccionar"> title="Seleccionar">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-outline-danger ms-1" <button class="btn btn-sm btn-outline-danger ms-1"
@@ -213,8 +91,9 @@
</div> </div>
</div> </div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> {% block scripts %}
<script> <script>
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 }); const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
@@ -290,8 +169,4 @@
if (res.ok) window.location.reload(); if (res.ok) window.location.reload();
} }
</script> </script>
<script src="./static/cookieStuff.js"></script> {% endblock %}
<script src="./static/themeStuff.js"></script>
</body>
</html>

View File

@@ -1,821 +0,0 @@
<!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 - Inventory</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">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<script src="https://unpkg.com/html5-qrcode"></script>
<link rel="stylesheet" href="./static/style.css">
</head>
<body>
{% with active_page='inventory' %}{% include 'navbar.html' %}{% endwith %}
<!-- ════════════════════════ MAIN ════════════════════════ -->
<div class="container-fluid px-3">
<div class="row g-3">
<!-- ── LEFT COLUMN ── -->
<div class="col-12 col-lg-5">
<!-- New product prompt -->
<div id="new-product-prompt"
class="new-product-prompt p-3 mb-3 d-none d-flex justify-content-between align-items-center">
<span>Nuevo: <b id="new-barcode-display"></b></span>
<button onclick="dismissPrompt()" class="btn btn-sm"
style="background:rgba(0,0,0,0.25);color:#fff;">
Omitir
</button>
</div>
<!-- Last scanned card -->
<div class="discord-card p-3 mb-3 text-center">
<p class="mb-1 fw-semibold"
style="color:var(--text-muted); font-size:0.8rem; text-transform:uppercase; letter-spacing:.05em;">
Último Escaneado</p>
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
<h5 id="display-name" class="mb-1">Esperando scan...</h5>
<div class="price-tag" id="display-price">$0</div>
<p id="display-barcode" class="mb-0 mt-1"
style="font-family:monospace; opacity:.5; font-size:.8rem;"></p>
</div>
<button class="btn btn-accent mb-3 w-100" onclick="startScanner()">
<i class="bi bi-qr-code-scan me-2"></i>Escanear con Cámara
</button>
<!-- Edit / Create card -->
<div class="discord-card p-3">
<h6 id="form-title" class="mb-3 fw-bold">Editar / Crear</h6>
<form action="/upsert" method="POST" id="product-form">
<input class="form-control mb-2" type="text" name="barcode" id="form-barcode"
placeholder="Barcode" required>
<input class="form-control mb-2" type="text" name="name" id="form-name" placeholder="Nombre"
required>
<div class="row g-2 mb-2">
<div class="col-8">
<input class="form-control" type="number" name="price" id="form-price"
placeholder="Precio (CLP)" required>
</div>
<div class="col-4">
<select class="form-select" name="unit_type" id="form-unit-type">
<option value="unit">Unidad</option>
<option value="kg">Kg</option>
</select>
</div>
</div>
<input class="form-control mb-2" type="number" step="1" name="stock" id="form-stock"
placeholder="Stock Inicial">
<div class="input-group mb-3">
<input class="form-control" type="text" name="image_url" id="form-image"
placeholder="URL Imagen">
<input type="file" id="camera-input" accept="image/*" capture="environment"
style="display: none;" onchange="handleFileUpload(this)">
<button class="btn btn-outline-secondary" type="button"
onclick="document.getElementById('camera-input').click()">
<i class="bi bi-camera"></i>
</button>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-accent flex-grow-1">
<i class="bi bi-floppy me-1"></i>Guardar
</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()">
<i class="bi bi-eraser"></i>
</button>
</div>
</form>
</div>
</div>
<!-- ── RIGHT COLUMN ── -->
<div class="col-12 col-lg-7">
<div class="discord-card p-3">
<!-- Bulk actions bar -->
<div id="bulk-bar" class="bulk-bar p-2 mb-3 d-flex justify-content-between align-items-center">
<span><b id="selected-count">0</b> seleccionados</span>
<div class="d-flex align-items-center gap-2">
<input type="number" id="bulk-price-input" class="form-control form-control-sm"
placeholder="Precio">
<button onclick="applyBulkPrice()" class="btn btn-sm"
style="background:#fff; color:var(--accent); font-weight:600;">OK</button>
<button onclick="applyBulkDelete()" class="btn btn-sm btn-danger-discord">
<i class="bi bi-trash"></i>
</button>
<button onclick="clearSelection()" class="btn btn-sm"
style="background: rgba(255,255,255,0.2); color:#fff;">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Search -->
<div class="position-relative mb-3">
<input type="text" id="searchInput" class="form-control pe-5" onkeyup="searchTable()"
placeholder="Filtrar productos...">
<button class="btn btn-link position-absolute end-0 top-50 translate-middle-y text-muted"
onclick="clearSearch()" id="clearSearchBtn" style="display: none; text-decoration: none;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Table -->
<div class="table-responsive">
<table class="table table-borderless mb-0" id="inventoryTable">
<thead>
<tr>
<th style="width:36px;"><input class="form-check-input" type="checkbox"
id="select-all" onclick="toggleAll(this)"></th>
<th class="col-barcode" onclick="sortTable(1)">Código</th>
<th onclick="sortTable(2)">Nombre</th>
<th onclick="sortTable(3)">Stock</th>
<th onclick="sortTable(4)">Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr data-barcode="{{ p[0] }}">
<td><input class="form-check-input product-checkbox" type="checkbox"
onclick="updateBulkBar()"></td>
<td class="col-barcode">{{ p[0] }}</td>
<td class="name-cell">{{ p[1] }}</td>
<td>
{% if p[5] == 'kg' %}
<span class="text-muted d-inline-block text-center"
style="width: 45px;">-</span>
{% else %}
{{ p[4] | int }} <small class="text-muted">Uni</small>
{% endif %}
</td>
<td class="price-cell" data-value="{{ p[2] }}"></td>
<td>
<button class="btn btn-accent btn-sm"
onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}', '{{ p[4] }}', '{{ p[5] }}')"
data-bs-toggle="modal" data-bs-target="#editModal">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-danger-discord btn-sm btn-del-sm" data-bs-toggle="modal"
data-bs-target="#deleteModal" data-barcode="{{ p[0] }}"
data-name="{{ p[1] }}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /row -->
</div><!-- /container -->
<!-- Edit Modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Editar Producto</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>¿Quieres editar <strong id="editProductName"></strong>?</p>
<div class="d-grid gap-2">
<button class="btn btn-accent" onclick="confirmEdit()">
<i class="bi bi-pencil me-1"></i>Editar
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Eliminar Producto</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>¿Seguro que quieres eliminar <strong id="deleteProductName"></strong>?</p>
<p class="text-muted small">Esta acción no se puede deshacer.</p>
<div class="d-grid gap-2 mt-3">
<button class="btn btn-danger-discord" onclick="confirmDelete()">
<i class="bi bi-trash me-1"></i>Eliminar
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bulkConfirmModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmar Cambio Masivo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a actualizar <strong id="bulk-count-text">0</strong> productos al precio de
<strong id="bulk-price-text"></strong>.
</p>
<div class="d-grid gap-2 mt-3">
<button class="btn btn-accent" onclick="executeBulkPrice()">
Confirmar Cambios
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bulkDeleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmar Eliminación Masiva</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a eliminar <strong id="bulk-delete-count">0</strong> productos permanentemente.
</p>
<p class="text-muted small">Esta acción borrará los datos y las imágenes de la caché.</p>
<div class="d-grid gap-2 mt-3">
<button class="btn btn-danger-discord" onclick="executeBulkDelete()">
Eliminar permanentemente
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="scannerModal" 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">Escanear Código</h5>
<button type="button" class="btn-close" onclick="stopScanner()" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3 d-flex align-items-end gap-2">
<div class="flex-grow-1">
<label class="form-label small text-muted">Seleccionar Cámara:</label>
<select id="camera-select" class="form-select form-select-sm"
onchange="switchCamera(this.value)">
<option value="">Cargando cámaras...</option>
</select>
</div>
<button id="torch-btn" class="btn btn-outline-secondary btn-sm" onclick="toggleTorch()"
style="height: 31px; min-width: 40px;">
</div>
<div id="reader" style="width: 100%; border-radius: 8px; overflow: hidden;"></div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
/* ── Socket.IO ── */
const socket = io();
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
function formatAll() {
document.querySelectorAll('.price-cell').forEach(td => {
td.innerText = clp.format(td.getAttribute('data-value'));
});
}
formatAll();
// Inside socket.on('new_scan')
socket.on('new_scan', d => {
// Update the "Last Scanned" card
document.getElementById('display-name').innerText = d.name;
document.getElementById('display-price').innerText = clp.format(d.price);
document.getElementById('display-barcode').innerText = d.barcode;
document.getElementById('display-img').src = d.image || './static/placeholder.png';
let title = 'Editando: ' + d.name;
if (d.note) title += ` (${d.note})`;
// Update the actual form
updateForm(d.barcode, d.name, d.price, d.image, title, d.stock, d.unit_type);
});
socket.on('scan_error', d => {
const prompt = document.getElementById('new-product-prompt');
document.getElementById('new-barcode-display').innerText = d.barcode;
prompt.classList.remove('d-none');
// Update the "Last Scanned" card so it doesn't show old data
document.getElementById('display-name').innerText = d.name || "Producto Nuevo";
document.getElementById('display-price').innerText = clp.format(0);
document.getElementById('display-barcode').innerText = d.barcode;
document.getElementById('display-img').src = d.image || './static/placeholder.png';
// Clear the price and set the name in the form
updateForm(d.barcode, d.name || '', '', d.image || '', 'Crear: ' + d.barcode);
});
socket.on('scale_update', function (data) {
console.log("Current Weight:", data.grams + "g");
// If the unit type is 'kg', update the stock field automatically
const unitType = document.getElementById('form-unit-type').value;
if (unitType === 'kg') {
document.getElementById('form-stock').value = data.kilograms;
}
});
// Replace your existing updateForm function with this one
function updateForm(b, n, p, i, t, stock, unit) {
dismissPrompt();
document.getElementById('form-barcode').value = b;
document.getElementById('form-name').value = n;
// Force integers here to nuke the decimals once and for all
document.getElementById('form-price').value = p ? parseInt(p, 10) : '';
document.getElementById('form-stock').value = stock ? parseInt(stock, 10) : 0;
document.getElementById('form-unit-type').value = unit || 'unit';
document.getElementById('form-image').value = i || '';
document.getElementById('form-title').innerText = t;
// Add a timestamp to the URL if it's a local cache image
let displayImg = i || './static/placeholder.png';
if (displayImg.includes('/static/cache/')) {
displayImg += (displayImg.includes('?') ? '&' : '?') + 't=' + Date.now();
}
document.getElementById('form-image').value = i || '';
document.getElementById('form-title').innerText = t;
document.getElementById('display-img').src = displayImg;
document.getElementById('display-name').innerText = n || 'Producto Nuevo';
document.getElementById('display-price').innerText = clp.format(p || 0);
document.getElementById('display-barcode').innerText = b;
toggleStockInput(); // Show/hide stock input based on unit type
}
function dismissPrompt() {
document.getElementById('new-product-prompt').classList.add('d-none');
}
function editProduct(b, n, p, i, stock, unit) {
document.getElementById('editProductName').innerText = n;
const modal = document.getElementById('editModal');
modal.dataset.barcode = b;
modal.dataset.name = n;
modal.dataset.price = p;
modal.dataset.image = i;
modal.dataset.stock = stock;
modal.dataset.unit = unit;
}
function confirmEdit() {
const m = document.getElementById('editModal');
updateForm(m.dataset.barcode, m.dataset.name, m.dataset.price, m.dataset.image, 'Editando: ' + m.dataset.name, m.dataset.stock, m.dataset.unit);
window.scrollTo({ top: 0, behavior: 'smooth' });
bootstrap.Modal.getInstance(m).hide();
}
function confirmDelete() {
const modal = document.getElementById('deleteModal');
const form = document.createElement('form');
form.action = `/delete/${modal.dataset.barcode}`;
form.method = 'POST';
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
}
// Delete modal setup
document.getElementById('deleteModal').addEventListener('show.bs.modal', e => {
const button = e.relatedTarget;
document.getElementById('deleteProductName').innerText = button.dataset.name;
document.getElementById('deleteModal').dataset.barcode = button.dataset.barcode;
});
/* ── Bulk selection ── */
function toggleAll(src) {
const visibleRows = document.querySelectorAll('#inventoryTable tbody tr:not([style*="display: none"])');
visibleRows.forEach(row => {
const cb = row.querySelector('.product-checkbox');
if (cb) cb.checked = src.checked;
});
updateBulkBar();
}
function updateBulkBar() {
document.getElementById('selected-count').innerText =
document.querySelectorAll('.product-checkbox:checked').length;
}
function clearForm() {
document.getElementById('product-form').reset();
document.getElementById('form-title').innerText = 'Editar / Crear';
// Reset preview card
document.getElementById('display-img').src = './static/placeholder.png';
document.getElementById('display-name').innerText = 'Esperando scan...';
document.getElementById('display-price').innerText = '$0';
document.getElementById('display-barcode').innerText = '';
toggleStockInput(); // Show/hide stock input based on default unit type
}
async function handleFileUpload(input) {
const barcode = document.getElementById('form-barcode').value;
if (!barcode) {
alert("Primero escanea o ingresa un código de barras.");
input.value = '';
return;
}
const file = input.files[0];
if (!file) return;
// Show a "loading" state if you feel like being fancy
const originalBtnContent = document.querySelector('button[onclick*="camera-input"]').innerHTML;
document.querySelector('button[onclick*="camera-input"]').innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const compressedBlob = await compressImage(file, 800, 0.7); // Max 800px, 70% quality
const formData = new FormData();
formData.append('image', compressedBlob, `photo_${barcode}.jpg`);
formData.append('barcode', barcode);
const res = await fetch('/upload_image', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
document.getElementById('form-image').value = data.image_url;
document.getElementById('display-img').src = data.image_url;
} else {
alert("Error al subir imagen: " + data.error);
}
} catch (err) {
console.error(err);
alert("Error procesando imagen.");
} finally {
document.querySelector('button[onclick*="camera-input"]').innerHTML = originalBtnContent;
}
}
// The compression engine
function compressImage(file, maxWidth, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = event => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(blob => {
resolve(blob);
}, 'image/jpeg', quality);
};
img.onerror = err => reject(err);
};
reader.onerror = err => reject(err);
});
}
function applyBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
if (!price || checked.length === 0) return;
// Set text in the pretty modal
document.getElementById('bulk-count-text').innerText = checked.length;
document.getElementById('bulk-price-text').innerText = clp.format(price);
// Show the modal
const bulkModal = new bootstrap.Modal(document.getElementById('bulkConfirmModal'));
bulkModal.show();
}
async function executeBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_price_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes, new_price: price })
});
if (res.ok) {
checked.forEach(cb => {
const cell = cb.closest('tr').querySelector('.price-cell');
cell.setAttribute('data-value', price);
cell.innerText = clp.format(price);
cb.checked = false;
});
document.getElementById('bulk-price-input').value = '';
updateBulkBar();
// Hide modal
const modalEl = document.getElementById('bulkConfirmModal');
bootstrap.Modal.getInstance(modalEl).hide();
}
}
function applyBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
if (checked.length === 0) return;
document.getElementById('bulk-delete-count').innerText = checked.length;
const delModal = new bootstrap.Modal(document.getElementById('bulkDeleteModal'));
delModal.show();
}
async function executeBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes })
});
if (res.ok) {
checked.forEach(cb => {
cb.closest('tr').remove(); // Remove the row from the table immediately
});
updateBulkBar();
const modalEl = document.getElementById('bulkDeleteModal');
bootstrap.Modal.getInstance(modalEl).hide();
} else {
alert("Error al eliminar productos.");
}
}
/* ── Search ── */
function searchTable() {
const input = document.getElementById('searchInput');
const q = input.value.toUpperCase();
const clearBtn = document.getElementById('clearSearchBtn');
// Show/hide clear button based on input
clearBtn.style.display = q.length > 0 ? 'block' : 'none';
document.querySelectorAll('#inventoryTable tbody tr').forEach(tr => {
tr.style.display = tr.innerText.toUpperCase().includes(q) ? '' : 'none';
});
document.getElementById('select-all').checked = false;
}
function clearSearch() {
const input = document.getElementById('searchInput');
input.value = '';
searchTable(); // Re-run search to show all rows
input.focus();
}
let sortDirections = [true, true, true, true]; // Tracks asc/desc for each column
function sortTable(colIdx) {
const table = document.getElementById("inventoryTable");
const tbody = table.querySelector("tbody");
const rows = Array.from(tbody.querySelectorAll("tr"));
const isAscending = sortDirections[colIdx];
const sortedRows = rows.sort((a, b) => {
let valA = a.cells[colIdx].innerText.trim();
let valB = b.cells[colIdx].innerText.trim();
// If sorting price, use the data-value attribute for pure numbers
if (colIdx === 3) {
valA = parseFloat(a.cells[colIdx].getAttribute('data-value')) || 0;
valB = parseFloat(b.cells[colIdx].getAttribute('data-value')) || 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return isAscending ? -1 : 1;
if (valA > valB) return isAscending ? 1 : -1;
return 0;
});
// Toggle direction for next click
sortDirections[colIdx] = !isAscending;
// Append sorted rows back to tbody
sortedRows.forEach(row => tbody.appendChild(row));
// Optional: Reset "select all" state since order changed
document.getElementById('select-all').checked = false;
}
let html5QrCode;
let currentCameraId;
async function startScanner() {
const modal = new bootstrap.Modal(document.getElementById('scannerModal'));
modal.show();
if (!html5QrCode) {
html5QrCode = new Html5Qrcode("reader");
}
try {
const devices = await Html5Qrcode.getCameras();
const select = document.getElementById('camera-select');
select.innerHTML = '';
if (devices && devices.length) {
devices.forEach((device, index) => {
const option = document.createElement('option');
option.value = device.id;
// Store the index in a data attribute for easier retrieval later
option.dataset.index = index;
option.text = device.label || `Cámara ${index + 1}`;
select.appendChild(option);
});
// Retrieve saved index or default to the last camera
const savedIndex = getCookie('cameraIndex');
const targetIndex = (savedIndex !== null && savedIndex < devices.length)
? savedIndex
: devices.length - 1;
currentCameraId = devices[targetIndex].id;
select.value = currentCameraId;
launchCamera(currentCameraId);
} else {
alert("No se encontraron cámaras.");
}
} catch (err) {
console.error("Error obteniendo cámaras:", err);
alert("Error de permisos de cámara. Revisa la conexión HTTPS.");
}
}
let torchEnabled = false;
function isTorchSupported() {
if (!html5QrCode || !html5QrCode.isScanning) return false;
const settings = html5QrCode.getRunningTrackSettings();
return "torch" in settings;
}
async function toggleTorch() {
if (!isTorchSupported()) return;
torchEnabled = !torchEnabled;
try {
await html5QrCode.applyVideoConstraints({
advanced: [{ torch: torchEnabled }]
});
const btn = document.getElementById('torch-btn');
if (torchEnabled) {
btn.classList.replace('btn-outline-secondary', 'btn-accent');
btn.innerHTML = '<i class="bi bi-lightbulb-fill"></i>';
} else {
btn.classList.replace('btn-accent', 'btn-outline-secondary');
btn.innerHTML = '<i class="bi bi-lightbulb"></i>';
}
} catch (err) {
console.error("Torch error:", err);
}
}
async function launchCamera(cameraId) {
const config = { fps: 10, qrbox: { width: 250, height: 150 } };
if (html5QrCode.isScanning) {
await html5QrCode.stop();
}
try {
await html5QrCode.start(
cameraId,
config,
(decodedText) => {
stopScanner();
bootstrap.Modal.getInstance(document.getElementById('scannerModal')).hide();
fetch(`/scan?content=${decodedText}`)
.then(res => {
if (res.status === 404) console.log("Nuevo producto detectado");
});
}
);
setTimeout(() => {
html5QrCode.applyVideoConstraints({
focusMode: "continuous",
advanced: [{ zoom: 2.0 }],
});
const torchBtn = document.getElementById('torch-btn');
if (isTorchSupported()) {
torchBtn.style.setProperty('display', 'block', 'important'); // Use !important to override inline styles
torchEnabled = false;
torchBtn.classList.replace('btn-accent', 'btn-outline-secondary');
torchBtn.innerHTML = '<i class="bi bi-lightbulb"></i>';
} else {
torchBtn.style.display = 'none';
console.log("Torch not supported on this device/browser.");
}
}, 500); // 500ms delay to let the camera stream stabilize
} catch (err) {
console.error("Camera start error:", err);
}
}
function stopScanner() {
if (html5QrCode && html5QrCode.isScanning) {
// Hide the button when the camera stops
document.getElementById('torch-btn').style.display = 'none';
html5QrCode.stop().then(() => {
html5QrCode.clear();
}).catch(err => console.error("Stop error", err));
}
}
function switchCamera(cameraId) {
if (cameraId) {
const select = document.getElementById('camera-select');
const selectedOption = select.options[select.selectedIndex];
// Save the index of the selected camera to a cookie
if (selectedOption && selectedOption.dataset.index !== undefined) {
setCookie('cameraIndex', selectedOption.dataset.index, 365);
}
currentCameraId = cameraId;
launchCamera(cameraId);
}
}
// Function to toggle stock state
function toggleStockInput() {
const unitSelect = document.getElementById('form-unit-type');
const stockInput = document.getElementById('form-stock');
if (unitSelect.value === 'kg') {
stockInput.classList.add('d-none'); // Poof.
stockInput.disabled = true; // Prevent form submission errors
stockInput.value = '';
} else {
stockInput.classList.remove('d-none'); // Bring it back for units
stockInput.disabled = false;
}
}
// Listen for manual dropdown changes
document.getElementById('form-unit-type').addEventListener('change', toggleStockInput);
</script>
<script src="./static/cookieStuff.js"></script>
<script src="./static/themeStuff.js"></script>
</body>
</html>

717
templates/inventory.html Normal file
View File

@@ -0,0 +1,717 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal, scanner_modal %}
{% block title %}Inventario{% endblock %}
{% block head %}
<script src="https://unpkg.com/html5-qrcode"></script>
{% endblock %}
{% block content %}
<div class="row g-3">
<!-- ── LEFT COLUMN ── -->
<div class="col-12 col-lg-5">
<!-- New product prompt -->
<div id="new-product-prompt"
class="new-product-prompt p-3 mb-3 d-none d-flex justify-content-between align-items-center">
<span>Nuevo: <b id="new-barcode-display"></b></span>
<button onclick="dismissPrompt()" class="btn btn-sm" style="background:rgba(0,0,0,0.25);color:#fff;">
Omitir
</button>
</div>
<!-- Last scanned card -->
<div class="discord-card p-3 mb-3 text-center">
<p class="mb-1 fw-semibold"
style="color:var(--text-muted); font-size:0.8rem; text-transform:uppercase; letter-spacing:.05em;">
Último Escaneado</p>
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
<h5 id="display-name" class="mb-1">Esperando scan...</h5>
<div class="price-tag" id="display-price">$0</div>
<p id="display-barcode" class="mb-0 mt-1" style="font-family:monospace; opacity:.5; font-size:.8rem;">
</p>
</div>
<button class="btn btn-accent mb-3 w-100" onclick="startScanner()">
<i class="bi bi-qr-code-scan me-2"></i>Escanear con Cámara
</button>
<!-- Edit / Create card -->
<div class="discord-card p-3">
<h6 id="form-title" class="mb-3 fw-bold">Editar / Crear</h6>
<form action="/upsert" method="POST" id="product-form">
<input class="form-control mb-2" type="text" name="barcode" id="form-barcode" placeholder="Barcode"
required>
<input class="form-control mb-2" type="text" name="name" id="form-name" placeholder="Nombre" required>
<div class="row g-2 mb-2">
<div class="col-8">
<input class="form-control" type="number" name="price" id="form-price"
placeholder="Precio (CLP)" required>
</div>
<div class="col-4">
<select class="form-select" name="unit_type" id="form-unit-type">
<option value="unit">Unidad</option>
<option value="kg">Kg</option>
</select>
</div>
</div>
<input class="form-control mb-2" type="number" step="1" name="stock" id="form-stock"
placeholder="Stock Inicial">
<div class="input-group mb-3">
<input class="form-control" type="text" name="image_url" id="form-image" placeholder="URL Imagen">
<input type="file" id="camera-input" accept="image/*" capture="environment" style="display: none;"
onchange="handleFileUpload(this)">
<button class="btn btn-outline-secondary" type="button"
onclick="document.getElementById('camera-input').click()">
<i class="bi bi-camera"></i>
</button>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-accent flex-grow-1">
<i class="bi bi-floppy me-1"></i>Guardar
</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()">
<i class="bi bi-eraser"></i>
</button>
</div>
</form>
</div>
</div>
<!-- ── RIGHT COLUMN ── -->
<div class="col-12 col-lg-7">
<div class="discord-card p-3">
<!-- Bulk actions bar -->
<div id="bulk-bar" class="bulk-bar p-2 mb-3 d-flex justify-content-between align-items-center">
<span><b id="selected-count">0</b> seleccionados</span>
<div class="d-flex align-items-center gap-2">
<input type="number" id="bulk-price-input" class="form-control form-control-sm"
placeholder="Precio">
<button onclick="applyBulkPrice()" class="btn btn-sm"
style="background:#fff; color:var(--accent); font-weight:600;">OK</button>
<button onclick="applyBulkDelete()" class="btn btn-sm btn-danger-discord">
<i class="bi bi-trash"></i>
</button>
<button onclick="clearSelection()" class="btn btn-sm"
style="background: rgba(255,255,255,0.2); color:#fff;">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Search -->
<div class="position-relative mb-3">
<input type="text" id="searchInput" class="form-control pe-5" onkeyup="searchTable()"
placeholder="Filtrar productos...">
<button class="btn btn-link position-absolute end-0 top-50 translate-middle-y text-muted"
onclick="clearSearch()" id="clearSearchBtn" style="display: none; text-decoration: none;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Table -->
<div class="table-responsive">
<table class="table table-borderless mb-0" id="inventoryTable">
<thead>
<tr>
<th style="width:36px;"><input class="form-check-input" type="checkbox" id="select-all"
onclick="toggleAll(this)"></th>
<th class="col-barcode" onclick="sortTable(1)">Código</th>
<th onclick="sortTable(2)">Nombre</th>
<th onclick="sortTable(3)">Stock</th>
<th onclick="sortTable(4)">Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr data-barcode="{{ p[0] }}">
<td><input class="form-check-input product-checkbox" type="checkbox"
onclick="updateBulkBar()"></td>
<td class="col-barcode">{{ p[0] }}</td>
<td class="name-cell">{{ p[1] }}</td>
<td>
{% if p[5] == 'kg' %}
<span class="text-muted d-inline-block text-center" style="width: 45px;">-</span>
{% else %}
{{ p[4] | int }} <small class="text-muted">Uni</small>
{% endif %}
</td>
<td class="price-cell" data-value="{{ p[2] }}"></td>
<td>
<button class="btn btn-accent btn-sm"
onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}', '{{ p[4] }}', '{{ p[5] }}')"
data-bs-toggle="modal" data-bs-target="#editModal">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-danger-discord btn-sm btn-del-sm" data-bs-toggle="modal"
data-bs-target="#deleteModal" data-barcode="{{ p[0] }}" data-name="{{ p[1] }}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% call confirm_modal('editModal', 'Editar Producto', 'btn-accent', 'Editar', 'confirmEdit()') %}
<p>¿Quieres editar <strong id="editProductName"></strong>?</p>
{% endcall %}
{% call confirm_modal('deleteModal', 'Eliminar Producto', 'btn-danger-discord', 'Eliminar', 'confirmDelete()') %}
<p>¿Seguro que quieres eliminar <strong id="deleteProductName"></strong>?</p>
<p class="text-muted small">Esta acción no se puede deshacer.</p>
{% endcall %}
{% call confirm_modal('bulkConfirmModal', 'Confirmar Cambio Masivo', 'btn-accent', 'Confirmar Cambios',
'executeBulkPrice()') %}
<div class="text-center">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a actualizar <strong id="bulk-count-text">0</strong> productos al precio de <strong
id="bulk-price-text"></strong>.</p>
</div>
{% endcall %}
{% call confirm_modal('bulkDeleteModal', 'Confirmar Eliminación Masiva', 'btn-danger-discord', 'Eliminar
permanentemente', 'executeBulkDelete()') %}
<div class="text-center">
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a eliminar <strong id="bulk-delete-count">0</strong> productos permanentemente.</p>
<p class="text-muted small">Esta acción borrará los datos y las imágenes de la caché.</p>
</div>
{% endcall %}
{{ scanner_modal() }}
{% endblock %}
{% block scripts %}
<script>
/* ── Socket.IO ── */
const socket = io();
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
function formatAll() {
document.querySelectorAll('.price-cell').forEach(td => {
td.innerText = clp.format(td.getAttribute('data-value'));
});
}
formatAll();
// Inside socket.on('new_scan')
socket.on('new_scan', d => {
// Update the "Last Scanned" card
document.getElementById('display-name').innerText = d.name;
document.getElementById('display-price').innerText = clp.format(d.price);
document.getElementById('display-barcode').innerText = d.barcode;
document.getElementById('display-img').src = d.image || './static/placeholder.png';
let title = 'Editando: ' + d.name;
if (d.note) title += ` (${d.note})`;
// Update the actual form
updateForm(d.barcode, d.name, d.price, d.image, title, d.stock, d.unit_type);
});
socket.on('scan_error', d => {
const prompt = document.getElementById('new-product-prompt');
document.getElementById('new-barcode-display').innerText = d.barcode;
prompt.classList.remove('d-none');
// Update the "Last Scanned" card so it doesn't show old data
document.getElementById('display-name').innerText = d.name || "Producto Nuevo";
document.getElementById('display-price').innerText = clp.format(0);
document.getElementById('display-barcode').innerText = d.barcode;
document.getElementById('display-img').src = d.image || './static/placeholder.png';
// Clear the price and set the name in the form
updateForm(d.barcode, d.name || '', '', d.image || '', 'Crear: ' + d.barcode);
});
socket.on('scale_update', function (data) {
console.log("Current Weight:", data.grams + "g");
// If the unit type is 'kg', update the stock field automatically
const unitType = document.getElementById('form-unit-type').value;
if (unitType === 'kg') {
document.getElementById('form-stock').value = data.kilograms;
}
});
// Replace your existing updateForm function with this one
function updateForm(b, n, p, i, t, stock, unit) {
dismissPrompt();
document.getElementById('form-barcode').value = b;
document.getElementById('form-name').value = n;
// Force integers here to nuke the decimals once and for all
document.getElementById('form-price').value = p ? parseInt(p, 10) : '';
document.getElementById('form-stock').value = stock ? parseInt(stock, 10) : 0;
document.getElementById('form-unit-type').value = unit || 'unit';
document.getElementById('form-image').value = i || '';
document.getElementById('form-title').innerText = t;
// Add a timestamp to the URL if it's a local cache image
let displayImg = i || './static/placeholder.png';
if (displayImg.includes('/static/cache/')) {
displayImg += (displayImg.includes('?') ? '&' : '?') + 't=' + Date.now();
}
document.getElementById('form-image').value = i || '';
document.getElementById('form-title').innerText = t;
document.getElementById('display-img').src = displayImg;
document.getElementById('display-name').innerText = n || 'Producto Nuevo';
document.getElementById('display-price').innerText = clp.format(p || 0);
document.getElementById('display-barcode').innerText = b;
toggleStockInput(); // Show/hide stock input based on unit type
}
function dismissPrompt() {
document.getElementById('new-product-prompt').classList.add('d-none');
}
function editProduct(b, n, p, i, stock, unit) {
document.getElementById('editProductName').innerText = n;
const modal = document.getElementById('editModal');
modal.dataset.barcode = b;
modal.dataset.name = n;
modal.dataset.price = p;
modal.dataset.image = i;
modal.dataset.stock = stock;
modal.dataset.unit = unit;
}
function confirmEdit() {
const m = document.getElementById('editModal');
updateForm(m.dataset.barcode, m.dataset.name, m.dataset.price, m.dataset.image, 'Editando: ' + m.dataset.name, m.dataset.stock, m.dataset.unit);
window.scrollTo({ top: 0, behavior: 'smooth' });
bootstrap.Modal.getInstance(m).hide();
}
function confirmDelete() {
const modal = document.getElementById('deleteModal');
const form = document.createElement('form');
form.action = `/delete/${modal.dataset.barcode}`;
form.method = 'POST';
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
}
// Delete modal setup
document.getElementById('deleteModal').addEventListener('show.bs.modal', e => {
const button = e.relatedTarget;
document.getElementById('deleteProductName').innerText = button.dataset.name;
document.getElementById('deleteModal').dataset.barcode = button.dataset.barcode;
});
/* ── Bulk selection ── */
function toggleAll(src) {
const visibleRows = document.querySelectorAll('#inventoryTable tbody tr:not([style*="display: none"])');
visibleRows.forEach(row => {
const cb = row.querySelector('.product-checkbox');
if (cb) cb.checked = src.checked;
});
updateBulkBar();
}
function updateBulkBar() {
document.getElementById('selected-count').innerText =
document.querySelectorAll('.product-checkbox:checked').length;
}
function clearForm() {
document.getElementById('product-form').reset();
document.getElementById('form-title').innerText = 'Editar / Crear';
// Reset preview card
document.getElementById('display-img').src = './static/placeholder.png';
document.getElementById('display-name').innerText = 'Esperando scan...';
document.getElementById('display-price').innerText = '$0';
document.getElementById('display-barcode').innerText = '';
toggleStockInput(); // Show/hide stock input based on default unit type
}
async function handleFileUpload(input) {
const barcode = document.getElementById('form-barcode').value;
if (!barcode) {
alert("Primero escanea o ingresa un código de barras.");
input.value = '';
return;
}
const file = input.files[0];
if (!file) return;
// Show a "loading" state if you feel like being fancy
const originalBtnContent = document.querySelector('button[onclick*="camera-input"]').innerHTML;
document.querySelector('button[onclick*="camera-input"]').innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const compressedBlob = await compressImage(file, 800, 0.7); // Max 800px, 70% quality
const formData = new FormData();
formData.append('image', compressedBlob, `photo_${barcode}.jpg`);
formData.append('barcode', barcode);
const res = await fetch('/upload_image', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
document.getElementById('form-image').value = data.image_url;
document.getElementById('display-img').src = data.image_url;
} else {
alert("Error al subir imagen: " + data.error);
}
} catch (err) {
console.error(err);
alert("Error procesando imagen.");
} finally {
document.querySelector('button[onclick*="camera-input"]').innerHTML = originalBtnContent;
}
}
// The compression engine
function compressImage(file, maxWidth, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = event => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(blob => {
resolve(blob);
}, 'image/jpeg', quality);
};
img.onerror = err => reject(err);
};
reader.onerror = err => reject(err);
});
}
function applyBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
if (!price || checked.length === 0) return;
// Set text in the pretty modal
document.getElementById('bulk-count-text').innerText = checked.length;
document.getElementById('bulk-price-text').innerText = clp.format(price);
// Show the modal
const bulkModal = new bootstrap.Modal(document.getElementById('bulkConfirmModal'));
bulkModal.show();
}
async function executeBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_price_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes, new_price: price })
});
if (res.ok) {
checked.forEach(cb => {
const cell = cb.closest('tr').querySelector('.price-cell');
cell.setAttribute('data-value', price);
cell.innerText = clp.format(price);
cb.checked = false;
});
document.getElementById('bulk-price-input').value = '';
updateBulkBar();
// Hide modal
const modalEl = document.getElementById('bulkConfirmModal');
bootstrap.Modal.getInstance(modalEl).hide();
}
}
function applyBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
if (checked.length === 0) return;
document.getElementById('bulk-delete-count').innerText = checked.length;
const delModal = new bootstrap.Modal(document.getElementById('bulkDeleteModal'));
delModal.show();
}
async function executeBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes })
});
if (res.ok) {
checked.forEach(cb => {
cb.closest('tr').remove(); // Remove the row from the table immediately
});
updateBulkBar();
const modalEl = document.getElementById('bulkDeleteModal');
bootstrap.Modal.getInstance(modalEl).hide();
} else {
alert("Error al eliminar productos.");
}
}
/* ── Search ── */
function searchTable() {
const input = document.getElementById('searchInput');
const q = input.value.toUpperCase();
const clearBtn = document.getElementById('clearSearchBtn');
// Show/hide clear button based on input
clearBtn.style.display = q.length > 0 ? 'block' : 'none';
document.querySelectorAll('#inventoryTable tbody tr').forEach(tr => {
tr.style.display = tr.innerText.toUpperCase().includes(q) ? '' : 'none';
});
document.getElementById('select-all').checked = false;
}
function clearSearch() {
const input = document.getElementById('searchInput');
input.value = '';
searchTable(); // Re-run search to show all rows
input.focus();
}
let sortDirections = [true, true, true, true]; // Tracks asc/desc for each column
function sortTable(colIdx) {
const table = document.getElementById("inventoryTable");
const tbody = table.querySelector("tbody");
const rows = Array.from(tbody.querySelectorAll("tr"));
const isAscending = sortDirections[colIdx];
const sortedRows = rows.sort((a, b) => {
let valA = a.cells[colIdx].innerText.trim();
let valB = b.cells[colIdx].innerText.trim();
// If sorting price, use the data-value attribute for pure numbers
if (colIdx === 3) {
valA = parseFloat(a.cells[colIdx].getAttribute('data-value')) || 0;
valB = parseFloat(b.cells[colIdx].getAttribute('data-value')) || 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return isAscending ? -1 : 1;
if (valA > valB) return isAscending ? 1 : -1;
return 0;
});
// Toggle direction for next click
sortDirections[colIdx] = !isAscending;
// Append sorted rows back to tbody
sortedRows.forEach(row => tbody.appendChild(row));
// Optional: Reset "select all" state since order changed
document.getElementById('select-all').checked = false;
}
let html5QrCode;
let currentCameraId;
async function startScanner() {
const modal = new bootstrap.Modal(document.getElementById('scannerModal'));
modal.show();
if (!html5QrCode) {
html5QrCode = new Html5Qrcode("reader");
}
try {
const devices = await Html5Qrcode.getCameras();
const select = document.getElementById('camera-select');
select.innerHTML = '';
if (devices && devices.length) {
devices.forEach((device, index) => {
const option = document.createElement('option');
option.value = device.id;
// Store the index in a data attribute for easier retrieval later
option.dataset.index = index;
option.text = device.label || `Cámara ${index + 1}`;
select.appendChild(option);
});
// Retrieve saved index or default to the last camera
const savedIndex = getCookie('cameraIndex');
const targetIndex = (savedIndex !== null && savedIndex < devices.length)
? savedIndex
: devices.length - 1;
currentCameraId = devices[targetIndex].id;
select.value = currentCameraId;
launchCamera(currentCameraId);
} else {
alert("No se encontraron cámaras.");
}
} catch (err) {
console.error("Error obteniendo cámaras:", err);
alert("Error de permisos de cámara. Revisa la conexión HTTPS.");
}
}
let torchEnabled = false;
function isTorchSupported() {
if (!html5QrCode || !html5QrCode.isScanning) return false;
const settings = html5QrCode.getRunningTrackSettings();
return "torch" in settings;
}
async function toggleTorch() {
if (!isTorchSupported()) return;
torchEnabled = !torchEnabled;
try {
await html5QrCode.applyVideoConstraints({
advanced: [{ torch: torchEnabled }]
});
const btn = document.getElementById('torch-btn');
if (torchEnabled) {
btn.classList.replace('btn-outline-secondary', 'btn-accent');
btn.innerHTML = '<i class="bi bi-lightbulb-fill"></i>';
} else {
btn.classList.replace('btn-accent', 'btn-outline-secondary');
btn.innerHTML = '<i class="bi bi-lightbulb"></i>';
}
} catch (err) {
console.error("Torch error:", err);
}
}
async function launchCamera(cameraId) {
const config = { fps: 10, qrbox: { width: 250, height: 150 } };
if (html5QrCode.isScanning) {
await html5QrCode.stop();
}
try {
await html5QrCode.start(
cameraId,
config,
(decodedText) => {
stopScanner();
bootstrap.Modal.getInstance(document.getElementById('scannerModal')).hide();
fetch(`/scan?content=${decodedText}`)
.then(res => {
if (res.status === 404) console.log("Nuevo producto detectado");
});
}
);
setTimeout(() => {
html5QrCode.applyVideoConstraints({
focusMode: "continuous",
advanced: [{ zoom: 2.0 }],
});
const torchBtn = document.getElementById('torch-btn');
if (isTorchSupported()) {
torchBtn.style.setProperty('display', 'block', 'important'); // Use !important to override inline styles
torchEnabled = false;
torchBtn.classList.replace('btn-accent', 'btn-outline-secondary');
torchBtn.innerHTML = '<i class="bi bi-lightbulb"></i>';
} else {
torchBtn.style.display = 'none';
console.log("Torch not supported on this device/browser.");
}
}, 500); // 500ms delay to let the camera stream stabilize
} catch (err) {
console.error("Camera start error:", err);
}
}
function stopScanner() {
if (html5QrCode && html5QrCode.isScanning) {
// Hide the button when the camera stops
document.getElementById('torch-btn').style.display = 'none';
html5QrCode.stop().then(() => {
html5QrCode.clear();
}).catch(err => console.error("Stop error", err));
}
}
function switchCamera(cameraId) {
if (cameraId) {
const select = document.getElementById('camera-select');
const selectedOption = select.options[select.selectedIndex];
// Save the index of the selected camera to a cookie
if (selectedOption && selectedOption.dataset.index !== undefined) {
setCookie('cameraIndex', selectedOption.dataset.index, 365);
}
currentCameraId = cameraId;
launchCamera(cameraId);
}
}
// Function to toggle stock state
function toggleStockInput() {
const unitSelect = document.getElementById('form-unit-type');
const stockInput = document.getElementById('form-stock');
if (unitSelect.value === 'kg') {
stockInput.classList.add('d-none'); // Poof.
stockInput.disabled = true; // Prevent form submission errors
stockInput.value = '';
} else {
stockInput.classList.remove('d-none'); // Bring it back for units
stockInput.disabled = false;
}
}
// Listen for manual dropdown changes
document.getElementById('form-unit-type').addEventListener('change', toggleStockInput);
</script>
{% endblock %}

View File

@@ -7,80 +7,7 @@
<title>SekiPOS Login</title> <title>SekiPOS Login</title>
<link rel="shortcut icon" href="./static/favicon.png" type="image/x-icon"> <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@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style> <link rel="stylesheet" href="./static/style.css">
:root {
--bg: #5865f2;
--card-bg: #ffffff;
--text: #2e3338;
--input-bg: #e3e5e8;
--accent: #5865f2;
--accent-hover: #4752c4;
--error: #ed4245;
}
[data-theme="dark"] {
--bg: #36393f;
--card-bg: #2f3136;
--text: #ffffff;
--input-bg: #202225;
}
body {
background: var(--bg);
font-family: "gg sans", "Segoe UI", sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
margin: 0;
padding: 16px;
}
.login-box {
background: var(--card-bg);
color: var(--text);
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
transition: background 0.2s, color 0.2s;
}
.form-control {
background: var(--input-bg) !important;
color: var(--text) !important;
border: none;
}
.form-control:focus {
box-shadow: 0 0 0 2px var(--accent);
}
.form-control::placeholder {
color: #8a8e94;
}
.btn-login {
background: var(--accent);
color: #fff;
border: none;
font-weight: 600;
}
.btn-login:hover {
background: var(--accent-hover);
color: #fff;
}
.error-alert {
background: var(--error);
color: #fff;
border-radius: 4px;
font-size: .88rem;
}
</style>
</head> </head>
<body> <body>

View File

@@ -0,0 +1,31 @@
<!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 - {% block title %}{% endblock %}</title>
<link rel="shortcut icon" href="{{ url_for('static', filename='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">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
{% include 'macros/navbar.html' %}
<main class="container-fluid px-3">
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='cookieStuff.js') }}"></script>
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,46 @@
{% macro confirm_modal(id, title, button_class, button_text, onclick_fn) %}
<div class="modal fade" id="{{ id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{{ caller() }}
<div class="d-grid gap-2 mt-3">
<button class="btn {{ button_class }}" onclick="{{ onclick_fn }}">
{{ button_text }}
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
{% endmacro %}
{% macro scanner_modal() %}
<div class="modal fade" id="scannerModal" 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">Escanear Código</h5>
<button type="button" class="btn-close" onclick="stopScanner()" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3 d-flex align-items-end gap-2">
<div class="flex-grow-1">
<label class="form-label small text-muted">Seleccionar Cámara:</label>
<select id="camera-select" class="form-select form-select-sm" onchange="switchCamera(this.value)">
<option value="">Cargando cámaras...</option>
</select>
</div>
<button id="torch-btn" class="btn btn-outline-secondary btn-sm" onclick="toggleTorch()" style="height: 31px; min-width: 40px; display: none;"></button>
</div>
<div id="reader" style="width: 100%; border-radius: 8px; overflow: hidden;"></div>
</div>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -1,19 +1,11 @@
<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"> <span class="navbar-brand">
SekiPOS SekiPOS
{% if active_page == 'checkout' %}
<small class="text-muted fw-normal" style="font-size:0.65rem;">v1.6</small> <small class="text-muted fw-normal" style="font-size:0.65rem;">v1.6</small>
{% elif active_page == 'sales' %}
<small class="text-muted fw-normal" style="font-size:0.65rem;">v1.6</small>
{% elif active_page == 'dicom' %}
<small class="text-muted fw-normal" style="font-size:0.65rem;">v1.6</small>
{% else %}
<small class="text-muted fw-normal" style="font-size:0.65rem;">v1.6</small>
{% endif %}
</span> </span>
<div class="ms-3 gap-2 d-flex"> <div class="ms-3 gap-2 d-flex">
<a href="/" class="btn btn-sm {{ 'btn-primary' if active_page == 'inventory' else 'btn-outline-primary' }}"> <a href="/inventory" class="btn btn-sm {{ 'btn-primary' if active_page == 'inventory' else 'btn-outline-primary' }}">
<i class="bi bi-box-seam me-1"></i>Inventario <i class="bi bi-box-seam me-1"></i>Inventario
</a> </a>
<a href="/checkout" <a href="/checkout"
@@ -30,8 +22,7 @@
<div class="ms-auto"> <div class="ms-auto">
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-accent dropdown-toggle" type="button" data-bs-toggle="dropdown" <button class="btn btn-accent dropdown-toggle" type="button" data-bs-toggle="dropdown">
aria-expanded="false">
<i class="bi bi-person-circle me-1"></i> <i class="bi bi-person-circle me-1"></i>
<span class="d-none d-sm-inline">{{ user.username }}</span> <span class="d-none d-sm-inline">{{ user.username }}</span>
</button> </button>

View File

@@ -1,143 +1,13 @@
<!DOCTYPE html> {% extends "macros/base.html" %}
<html lang="es" data-theme="light"> {% from 'macros/modals.html' import confirm_modal, scanner_modal %}
<head> {% block title %}Ventas{% endblock %}
<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"] { {% block head %}
--bg: #36393f; <!--HEAD-->
--card-bg: #2f3136; {% endblock %}
--text-main: #dcddde;
--text-muted: #b9bbbe;
--border: #202225;
--navbar-bg: #202225;
--input-bg: #202225;
}
body { {% block content %}
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>
{% with active_page='sales' %}{% include 'navbar.html' %}{% endwith %}
<div class="container-fluid px-3">
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
@@ -145,24 +15,24 @@
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;"> <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 %} {% if selected_date %}Día Seleccionado{% else %}Ventas de Hoy{% endif %}
</h6> </h6>
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" <h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.daily }}">
data-value="{{ stats.daily }}"></h2> </h2>
</div> </div>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center"> <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 <h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Últimos 7
Días</h6> Días</h6>
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" <h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.week }}">
data-value="{{ stats.week }}"></h2> </h2>
</div> </div>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center"> <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 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Este Mes
</h6> </h6>
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" <h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.month }}">
data-value="{{ stats.month }}"></h2> </h2>
</div> </div>
</div> </div>
</div> </div>
@@ -274,8 +144,8 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> {% block scripts %}
<script> <script>
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 }); const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
let saleToReverse = null; let saleToReverse = null;
@@ -369,8 +239,4 @@
} }
} }
</script> </script>
<script src="./static/cookieStuff.js"></script> {% endblock %}
<script src="./static/themeStuff.js"></script>
</body>
</html>