WIP rewrite with macros
This commit is contained in:
81
app.py
81
app.py
@@ -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
|
||||||
|
|||||||
@@ -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%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,297 +1,172 @@
|
|||||||
<!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 {
|
<div class="row g-3">
|
||||||
background: var(--navbar-bg) !important;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
<div class="col-md-4">
|
||||||
color: var(--text-main) !important;
|
<div class="discord-card p-3 mb-3">
|
||||||
font-weight: 700;
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
}
|
<h5 class="mb-0 fw-bold">Registrar Movimiento</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="clearDicomForm()" title="Limpiar Formulario">
|
||||||
.discord-card {
|
<i class="bi bi-eraser"></i> Nuevo
|
||||||
background: var(--card-bg);
|
</button>
|
||||||
border: 1px solid var(--border);
|
</div>
|
||||||
border-radius: 8px;
|
<div class="mb-2">
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
<label class="small text-muted mb-1">Nombre del Cliente</label>
|
||||||
}
|
<input type="text" id="dicom-name" class="form-control" placeholder="Ej: Doña Juanita">
|
||||||
|
</div>
|
||||||
.form-control,
|
<div class="mb-2">
|
||||||
.form-control:focus {
|
<label class="small text-muted mb-1">Monto (CLP)</label>
|
||||||
background-color: var(--input-bg) !important;
|
<input type="number" id="dicom-amount" class="form-control" placeholder="Ej: 5000">
|
||||||
color: var(--text-main) !important;
|
</div>
|
||||||
border: 1px solid var(--border) !important;
|
<div class="mb-4">
|
||||||
box-shadow: none !important;
|
<label class="small text-muted mb-1">Nota (Opcional)</label>
|
||||||
}
|
<input type="text" id="dicom-notes" class="form-control" placeholder="Ej: Pan y bebida"
|
||||||
|
onkeydown="if(event.key === 'Enter') submitDicom('add')">
|
||||||
.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="col-md-4">
|
|
||||||
<div class="discord-card p-3 mb-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h5 class="mb-0 fw-bold">Registrar Movimiento</h5>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="clearDicomForm()"
|
|
||||||
title="Limpiar Formulario">
|
|
||||||
<i class="bi bi-eraser"></i> Nuevo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="small text-muted mb-1">Nombre del Cliente</label>
|
|
||||||
<input type="text" id="dicom-name" class="form-control" placeholder="Ej: Doña Juanita">
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="small text-muted mb-1">Monto (CLP)</label>
|
|
||||||
<input type="number" id="dicom-amount" class="form-control" placeholder="Ej: 5000">
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="small text-muted mb-1">Nota (Opcional)</label>
|
|
||||||
<input type="text" id="dicom-notes" class="form-control" placeholder="Ej: Pan y bebida"
|
|
||||||
onkeydown="if(event.key === 'Enter') submitDicom('add')">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex flex-column gap-2">
|
|
||||||
<button class="btn btn-danger py-2 fw-bold" onclick="submitDicom('add')">
|
|
||||||
<i class="bi bi-cart-plus me-1"></i> Fiar (Sumar Deuda)
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success py-2 fw-bold" onclick="submitDicom('pay')">
|
|
||||||
<i class="bi bi-cash-coin me-1"></i> Abonar (Restar Deuda)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="d-flex flex-column gap-2">
|
||||||
<div class="discord-card p-3">
|
<button class="btn btn-danger py-2 fw-bold" onclick="submitDicom('add')">
|
||||||
|
<i class="bi bi-cart-plus me-1"></i> Fiar (Sumar Deuda)
|
||||||
<div class="position-relative mb-3">
|
</button>
|
||||||
<input type="text" id="dicom-search" class="form-control ps-5"
|
<button class="btn btn-success py-2 fw-bold" onclick="submitDicom('pay')">
|
||||||
placeholder="Buscar cliente por nombre..." onkeyup="filterDicom()">
|
<i class="bi bi-cash-coin me-1"></i> Abonar (Restar Deuda)
|
||||||
<i class="bi bi-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted"></i>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table mb-0" id="dicom-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Nombre</th>
|
|
||||||
<th>Deuda Total</th>
|
|
||||||
<th>Última Nota</th>
|
|
||||||
<th>Actualizado</th>
|
|
||||||
<th class="text-end">Acciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for d in debtors %}
|
|
||||||
<tr>
|
|
||||||
<td class="fw-bold">{{ d[1] }}</td>
|
|
||||||
<td class="fw-bold price-cell" data-value="{{ d[2] }}"></td>
|
|
||||||
<td class="text-muted small">{{ d[3] }}</td>
|
|
||||||
<td class="text-muted small">{{ d[4] }}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary"
|
|
||||||
onclick="selectClient('{{ d[1] }}')" title="Seleccionar">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger ms-1"
|
|
||||||
onclick="forgiveDebt({{ d[0] }}, '{{ d[1] }}')" title="Eliminar Registro">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<div class="col-md-8">
|
||||||
<script>
|
<div class="discord-card p-3">
|
||||||
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
|
||||||
|
|
||||||
// Smart color formatting for the debt column
|
<div class="position-relative mb-3">
|
||||||
document.querySelectorAll('.price-cell').forEach(td => {
|
<input type="text" id="dicom-search" class="form-control ps-5"
|
||||||
const val = parseFloat(td.getAttribute('data-value'));
|
placeholder="Buscar cliente por nombre..." onkeyup="filterDicom()">
|
||||||
td.innerText = clp.format(val);
|
<i class="bi bi-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Reversing the logic: Negative is debt (red), Positive is credit (green)
|
<div class="table-responsive">
|
||||||
if (val < 0) {
|
<table class="table mb-0" id="dicom-table">
|
||||||
td.classList.add('text-danger');
|
<thead>
|
||||||
} else if (val > 0) {
|
<tr>
|
||||||
td.classList.add('text-success');
|
<th>Nombre</th>
|
||||||
} else {
|
<th>Deuda Total</th>
|
||||||
td.classList.add('text-muted');
|
<th>Última Nota</th>
|
||||||
}
|
<th>Actualizado</th>
|
||||||
|
<th class="text-end">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in debtors %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">{{ d[1] }}</td>
|
||||||
|
<td class="fw-bold price-cell" data-value="{{ d[2] }}"></td>
|
||||||
|
<td class="text-muted small">{{ d[3] }}</td>
|
||||||
|
<td class="text-muted small">{{ d[4] }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="selectClient('{{ d[1] }}')"
|
||||||
|
title="Seleccionar">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger ms-1"
|
||||||
|
onclick="forgiveDebt({{ d[0] }}, '{{ d[1] }}')" title="Eliminar Registro">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
// Smart color formatting for the debt column
|
||||||
|
document.querySelectorAll('.price-cell').forEach(td => {
|
||||||
|
const val = parseFloat(td.getAttribute('data-value'));
|
||||||
|
td.innerText = clp.format(val);
|
||||||
|
|
||||||
|
// Reversing the logic: Negative is debt (red), Positive is credit (green)
|
||||||
|
if (val < 0) {
|
||||||
|
td.classList.add('text-danger');
|
||||||
|
} else if (val > 0) {
|
||||||
|
td.classList.add('text-success');
|
||||||
|
} else {
|
||||||
|
td.classList.add('text-muted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearDicomForm() {
|
||||||
|
document.getElementById('dicom-name').value = '';
|
||||||
|
document.getElementById('dicom-amount').value = '';
|
||||||
|
document.getElementById('dicom-notes').value = '';
|
||||||
|
document.getElementById('dicom-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Filter
|
||||||
|
function filterDicom() {
|
||||||
|
const q = document.getElementById('dicom-search').value.toLowerCase();
|
||||||
|
document.querySelectorAll('#dicom-table tbody tr').forEach(row => {
|
||||||
|
const name = row.cells[0].innerText.toLowerCase();
|
||||||
|
row.style.display = name.includes(q) ? '' : 'none';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function clearDicomForm() {
|
// Just pre-fills the form so you don't accidentally click the wrong action
|
||||||
document.getElementById('dicom-name').value = '';
|
function selectClient(name) {
|
||||||
document.getElementById('dicom-amount').value = '';
|
document.getElementById('dicom-name').value = name;
|
||||||
document.getElementById('dicom-notes').value = '';
|
document.getElementById('dicom-amount').focus();
|
||||||
document.getElementById('dicom-name').focus();
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDicom(action) {
|
||||||
|
const name = document.getElementById('dicom-name').value;
|
||||||
|
const amount = document.getElementById('dicom-amount').value;
|
||||||
|
const notes = document.getElementById('dicom-notes').value;
|
||||||
|
|
||||||
|
if (!name || amount <= 0) {
|
||||||
|
alert('Ingresa un nombre y monto válido.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Filter
|
try {
|
||||||
function filterDicom() {
|
const res = await fetch('/api/dicom/update', {
|
||||||
const q = document.getElementById('dicom-search').value.toLowerCase();
|
method: 'POST',
|
||||||
document.querySelectorAll('#dicom-table tbody tr').forEach(row => {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const name = row.cells[0].innerText.toLowerCase();
|
body: JSON.stringify({ name, amount, notes, action })
|
||||||
row.style.display = name.includes(q) ? '' : 'none';
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Just pre-fills the form so you don't accidentally click the wrong action
|
if (res.ok) {
|
||||||
function selectClient(name) {
|
window.location.reload();
|
||||||
document.getElementById('dicom-name').value = name;
|
} else {
|
||||||
document.getElementById('dicom-amount').focus();
|
alert('Error actualizando la base de datos.');
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitDicom(action) {
|
|
||||||
const name = document.getElementById('dicom-name').value;
|
|
||||||
const amount = document.getElementById('dicom-amount').value;
|
|
||||||
const notes = document.getElementById('dicom-notes').value;
|
|
||||||
|
|
||||||
if (!name || amount <= 0) {
|
|
||||||
alert('Ingresa un nombre y monto válido.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/dicom/update', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, amount, notes, action })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Error actualizando la base de datos.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Error de conexión.");
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Error de conexión.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function forgiveDebt(id, name) {
|
async function forgiveDebt(id, name) {
|
||||||
if (!confirm(`¿Estás seguro de que quieres eliminar completamente a ${name} del registro?`)) return;
|
if (!confirm(`¿Estás seguro de que quieres eliminar completamente a ${name} del registro?`)) return;
|
||||||
|
|
||||||
const res = await fetch(`/api/dicom/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/dicom/${id}`, { method: 'DELETE' });
|
||||||
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>
|
|
||||||
@@ -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
717
templates/inventory.html
Normal 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
31
templates/macros/base.html
Normal file
31
templates/macros/base.html
Normal 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>
|
||||||
46
templates/macros/modals.html
Normal file
46
templates/macros/modals.html
Normal 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 %}
|
||||||
@@ -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>
|
||||||
@@ -1,322 +1,192 @@
|
|||||||
<!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 {
|
<div class="row g-3 mb-3">
|
||||||
background: var(--navbar-bg) !important;
|
<div class="col-12 col-md-4">
|
||||||
border-bottom: 1px solid var(--border);
|
<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 %}
|
||||||
.navbar-brand,
|
</h6>
|
||||||
.nav-link,
|
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.daily }}">
|
||||||
.dropdown-item {
|
</h2>
|
||||||
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="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>
|
||||||
|
</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="discord-card p-3 shadow-sm">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<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>
|
<h4 class="mb-0"><i class="bi bi-receipt-cutoff me-2"></i>Historial</h4>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<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
|
<label for="date-filter" class="form-label mb-0 text-muted small d-none d-sm-block">Filtrar
|
||||||
Día:</label>
|
Día:</label>
|
||||||
<input type="date" id="date-filter" class="form-control form-control-sm"
|
<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);"
|
style="background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
|
||||||
value="{{ selected_date or '' }}" onchange="filterByDate(this.value)">
|
value="{{ selected_date or '' }}" onchange="filterByDate(this.value)">
|
||||||
{% if selected_date %}
|
{% if selected_date %}
|
||||||
<a href="/sales" class="btn btn-sm btn-outline-secondary" title="Limpiar filtro"><i
|
<a href="/sales" class="btn btn-sm btn-outline-secondary" title="Limpiar filtro"><i
|
||||||
class="bi bi-x-lg"></i></a>
|
class="bi bi-x-lg"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
<div class="table-responsive">
|
<div class="modal-body">
|
||||||
<table class="table table-hover">
|
<p class="text-muted small mb-2" id="modal-date"></p>
|
||||||
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nº Ticket</th>
|
<th>Producto</th>
|
||||||
<th>Fecha y Hora</th>
|
<th>Cant</th>
|
||||||
<th>Método</th>
|
<th class="text-end">Subtotal</th>
|
||||||
<th>Total</th>
|
|
||||||
<th>Acciones</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="receipt-items">
|
||||||
{% 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>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer d-flex justify-content-between border-0 pt-0">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" id="btn-reverse-sale">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Anular Venta
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="receiptModal" tabindex="-1">
|
<div class="modal fade" id="reverseConfirmModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content border-danger">
|
||||||
<div class="modal-header">
|
<div class="modal-header pb-0 border-0">
|
||||||
<h5 class="modal-title">Detalle de Venta <span id="modal-ticket-id" class="text-muted small"></span>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</h5>
|
</div>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<div class="modal-body text-center pt-0 pb-4">
|
||||||
</div>
|
<i class="bi bi-exclamation-triangle-fill text-danger mb-3" style="font-size: 3rem;"></i>
|
||||||
<div class="modal-body">
|
<h4 class="mb-3">¿Anular Venta #<span id="reverse-modal-id"></span>?</h4>
|
||||||
<p class="text-muted small mb-2" id="modal-date"></p>
|
<p class="text-muted small px-3">Los productos regresarán automáticamente al inventario y el ticket
|
||||||
<table class="table table-sm">
|
será eliminado permanentemente.</p>
|
||||||
<thead>
|
|
||||||
<tr>
|
<div class="d-flex gap-2 justify-content-center mt-4 px-3">
|
||||||
<th>Producto</th>
|
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancelar</button>
|
||||||
<th>Cant</th>
|
<button class="btn btn-danger w-50" onclick="executeReverseSale()">Sí, Anular</button>
|
||||||
<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 class="modal-body">
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer d-flex justify-content-between border-0 pt-0">
|
|
||||||
<button class="btn btn-outline-danger btn-sm" id="btn-reverse-sale">
|
|
||||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Anular Venta
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
||||||
|
let saleToReverse = null;
|
||||||
|
|
||||||
<div class="modal fade" id="reverseConfirmModal" tabindex="-1">
|
// Format raw UTC dates from DB into friendly local time
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
document.querySelectorAll('.utc-date').forEach(el => {
|
||||||
<div class="modal-content border-danger">
|
const date = new Date(el.innerText + " UTC");
|
||||||
<div class="modal-header pb-0 border-0">
|
if (!isNaN(date)) {
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
el.innerText = date.toLocaleString('es-CL', {
|
||||||
</div>
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
<div class="modal-body text-center pt-0 pb-4">
|
hour: '2-digit', minute: '2-digit'
|
||||||
<i class="bi bi-exclamation-triangle-fill text-danger mb-3" style="font-size: 3rem;"></i>
|
});
|
||||||
<h4 class="mb-3">¿Anular Venta #<span id="reverse-modal-id"></span>?</h4>
|
}
|
||||||
<p class="text-muted small px-3">Los productos regresarán automáticamente al inventario y el ticket
|
});
|
||||||
será eliminado permanentemente.</p>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2 justify-content-center mt-4 px-3">
|
// Format all prices
|
||||||
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancelar</button>
|
document.querySelectorAll('.price-cell').forEach(td => {
|
||||||
<button class="btn btn-danger w-50" onclick="executeReverseSale()">Sí, Anular</button>
|
td.innerText = clp.format(td.getAttribute('data-value'));
|
||||||
</div>
|
});
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
async function viewSale(id, rawDate, total) {
|
||||||
<script>
|
document.getElementById('modal-ticket-id').innerText = `#${id}`;
|
||||||
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
document.getElementById('modal-total').innerText = clp.format(total);
|
||||||
let saleToReverse = null;
|
|
||||||
|
|
||||||
// Format raw UTC dates from DB into friendly local time
|
const localDate = new Date(rawDate + " UTC").toLocaleString('es-CL');
|
||||||
document.querySelectorAll('.utc-date').forEach(el => {
|
document.getElementById('modal-date').innerText = localDate !== "Invalid Date" ? localDate : rawDate;
|
||||||
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
|
const tbody = document.getElementById('receipt-items');
|
||||||
document.querySelectorAll('.price-cell').forEach(td => {
|
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">Cargando...</td></tr>';
|
||||||
td.innerText = clp.format(td.getAttribute('data-value'));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function viewSale(id, rawDate, total) {
|
// Attach the ID to the delete button
|
||||||
document.getElementById('modal-ticket-id').innerText = `#${id}`;
|
document.getElementById('btn-reverse-sale').setAttribute('onclick', `reverseSale(${id})`);
|
||||||
document.getElementById('modal-total').innerText = clp.format(total);
|
|
||||||
|
|
||||||
const localDate = new Date(rawDate + " UTC").toLocaleString('es-CL');
|
const modal = new bootstrap.Modal(document.getElementById('receiptModal'));
|
||||||
document.getElementById('modal-date').innerText = localDate !== "Invalid Date" ? localDate : rawDate;
|
modal.show();
|
||||||
|
|
||||||
const tbody = document.getElementById('receipt-items');
|
try {
|
||||||
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">Cargando...</td></tr>';
|
const res = await fetch(`/api/sale/${id}`);
|
||||||
|
const items = await res.json();
|
||||||
|
|
||||||
// Attach the ID to the delete button
|
tbody.innerHTML = items.map(item => `
|
||||||
document.getElementById('btn-reverse-sale').setAttribute('onclick', `reverseSale(${id})`);
|
|
||||||
|
|
||||||
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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
${item.name}<br>
|
${item.name}<br>
|
||||||
@@ -326,51 +196,47 @@
|
|||||||
<td class="text-end">${clp.format(item.subtotal)}</td>
|
<td class="text-end">${clp.format(item.subtotal)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-danger">Error cargando productos</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-danger">Error cargando productos</td></tr>';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function filterByDate(dateVal) {
|
function filterByDate(dateVal) {
|
||||||
if (dateVal) {
|
if (dateVal) {
|
||||||
window.location.href = `/sales?date=${dateVal}`;
|
window.location.href = `/sales?date=${dateVal}`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `/sales`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseSale(id) {
|
||||||
|
saleToReverse = id;
|
||||||
|
document.getElementById('reverse-modal-id').innerText = id;
|
||||||
|
|
||||||
|
// Hide the receipt modal so we don't have overlapping popups
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('receiptModal')).hide();
|
||||||
|
|
||||||
|
// Show the new confirmation modal
|
||||||
|
const confirmModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('reverseConfirmModal'));
|
||||||
|
confirmModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeReverseSale() {
|
||||||
|
if (!saleToReverse) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sale/${saleToReverse}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload(); // Refresh the dashboard
|
||||||
} else {
|
} else {
|
||||||
window.location.href = `/sales`;
|
const data = await res.json();
|
||||||
|
alert("Error anulando la venta: " + (data.error || "Desconocido"));
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("Error de conexión con el servidor.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function reverseSale(id) {
|
</script>
|
||||||
saleToReverse = id;
|
{% endblock %}
|
||||||
document.getElementById('reverse-modal-id').innerText = id;
|
|
||||||
|
|
||||||
// Hide the receipt modal so we don't have overlapping popups
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('receiptModal')).hide();
|
|
||||||
|
|
||||||
// Show the new confirmation modal
|
|
||||||
const confirmModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('reverseConfirmModal'));
|
|
||||||
confirmModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeReverseSale() {
|
|
||||||
if (!saleToReverse) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sale/${saleToReverse}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
window.location.reload(); // Refresh the dashboard
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
alert("Error anulando la venta: " + (data.error || "Desconocido"));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert("Error de conexión con el servidor.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script src="./static/cookieStuff.js"></script>
|
|
||||||
<script src="./static/themeStuff.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user