modified: database.py
modified: routes_admin.py modified: routes_worker.py modified: templates/admin_productos.html modified: templates/admin_workers.html modified: templates/macros/modals.html modified: templates/worker_dashboard.html
This commit is contained in:
48
database.py
48
database.py
@@ -71,10 +71,20 @@ def populateDefaults():
|
||||
]
|
||||
c.execute("SELECT id FROM zonas")
|
||||
zonas_ids = [row[0] for row in c.fetchall()]
|
||||
for zona_id in zonas_ids:
|
||||
for name, price, commission in productos_base:
|
||||
c.execute("INSERT INTO productos (zona_id, name, price, commission) VALUES (?, ?, ?, ?)",
|
||||
(zona_id, name, price, commission))
|
||||
|
||||
# Fecha de activación artificial en el pasado para que las ventas históricas funcionen
|
||||
fecha_base_precios = (datetime.date.today() - datetime.timedelta(days=365*3)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
for name, price, commission in productos_base:
|
||||
c.execute("INSERT OR IGNORE INTO productos (name) VALUES (?)", (name,))
|
||||
c.execute("SELECT id FROM productos WHERE name = ?", (name,))
|
||||
p_id = c.fetchone()[0]
|
||||
|
||||
for zona_id in zonas_ids:
|
||||
c.execute('''INSERT INTO precios_historicos
|
||||
(producto_id, zona_id, price, commission, fecha_activacion)
|
||||
VALUES (?, ?, ?, ?, ?)''',
|
||||
(p_id, zona_id, price, commission, fecha_base_precios))
|
||||
conn.commit()
|
||||
|
||||
c.execute("SELECT COUNT(*) FROM workers WHERE is_admin = 0")
|
||||
@@ -165,7 +175,19 @@ def populateDefaults():
|
||||
|
||||
rend_id = c.lastrowid
|
||||
|
||||
c.execute("SELECT id, price, commission FROM productos WHERE zona_id = (SELECT zona_id FROM modulos WHERE id = ?)", (m_id,))
|
||||
# Buscar el precio activo en el momento exacto de la venta histórica
|
||||
c.execute('''
|
||||
SELECT p.id, ph.price, ph.commission
|
||||
FROM productos p
|
||||
JOIN precios_historicos ph ON p.id = ph.producto_id
|
||||
WHERE ph.zona_id = (SELECT zona_id FROM modulos WHERE id = ?)
|
||||
AND ph.fecha_activacion = (
|
||||
SELECT MAX(fecha_activacion)
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = p.id AND zona_id = ph.zona_id
|
||||
AND fecha_activacion <= ?
|
||||
)
|
||||
''', (m_id, fecha_str + ' 23:59:59'))
|
||||
prods_zona = c.fetchall()
|
||||
|
||||
if prods_zona:
|
||||
@@ -182,7 +204,7 @@ def populateDefaults():
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
@@ -194,12 +216,20 @@ def init_db():
|
||||
zona_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
FOREIGN KEY (zona_id) REFERENCES zonas(id))''')
|
||||
# 1. Catálogo Maestro (Solo el nombre)
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS productos
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL)''')
|
||||
|
||||
# 2. Historial de Precios por Zona
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS precios_historicos
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
producto_id INTEGER NOT NULL,
|
||||
zona_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
commission REAL NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
commission INTEGER NOT NULL,
|
||||
fecha_activacion DATETIME NOT NULL,
|
||||
FOREIGN KEY (producto_id) REFERENCES productos(id),
|
||||
FOREIGN KEY (zona_id) REFERENCES zonas(id))''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS workers
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
206
routes_admin.py
206
routes_admin.py
@@ -1,5 +1,5 @@
|
||||
import sqlite3
|
||||
from flask import app, render_template, request, redirect, url_for, flash, session
|
||||
from flask import app, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
from werkzeug.security import generate_password_hash
|
||||
from datetime import date, datetime
|
||||
from database import get_db_connection
|
||||
@@ -211,87 +211,165 @@ def register_admin_routes(app):
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name').strip()
|
||||
zona_id = request.form.get('zona_id')
|
||||
|
||||
raw_price = request.form.get('price').replace('.', '')
|
||||
raw_commission = request.form.get('commission').replace('.', '')
|
||||
|
||||
if not zona_id:
|
||||
flash("Debes seleccionar una Zona.", "danger")
|
||||
else:
|
||||
try:
|
||||
price = int(raw_price)
|
||||
commission = int(raw_commission)
|
||||
|
||||
c.execute("INSERT INTO productos (zona_id, name, price, commission) VALUES (?, ?, ?, ?)",
|
||||
(zona_id, name, price, commission))
|
||||
conn.commit()
|
||||
flash("Producto guardado exitosamente.", "success")
|
||||
except ValueError:
|
||||
flash("El precio y la comisión deben ser números enteros válidos.", "danger")
|
||||
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
c.execute("SELECT id, name FROM zonas ORDER BY name")
|
||||
zonas = c.fetchall()
|
||||
|
||||
c.execute('''SELECT p.id, p.name, p.price, p.commission, z.name, p.zona_id
|
||||
FROM productos p
|
||||
JOIN zonas z ON p.zona_id = z.id
|
||||
ORDER BY z.name, p.name''')
|
||||
productos = c.fetchall()
|
||||
|
||||
conn.close()
|
||||
return render_template('admin_productos.html', zonas=zonas, productos=productos)
|
||||
|
||||
@app.route('/admin/productos/edit/<int:id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def edit_product(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name').strip()
|
||||
zona_id = request.form.get('zona_id')
|
||||
|
||||
raw_price = request.form.get('price').replace('.', '')
|
||||
raw_commission = request.form.get('commission').replace('.', '')
|
||||
|
||||
try:
|
||||
price = int(raw_price)
|
||||
commission = int(raw_commission)
|
||||
# 1. Crear producto maestro
|
||||
c.execute("INSERT INTO productos (name) VALUES (?)", (name,))
|
||||
prod_id = c.lastrowid
|
||||
|
||||
# 2. Inicializar precios en 0 para todas las zonas activas
|
||||
c.execute("SELECT id FROM zonas")
|
||||
zonas = c.fetchall()
|
||||
for zona in zonas:
|
||||
c.execute('''INSERT INTO precios_historicos
|
||||
(producto_id, zona_id, price, commission, is_active)
|
||||
VALUES (?, ?, 0, 0, 1)''', (prod_id, zona[0]))
|
||||
|
||||
c.execute("UPDATE productos SET zona_id=?, name=?, price=?, commission=? WHERE id=?",
|
||||
(zona_id, name, price, commission, id))
|
||||
conn.commit()
|
||||
flash("Producto actualizado exitosamente.", "success")
|
||||
conn.close()
|
||||
return redirect(url_for('manage_products'))
|
||||
except ValueError:
|
||||
flash("El precio y la comisión deben ser números enteros válidos.", "danger")
|
||||
flash("Producto maestro creado. No olvides configurar sus precios.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Ese producto ya existe en el catálogo.", "danger")
|
||||
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
c.execute("SELECT id, name FROM zonas ORDER BY name")
|
||||
zonas = c.fetchall()
|
||||
|
||||
c.execute("SELECT id, zona_id, name, price, commission FROM productos WHERE id=?", (id,))
|
||||
producto = c.fetchone()
|
||||
# Obtener productos y el precio VIGENTE por zona usando una subquery
|
||||
c.execute('''
|
||||
SELECT p.id, p.name,
|
||||
z.id as zona_id, z.name as zona_name,
|
||||
ph.price, ph.commission
|
||||
FROM productos p
|
||||
CROSS JOIN zonas z
|
||||
LEFT JOIN precios_historicos ph
|
||||
ON p.id = ph.producto_id AND z.id = ph.zona_id
|
||||
AND ph.fecha_activacion = (
|
||||
SELECT MAX(fecha_activacion)
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = p.id AND zona_id = z.id
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
)
|
||||
ORDER BY p.name, z.name
|
||||
''')
|
||||
|
||||
# Agrupar datos para la vista: {prod_id: {'name': 'Lentes', 'precios': {zona_id: {'price': X, 'comm': Y}}}}
|
||||
raw_data = c.fetchall()
|
||||
productos_dict = {}
|
||||
for row in raw_data:
|
||||
p_id, p_name, z_id, z_name, price, comm = row
|
||||
if p_id not in productos_dict:
|
||||
# Añadimos la lista 'futuros'
|
||||
productos_dict[p_id] = {'id': p_id, 'name': p_name, 'precios': {}, 'futuros': []}
|
||||
productos_dict[p_id]['precios'][z_id] = {
|
||||
'zona_name': z_name,
|
||||
'price': price or 0,
|
||||
'commission': comm or 0
|
||||
}
|
||||
|
||||
# BÚSQUEDA DE PRECIOS PROGRAMADOS (Futuro)
|
||||
c.execute('''
|
||||
SELECT ph.id, ph.producto_id, z.name, ph.price, ph.commission, ph.fecha_activacion
|
||||
FROM precios_historicos ph
|
||||
JOIN zonas z ON ph.zona_id = z.id
|
||||
WHERE ph.fecha_activacion > datetime('now', 'localtime')
|
||||
ORDER BY ph.fecha_activacion ASC
|
||||
''')
|
||||
future_data = c.fetchall()
|
||||
|
||||
for row in future_data:
|
||||
ph_id, p_id, z_name, price, comm, fecha = row
|
||||
if p_id in productos_dict:
|
||||
productos_dict[p_id]['futuros'].append({
|
||||
'id': ph_id,
|
||||
'zona_name': z_name,
|
||||
'price': price,
|
||||
'commission': comm,
|
||||
'fecha': fecha
|
||||
})
|
||||
|
||||
conn.close()
|
||||
return render_template('admin_productos.html', zonas=zonas, productos=productos_dict.values())
|
||||
|
||||
if not producto:
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
return render_template('edit_producto.html', zonas=zonas, producto=producto)
|
||||
from datetime import datetime
|
||||
|
||||
@app.route('/admin/productos/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_product(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM productos WHERE id=?", (id,))
|
||||
try:
|
||||
# Borrar historial de precios primero
|
||||
c.execute("DELETE FROM precios_historicos WHERE producto_id=?", (id,))
|
||||
# Borrar producto maestro
|
||||
c.execute("DELETE FROM productos WHERE id=?", (id,))
|
||||
conn.commit()
|
||||
flash("Producto maestro y su historial eliminados.", "info")
|
||||
except sqlite3.IntegrityError:
|
||||
# Si hay ventas asociadas en rendicion_items, SQLite bloqueará el borrado
|
||||
flash("No puedes eliminar este producto porque ya tiene ventas registradas. Cámbiale el precio a 0 en su lugar.", "danger")
|
||||
finally:
|
||||
conn.close()
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
@app.route('/admin/productos/precios/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def update_product_prices(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("SELECT id FROM zonas")
|
||||
zonas = c.fetchall()
|
||||
|
||||
# Obtener fecha programada o usar la actual
|
||||
fecha_input = request.form.get('fecha_activacion')
|
||||
if fecha_input:
|
||||
fecha_activacion = fecha_input.replace('T', ' ') + ':00'
|
||||
else:
|
||||
fecha_activacion = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
for zona in zonas:
|
||||
z_id = str(zona[0])
|
||||
new_price = int(request.form.get(f'price_{z_id}', '0').replace('.', ''))
|
||||
new_comm = int(request.form.get(f'comm_{z_id}', '0').replace('.', ''))
|
||||
|
||||
c.execute('''INSERT INTO precios_historicos
|
||||
(producto_id, zona_id, price, commission, fecha_activacion)
|
||||
VALUES (?, ?, ?, ?, ?)''', (id, z_id, new_price, new_comm, fecha_activacion))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Producto eliminado.", "info")
|
||||
flash(f"Precios actualizados. Entrarán en vigencia el {fecha_activacion}.", "success")
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
@app.route('/admin/productos/precios/cancelar/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def cancel_scheduled_price(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM precios_historicos WHERE id = ?", (id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Cambio de precio programado cancelado.", "info")
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
@app.route('/admin/api/productos/<int:id>/historial')
|
||||
@admin_required
|
||||
def api_product_history(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
SELECT z.name, ph.price, ph.fecha_activacion
|
||||
FROM precios_historicos ph
|
||||
JOIN zonas z ON ph.zona_id = z.id
|
||||
WHERE ph.producto_id = ?
|
||||
ORDER BY ph.fecha_activacion ASC
|
||||
''', (id,)) # <-- Añade la coma para que sea una tupla
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
# Convertimos la data a una lista de diccionarios que JS entienda felizmente
|
||||
history = [{'zona': r[0], 'price': r[1], 'fecha': r[2]} for r in rows]
|
||||
return jsonify(history)
|
||||
|
||||
@app.route('/admin/rendiciones')
|
||||
@admin_required
|
||||
|
||||
@@ -133,7 +133,14 @@ def register_worker_routes(app):
|
||||
prod_id = int(key.split('_')[1])
|
||||
cantidad = int(value)
|
||||
|
||||
c.execute("SELECT price, commission FROM productos WHERE id = ?", (prod_id,))
|
||||
# Buscar el precio vigente al momento de la venta
|
||||
c.execute('''
|
||||
SELECT price, commission
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = ? AND zona_id = ?
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
ORDER BY fecha_activacion DESC LIMIT 1
|
||||
''', (prod_id, zona_id))
|
||||
prod_data = c.fetchone()
|
||||
|
||||
if prod_data:
|
||||
@@ -153,7 +160,20 @@ def register_worker_routes(app):
|
||||
''', (session['user_id'], modulo_id))
|
||||
otros_trabajadores = c.fetchall()
|
||||
|
||||
c.execute("SELECT id, name, price, commission FROM productos WHERE zona_id = ? ORDER BY name", (zona_id,))
|
||||
# Buscar solo el precio vigente actual para esta zona
|
||||
c.execute('''
|
||||
SELECT p.id, p.name, ph.price, ph.commission
|
||||
FROM productos p
|
||||
JOIN precios_historicos ph ON p.id = ph.producto_id
|
||||
WHERE ph.zona_id = ?
|
||||
AND ph.fecha_activacion = (
|
||||
SELECT MAX(fecha_activacion)
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = p.id AND zona_id = ?
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
)
|
||||
ORDER BY p.name
|
||||
''', (zona_id, zona_id)) # Nota: zona_id se pasa dos veces
|
||||
productos = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -21,103 +21,254 @@
|
||||
{{ edit_product_modal(zonas) }}
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-primary text-white">Agregar Nuevo Producto</div>
|
||||
<div class="card-header bg-primary text-white">Agregar Producto Maestro</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('manage_products') }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Zona</label>
|
||||
<select class="form-select" name="zona_id" required>
|
||||
<option value="" selected disabled>Seleccionar Zona...</option>
|
||||
{% for zona in zonas %}
|
||||
<option value="{{ zona[0] }}">{{ zona[1] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Nombre del Producto</label>
|
||||
<input type="text" class="form-control" name="name" required>
|
||||
<div class="col-md-10">
|
||||
<input type="text" class="form-control" name="name" placeholder="Nombre del Producto" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Precio de Venta</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="text" class="form-control money-input" name="price" placeholder="1.500" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Comisión</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="text" class="form-control money-input" name="commission" placeholder="500" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">+</button>
|
||||
<button type="submit" class="btn btn-primary w-100">Crear Producto</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3 shadow-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchProduct" placeholder="Buscar producto maestro...">
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Zona</th>
|
||||
<th>Producto</th>
|
||||
<th>Precio</th>
|
||||
<th>Comisión</th>
|
||||
<th>Producto Maestro</th>
|
||||
<th class="text-end">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for prod in productos %}
|
||||
<tr>
|
||||
<td class="align-middle"><span class="badge bg-info text-dark">{{ prod[4] }}</span></td>
|
||||
<td class="align-middle">{{ prod[1] }}</td>
|
||||
<td class="align-middle">${{ "{:,.0f}".format(prod[2]).replace(',', '.') }}</td>
|
||||
<td class="align-middle">${{ "{:,.0f}".format(prod[3]).replace(',', '.') }}</td>
|
||||
<tr class="product-row">
|
||||
<td class="align-middle fw-bold">{{ prod.name }}</td>
|
||||
<td class="text-end">
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-sm btn-edit-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editProductModal"
|
||||
data-id="{{ prod[0] }}"
|
||||
data-name="{{ prod[1] }}"
|
||||
data-price="{{ prod[2]|int }}"
|
||||
data-commission="{{ prod[3]|int }}"
|
||||
data-zona="{{ prod[5] }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
<button type="button" class="btn btn-info btn-sm text-white" onclick="showHistory({{ prod.id }}, '{{ prod.name }}')">
|
||||
<i class="bi bi-graph-up"></i> Historial
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-danger btn-sm btn-del-sm" data-bs-toggle="modal" data-bs-target="#deleteProd{{ prod[0] }}">
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#pricesModal{{ prod.id }}">
|
||||
<i class="bi bi-currency-dollar"></i> Precios
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteProd{{ prod.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
|
||||
{{ confirm_modal(
|
||||
id='deleteProd' ~ prod[0],
|
||||
id='deleteProd' ~ prod.id,
|
||||
title='Eliminar Producto',
|
||||
message='¿Estás seguro de que deseas eliminar "' ~ prod[1] ~ '"?',
|
||||
action_url=url_for('delete_product', id=prod[0]),
|
||||
message='¿Eliminar "' ~ prod.name ~ '"? Esto fallará si el producto ya tiene ventas registradas.',
|
||||
action_url=url_for('delete_product', id=prod.id),
|
||||
btn_class='btn-danger',
|
||||
btn_text='Eliminar permanentemente'
|
||||
btn_text='Eliminar'
|
||||
) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-3 text-muted">No hay productos registrados.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for prod in productos %}
|
||||
<div class="modal fade" id="pricesModal{{ prod.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('update_product_prices', id=prod.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Precios: {{ prod.name }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="row fw-bold mb-2">
|
||||
<div class="col-4">Zona</div>
|
||||
<div class="col-4">Precio</div>
|
||||
<div class="col-4">Comisión</div>
|
||||
</div>
|
||||
{% for z_id, datos in prod.precios.items() %}
|
||||
<div class="row mb-2">
|
||||
<div class="col-4 d-flex align-items-center">
|
||||
<span class="badge bg-info text-dark w-100">{{ datos.zona_name }}</span>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="text" class="form-control money-input" name="price_{{ z_id }}" value="{{ datos.price }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="text" class="form-control money-input" name="comm_{{ z_id }}" value="{{ datos.commission }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="modal-body bg-body-tertiary border-top pb-3">
|
||||
<label class="form-label text-warning fw-bold mb-1">
|
||||
<i class="bi bi-clock-history"></i> Programar Cambio de Precio (Opcional)
|
||||
</label>
|
||||
<input type="datetime-local" class="form-control form-control-sm mb-1" name="fecha_activacion">
|
||||
<small class="text-muted" style="font-size: 0.85em;">Si lo dejas en blanco, el cambio se aplicará inmediatamente.</small>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
|
||||
<button type="submit" class="btn btn-primary">Guardar Precios</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if prod.futuros %}
|
||||
<div class="modal-body border-top">
|
||||
<h6 class="text-info fw-bold mb-3">
|
||||
<i class="bi bi-calendar-event"></i> Cambios Programados a Futuro
|
||||
</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered text-center mb-0" style="font-size: 0.85rem;">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Fecha Programada</th>
|
||||
<th>Zona</th>
|
||||
<th>Precio</th>
|
||||
<th>Comisión</th>
|
||||
<th>Cancelar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for futuro in prod.futuros %}
|
||||
<tr>
|
||||
<td class="align-middle text-nowrap text-warning">{{ futuro.fecha }}</td>
|
||||
<td class="align-middle fw-bold">{{ futuro.zona_name }}</td>
|
||||
<td class="align-middle">${{ "{:,.0f}".format(futuro.price).replace(',', '.') }}</td>
|
||||
<td class="align-middle">${{ "{:,.0f}".format(futuro.commission).replace(',', '.') }}</td>
|
||||
<td class="align-middle">
|
||||
<form action="{{ url_for('cancel_scheduled_price', id=futuro.id) }}" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm py-0 px-2" title="Cancelar este cambio">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="chartModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="chartModalTitle">Historial de Precios</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<canvas id="priceChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<script>
|
||||
let priceChartInstance = null;
|
||||
|
||||
async function showHistory(prodId, prodName) {
|
||||
// Abrir el modal inmediatamente para que no parezca que la app murió
|
||||
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
|
||||
document.getElementById('chartModalTitle').innerText = 'Fluctuación de Precio: ' + prodName;
|
||||
modal.show();
|
||||
|
||||
// Traer la data desde nuestra nueva API
|
||||
const res = await fetch(`/admin/api/productos/${prodId}/historial`);
|
||||
const data = await res.json();
|
||||
|
||||
// Extraer zonas únicas y fechas únicas (limpiando la hora para el eje X)
|
||||
const zonas = [...new Set(data.map(d => d.zona))];
|
||||
const fechas = [...new Set(data.map(d => d.fecha.split(' ')[0]))].sort();
|
||||
|
||||
const colors = ['#0d6efd', '#198754', '#dc3545', '#ffc107', '#0dcaf0'];
|
||||
|
||||
const datasets = zonas.map((zona, index) => {
|
||||
let lastPrice = 0;
|
||||
|
||||
// Rellenar huecos: Si no hubo cambio un día, se mantiene el precio del día anterior
|
||||
const dataPoints = fechas.map(f => {
|
||||
const hits = data.filter(d => d.zona === zona && d.fecha.startsWith(f));
|
||||
if (hits.length > 0) {
|
||||
lastPrice = hits[hits.length - 1].price; // Tomar el último cambio de ese día
|
||||
}
|
||||
return lastPrice;
|
||||
});
|
||||
|
||||
return {
|
||||
label: zona,
|
||||
data: dataPoints,
|
||||
borderColor: colors[index % colors.length],
|
||||
backgroundColor: colors[index % colors.length],
|
||||
stepped: true, // Hace que se vea como escaleras en vez de curvas raras
|
||||
borderWidth: 2
|
||||
};
|
||||
});
|
||||
|
||||
// Dibujar (o redibujar) el gráfico
|
||||
const ctx = document.getElementById('priceChart').getContext('2d');
|
||||
if (priceChartInstance) {
|
||||
priceChartInstance.destroy();
|
||||
}
|
||||
|
||||
priceChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: fechas,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) { return '$' + value.toLocaleString('es-CL'); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const editModal = document.getElementById('editProductModal');
|
||||
if (editModal) {
|
||||
@@ -158,5 +309,19 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('searchProduct');
|
||||
const productRows = document.querySelectorAll('.product-row');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const term = this.value.toLowerCase();
|
||||
productRows.forEach(row => {
|
||||
// Asume que el nombre está en la primera celda
|
||||
const name = row.cells[0].textContent.toLowerCase();
|
||||
row.style.display = name.includes(term) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -59,6 +59,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 shadow-sm border-0">
|
||||
<div class="card-body bg-body-tertiary rounded">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchWorker" placeholder="Buscar por Nombre o RUT...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select class="form-select" id="filterModule">
|
||||
<option value="all">Todos los Módulos</option>
|
||||
{% for mod in modulos %}
|
||||
<option value="{{ mod[0] }}">{{ mod[2] }} - {{ mod[1] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="filterType">
|
||||
<option value="all">Todas las Jornadas</option>
|
||||
<option value="Full Time">Full Time</option>
|
||||
<option value="Part Time">Part Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
@@ -74,7 +102,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for worker in workers %}
|
||||
<tr>
|
||||
<tr class="worker-row" data-modulo="{{ worker[5] }}" data-tipo="{{ worker[6] }}">
|
||||
<td class="align-middle">{{ worker[1] }}</td>
|
||||
<td class="align-middle">{{ worker[2] }}</td>
|
||||
<td class="align-middle">{{ worker[3] }}</td>
|
||||
@@ -207,5 +235,40 @@
|
||||
document.querySelectorAll('.phone-input, #phoneInput').forEach(inp => {
|
||||
inp.addEventListener('input', () => formatPhone(inp));
|
||||
});
|
||||
|
||||
|
||||
// --- LÓGICA DE FILTRADO DE TRABAJADORES ---
|
||||
const searchInputWorker = document.getElementById('searchWorker');
|
||||
const moduleSelectFilter = document.getElementById('filterModule');
|
||||
const typeSelectFilter = document.getElementById('filterType');
|
||||
const workerRows = document.querySelectorAll('.worker-row');
|
||||
|
||||
function filterWorkers() {
|
||||
const searchTerm = searchInputWorker.value.toLowerCase();
|
||||
const selectedModule = moduleSelectFilter.value;
|
||||
const selectedType = typeSelectFilter.value;
|
||||
|
||||
workerRows.forEach(row => {
|
||||
// Asumiendo que celda 0 es RUT y celda 1 es Nombre
|
||||
const rut = row.cells[0].textContent.toLowerCase();
|
||||
const name = row.cells[1].textContent.toLowerCase();
|
||||
const rowModule = row.getAttribute('data-modulo');
|
||||
const rowType = row.getAttribute('data-tipo');
|
||||
|
||||
const matchesSearch = rut.includes(searchTerm) || name.includes(searchTerm);
|
||||
const matchesModule = selectedModule === 'all' || rowModule === selectedModule;
|
||||
const matchesType = selectedType === 'all' || rowType === selectedType;
|
||||
|
||||
if (matchesSearch && matchesModule && matchesType) {
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
searchInputWorker.addEventListener('input', filterWorkers);
|
||||
moduleSelectFilter.addEventListener('change', filterWorkers);
|
||||
typeSelectFilter.addEventListener('change', filterWorkers);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
<dd class="col-sm-7">{{ rendicion[2] }}
|
||||
{% if session.get('is_admin') %}
|
||||
<span class="badge {% if rendicion[14] %}bg-success{% else %}bg-secondary{% endif %} ms-1" style="font-size: 0.65em;">
|
||||
{% if rendicion[14] %}$ Sí{% else %}$ No{% endif %}
|
||||
{% if rendicion[14] %}$ Si Recibe Comision{% else %}$ No Recibe Comision{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
@@ -226,7 +226,7 @@
|
||||
{{ rendicion[10] }}
|
||||
{% if session.get('is_admin') %}
|
||||
<span class="badge {% if rendicion[15] %}bg-success{% else %}bg-secondary{% endif %} ms-1" style="font-size: 0.65em;">
|
||||
{% if rendicion[15] %}$ Sí{% else %}$ No{% endif %}
|
||||
{% if rendicion[15] %}$ Si Recibe Comision{% else %}$ No Recibe Comision{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
|
||||
@@ -214,6 +214,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="discrepancy_warning" class="alert alert-warning mb-4" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i> <span id="discrepancy_text"></span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary w-100 py-3 mb-5" data-bs-toggle="modal" data-bs-target="#confirmSubmitModal">
|
||||
<i class="bi bi-send-check me-2"></i> Enviar Rendición Diaria
|
||||
</button>
|
||||
@@ -277,6 +281,8 @@
|
||||
}
|
||||
});
|
||||
displayTotalProductos.value = granTotal.toLocaleString('es-CL');
|
||||
|
||||
checkWarnings();
|
||||
}
|
||||
|
||||
inputsCantidad.forEach(input => {
|
||||
@@ -292,6 +298,41 @@
|
||||
calcularVentaProductos();
|
||||
});
|
||||
});
|
||||
|
||||
function checkWarnings() {
|
||||
const getVal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || !el.value.trim() || el.value === '0') return 0;
|
||||
return parseInt(el.value.replace(/\D/g, '')) || 0;
|
||||
};
|
||||
|
||||
const totalProductos = getVal('total_productos_calc');
|
||||
const totalDeclarado = getVal('total_general');
|
||||
const gastos = getVal('gastos');
|
||||
const efectivo = getVal('venta_efectivo');
|
||||
|
||||
let warnings = [];
|
||||
|
||||
// Comprobar diferencias en totales (solo si se ha ingresado algo para no mostrar el error de entrada)
|
||||
if ((totalProductos > 0 || totalDeclarado > 0) && totalProductos !== totalDeclarado) {
|
||||
warnings.push("El <strong>Total Venta por Productos</strong> no coincide con el <strong>Total Ventas Declaradas</strong>.");
|
||||
}
|
||||
|
||||
// Comprobar si los gastos superan el efectivo
|
||||
if (gastos > efectivo) {
|
||||
warnings.push("El <strong>Monto de Gastos</strong> es mayor que el <strong>Efectivo</strong> declarado.");
|
||||
}
|
||||
|
||||
const warningContainer = document.getElementById('discrepancy_warning');
|
||||
const warningText = document.getElementById('discrepancy_text');
|
||||
|
||||
if (warnings.length > 0) {
|
||||
warningText.innerHTML = warnings.join("<br>");
|
||||
warningContainer.style.display = 'block';
|
||||
} else {
|
||||
warningContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
@@ -361,6 +402,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
document.getElementById('total_digital').value = totalDigital.toLocaleString('es-CL');
|
||||
document.getElementById('total_general').value = totalGeneral.toLocaleString('es-CL');
|
||||
|
||||
checkWarnings();
|
||||
}
|
||||
|
||||
inputsVenta.forEach(input => {
|
||||
|
||||
Reference in New Issue
Block a user