Major Refactor: Refactor the codebase to improve readability and maintainability

This commit is contained in:
2026-06-21 23:38:49 -04:00
parent 9c4753cd1f
commit 801b0b97fc
46 changed files with 2378 additions and 2031 deletions

34
.dockerignore Normal file
View File

@@ -0,0 +1,34 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
.venv/
venv/
env/
.git/
.gitignore
.gitattributes
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
tests/
*.log
.env
.env.*
!.env.example
README.md
LICENSE
build-deploy.sh
generar_unificado.py
docker-compose.yml
Dockerfile
.dockerignore

View File

@@ -1,23 +1,34 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Install dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code
COPY *.py .
COPY templates/ ./templates/
COPY static/ ./static/
#COPY .env .
COPY app.py database.py utils.py ./
COPY routes/ ./routes/
COPY models/ ./models/
COPY services/ ./services/
COPY templates/ ./templates/
COPY static/ ./static/
COPY db/ ./db/
# Create the folder structure for the volume mounts
RUN mkdir -p /app/static/cache
RUN mkdir -p /app/static/cache /app/db
RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
# Run with unbuffered output so you can actually see the logs in Portainer
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:5000/login >/dev/null || exit 1
CMD ["python", "app.py"]
CMD ["python", "app.py"]

22
app.py
View File

@@ -1,12 +1,20 @@
import os
from flask import Flask
from database import init_db
from routes_auth import register_auth_routes
from routes_worker import register_worker_routes
from routes_admin import register_admin_routes
from models.models import db
from routes.auth_bp import auth_bp
from routes.worker_bp import worker_bp
from routes.admin_bp import admin_bp
app = Flask(__name__)
app.secret_key = "super_secret_dev_key"
basedir = os.path.abspath(os.path.dirname(__file__))
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "db", "rendiciones.db")}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
@app.after_request
def add_header(response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -14,10 +22,10 @@ def add_header(response):
response.headers["Expires"] = "0"
return response
register_auth_routes(app)
register_worker_routes(app)
register_admin_routes(app)
app.register_blueprint(auth_bp)
app.register_blueprint(worker_bp)
app.register_blueprint(admin_bp)
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000, debug=True)
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -1,11 +1,24 @@
name: rendiciones
services:
rendiciones:
ports:
- 5500:5000
volumes:
- /home/shironeko/Documents/dockerVols/rendiciones/db:/app/db
- /home/shironeko/Documents/dockerVols/rendiciones/static/cache:/app/static/cache
container_name: rendiciones-server
image: rendiciones:latest
restart: unless-stopped
rendiciones:
build:
context: .
dockerfile: Dockerfile
image: rendiciones:latest
container_name: rendiciones-server
ports:
- "5500:5000"
environment:
- FLASK_ENV=production
- PYTHONUNBUFFERED=1
volumes:
- /home/shironeko/Documents/dockerVols/rendiciones/db:/app/db
- /home/shironeko/Documents/dockerVols/rendiciones/static/cache:/app/static/cache
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:5000/login"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

0
models/__init__.py Normal file
View File

96
models/models.py Normal file
View File

@@ -0,0 +1,96 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
_TABLE_ARGS = {'sqlite_autoincrement': True}
class Zona(db.Model):
__tablename__ = 'zonas'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True, nullable=False)
class Modulo(db.Model):
__tablename__ = 'modulos'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
zona_id = db.Column(db.Integer, db.ForeignKey('zonas.id'), nullable=False)
name = db.Column(db.String, nullable=False)
class Producto(db.Model):
__tablename__ = 'productos'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True, nullable=False)
class PrecioHistorico(db.Model):
__tablename__ = 'precios_historicos'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
producto_id = db.Column(db.Integer, db.ForeignKey('productos.id'), nullable=False)
zona_id = db.Column(db.Integer, db.ForeignKey('zonas.id'), nullable=False)
price = db.Column(db.Integer, nullable=False)
commission = db.Column(db.Integer, nullable=False)
fecha_activacion = db.Column(db.DateTime, nullable=False)
class Worker(db.Model):
__tablename__ = 'workers'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
rut = db.Column(db.String, unique=True, nullable=False)
name = db.Column(db.String, nullable=False)
phone = db.Column(db.String, nullable=False)
password_hash = db.Column(db.String, nullable=False)
is_admin = db.Column(db.Boolean, default=False)
modulo_id = db.Column(db.Integer, db.ForeignKey('modulos.id'))
tipo = db.Column(db.String, default='Full Time')
class Rendicion(db.Model):
__tablename__ = 'rendiciones'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
worker_id = db.Column(db.Integer, db.ForeignKey('workers.id'), nullable=False)
worker_comision = db.Column(db.Boolean, default=True)
companion_id = db.Column(db.Integer, db.ForeignKey('workers.id'))
modulo_id = db.Column(db.Integer, db.ForeignKey('modulos.id'), nullable=False)
fecha = db.Column(db.Date, nullable=False)
hora_entrada = db.Column(db.String, nullable=False)
hora_salida = db.Column(db.String, nullable=False)
companion_hora_entrada = db.Column(db.String)
companion_hora_salida = db.Column(db.String)
companion_comision = db.Column(db.Boolean, default=False)
venta_debito = db.Column(db.Integer, default=0)
venta_credito = db.Column(db.Integer, default=0)
venta_mp = db.Column(db.Integer, default=0)
venta_efectivo = db.Column(db.Integer, default=0)
boletas_debito = db.Column(db.Integer, default=0)
boletas_credito = db.Column(db.Integer, default=0)
boletas_mp = db.Column(db.Integer, default=0)
boletas_efectivo = db.Column(db.Integer, default=0)
gastos = db.Column(db.Integer, default=0)
observaciones = db.Column(db.Text)
class RendicionItem(db.Model):
__tablename__ = 'rendicion_items'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
rendicion_id = db.Column(db.Integer, db.ForeignKey('rendiciones.id'), nullable=False)
producto_id = db.Column(db.Integer, db.ForeignKey('productos.id'), nullable=False)
cantidad = db.Column(db.Integer, nullable=False)
precio_historico = db.Column(db.Integer, nullable=False)
comision_historica = db.Column(db.Integer, nullable=False)

View File

@@ -1,5 +1,4 @@
Flask==3.1.3
Flask-Login==0.6.3
Flask-SocketIO==5.6.1
requests==2.32.5
eventlet==0.36.1
Flask-SQLAlchemy==3.1.1
Werkzeug==3.1.6
SQLAlchemy==2.0.45

0
routes/__init__.py Normal file
View File

635
routes/admin_bp.py Normal file
View File

@@ -0,0 +1,635 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from werkzeug.security import generate_password_hash
from sqlalchemy import func, and_
from sqlalchemy.exc import IntegrityError
from datetime import date, datetime
from models.models import (
db, Zona, Modulo, Producto, PrecioHistorico,
Worker, Rendicion, RendicionItem,
)
from utils import (
admin_required, validate_rut, format_rut, validate_phone,
format_phone, generate_random_password, get_report_params,
)
from services import report_service, rendiciones_service
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# ============================================================
# WORKERS
# ============================================================
@admin_bp.route('/workers', methods=['GET', 'POST'])
@admin_required
def manage_workers():
form_data = {}
if request.method == 'POST':
raw_rut = request.form['rut']
raw_phone = request.form['phone']
name = request.form['name'].strip()
modulo_id = request.form.get('modulo_id')
tipo = request.form.get('tipo', 'Full Time')
form_data = request.form
if not validate_rut(raw_rut):
flash("El RUT ingresado no es válido.", "danger")
elif not validate_phone(raw_phone):
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
elif not modulo_id:
flash("Debes asignar un módulo al trabajador.", "danger")
else:
rut = format_rut(raw_rut)
phone = format_phone(raw_phone)
password = generate_random_password()
p_hash = generate_password_hash(password)
new_worker = Worker(
rut=rut, name=name, phone=phone, password_hash=p_hash,
is_admin=False, modulo_id=int(modulo_id), tipo=tipo,
)
try:
db.session.add(new_worker)
db.session.commit()
flash(f"Trabajador guardado. Contraseña temporal: <strong>{password}</strong>", "success")
return redirect(url_for('admin.manage_workers'))
except IntegrityError:
db.session.rollback()
flash("El RUT ya existe en el sistema.", "danger")
# Build (id, rut, name, phone, modulo_name, modulo_id, tipo) tuples
# to preserve the existing template contract.
workers_rows = (
db.session.query(Worker, Modulo)
.outerjoin(Modulo, Worker.modulo_id == Modulo.id)
.filter(Worker.is_admin == False)
.all()
)
workers = [
(w.id, w.rut, w.name, w.phone, m.name if m else None, w.modulo_id, w.tipo)
for w, m in workers_rows
]
modulos_rows = (
db.session.query(Modulo, Zona)
.join(Zona, Modulo.zona_id == Zona.id)
.order_by(Zona.name, Modulo.name)
.all()
)
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
return render_template('admin_workers.html', workers=workers, form=form_data, modulos=modulos)
@admin_bp.route('/workers/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def edit_worker(id):
if request.method == 'POST':
raw_phone = request.form['phone']
name = request.form['name'].strip()
modulo_id = request.form.get('modulo_id')
tipo = request.form.get('tipo', 'Full Time')
if not validate_phone(raw_phone):
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
return redirect(url_for('admin.edit_worker', id=id))
elif not modulo_id:
flash("Debes seleccionar un módulo.", "danger")
return redirect(url_for('admin.edit_worker', id=id))
worker = db.session.get(Worker, id)
if worker is None:
return redirect(url_for('admin.manage_workers'))
worker.name = name
worker.phone = format_phone(raw_phone)
worker.modulo_id = int(modulo_id)
worker.tipo = tipo
db.session.commit()
flash("Trabajador actualizado exitosamente.", "success")
return redirect(url_for('admin.manage_workers'))
worker = db.session.get(Worker, id)
modulos_rows = (
db.session.query(Modulo, Zona)
.join(Zona, Modulo.zona_id == Zona.id)
.order_by(Zona.name, Modulo.name)
.all()
)
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
if not worker:
return redirect(url_for('admin.manage_workers'))
worker_tuple = (worker.id, worker.rut, worker.name, worker.phone, worker.modulo_id)
return render_template('edit_worker.html', worker=worker_tuple, modulos=modulos)
@admin_bp.route('/workers/delete/<int:id>', methods=['POST'])
@admin_required
def delete_worker(id):
worker = db.session.get(Worker, id)
if worker is not None:
db.session.delete(worker)
db.session.commit()
flash("Trabajador eliminado.", "info")
return redirect(url_for('admin.manage_workers'))
@admin_bp.route('/workers/reset_password/<int:id>', methods=['POST'])
@admin_required
def admin_reset_password(id):
worker = db.session.get(Worker, id)
if worker is None:
return redirect(url_for('admin.manage_workers'))
new_password = generate_random_password()
worker.password_hash = generate_password_hash(new_password)
db.session.commit()
flash(f"Contraseña de {worker.name} restablecida. La nueva contraseña es: <strong>{new_password}</strong>", "warning")
return redirect(url_for('admin.manage_workers'))
# ============================================================
# STRUCTURE (Zonas & Modulos)
# ============================================================
@admin_bp.route('/estructura', methods=['GET', 'POST'])
@admin_required
def manage_structure():
if request.method == 'POST':
action = request.form.get('action')
if action == 'add_zona':
name = request.form.get('zona_name').strip()
try:
db.session.add(Zona(name=name))
db.session.commit()
flash("Zona guardada exitosamente.", "success")
except IntegrityError:
db.session.rollback()
flash("Ese nombre de Zona ya existe.", "danger")
elif action == 'add_modulo':
name = request.form.get('modulo_name').strip()
zona_id = request.form.get('zona_id')
if not zona_id:
flash("Debes seleccionar una Zona válida.", "danger")
else:
db.session.add(Modulo(zona_id=int(zona_id), name=name))
db.session.commit()
flash("Módulo guardado exitosamente.", "success")
return redirect(url_for('admin.manage_structure'))
zonas = [(z.id, z.name) for z in Zona.query.order_by(Zona.name).all()]
modulos_rows = (
db.session.query(Modulo, Zona)
.join(Zona, Modulo.zona_id == Zona.id)
.order_by(Zona.name, Modulo.name)
.all()
)
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
return render_template('admin_structure.html', zonas=zonas, modulos=modulos)
@admin_bp.route('/estructura/delete/<string:type>/<int:id>', methods=['POST'])
@admin_required
def delete_structure(type, id):
try:
if type == 'zona':
count = db.session.query(func.count(Modulo.id)).filter(Modulo.zona_id == id).scalar()
if count:
flash("No puedes eliminar una Zona que tiene Módulos asignados.", "danger")
else:
zona = db.session.get(Zona, id)
if zona is not None:
db.session.delete(zona)
flash("Zona eliminada.", "info")
elif type == 'modulo':
count = db.session.query(func.count(Worker.id)).filter(Worker.modulo_id == id).scalar()
if count:
flash("No puedes eliminar un Módulo que tiene Trabajadores asignados.", "danger")
else:
modulo = db.session.get(Modulo, id)
if modulo is not None:
db.session.delete(modulo)
flash("Módulo eliminado.", "info")
db.session.commit()
except Exception:
db.session.rollback()
flash("Error al eliminar el registro.", "danger")
return redirect(url_for('admin.manage_structure'))
# ============================================================
# PRODUCTS
# ============================================================
@admin_bp.route('/productos', methods=['GET', 'POST'])
@admin_required
def manage_products():
if request.method == 'POST':
name = request.form.get('name').strip()
try:
new_producto = Producto(name=name)
db.session.add(new_producto)
db.session.flush() # Populate new_producto.id
now = datetime.now()
for zona in Zona.query.all():
db.session.add(PrecioHistorico(
producto_id=new_producto.id,
zona_id=zona.id,
price=0,
commission=0,
fecha_activacion=now,
))
db.session.commit()
flash("Producto maestro creado. No olvides configurar sus precios.", "success")
except IntegrityError:
db.session.rollback()
flash("Ese producto ya existe en el catálogo.", "danger")
return redirect(url_for('admin.manage_products'))
zonas = [(z.id, z.name) for z in Zona.query.order_by(Zona.name).all()]
# Find the most-recent vigente price for every (producto, zona) pair
now = datetime.now()
max_fecha_subq = (
db.session.query(
PrecioHistorico.producto_id.label('producto_id'),
PrecioHistorico.zona_id.label('zona_id'),
func.max(PrecioHistorico.fecha_activacion).label('max_fecha'),
)
.filter(PrecioHistorico.fecha_activacion <= now)
.group_by(PrecioHistorico.producto_id, PrecioHistorico.zona_id)
.subquery()
)
current_prices = (
db.session.query(PrecioHistorico)
.join(
max_fecha_subq,
and_(
PrecioHistorico.producto_id == max_fecha_subq.c.producto_id,
PrecioHistorico.zona_id == max_fecha_subq.c.zona_id,
PrecioHistorico.fecha_activacion == max_fecha_subq.c.max_fecha,
),
)
.all()
)
price_map = {
(p.producto_id, p.zona_id): (p.price, p.commission)
for p in current_prices
}
productos = Producto.query.order_by(Producto.name).all()
productos_dict = {}
for p in productos:
productos_dict[p.id] = {'id': p.id, 'name': p.name, 'precios': {}, 'futuros': []}
for z in zonas:
z_id, z_name = z
price, comm = price_map.get((p.id, z_id), (None, None))
productos_dict[p.id]['precios'][z_id] = {
'zona_name': z_name,
'price': price or 0,
'commission': comm or 0,
}
# Scheduled future prices
future_rows = (
db.session.query(PrecioHistorico, Zona)
.join(Zona, PrecioHistorico.zona_id == Zona.id)
.filter(PrecioHistorico.fecha_activacion > now)
.order_by(PrecioHistorico.fecha_activacion.asc())
.all()
)
for ph, z in future_rows:
if ph.producto_id in productos_dict:
productos_dict[ph.producto_id]['futuros'].append({
'id': ph.id,
'zona_name': z.name,
'price': ph.price,
'commission': ph.commission,
'fecha': ph.fecha_activacion,
})
return render_template('admin_productos.html', zonas=zonas, productos=productos_dict.values())
@admin_bp.route('/productos/delete/<int:id>', methods=['POST'])
@admin_required
def delete_product(id):
try:
PrecioHistorico.query.filter_by(producto_id=id).delete()
producto = db.session.get(Producto, id)
if producto is not None:
db.session.delete(producto)
db.session.commit()
flash("Producto maestro y su historial eliminados.", "info")
except IntegrityError:
db.session.rollback()
flash("No puedes eliminar este producto porque ya tiene ventas registradas. Cámbiale el precio a 0 en su lugar.", "danger")
return redirect(url_for('admin.manage_products'))
@admin_bp.route('/productos/precios/<int:id>', methods=['POST'])
@admin_required
def update_product_prices(id):
fecha_input = request.form.get('fecha_activacion')
if fecha_input:
fecha_activacion = datetime.strptime(fecha_input, '%Y-%m-%dT%H:%M').replace(second=0)
else:
fecha_activacion = datetime.now()
for zona in Zona.query.all():
z_id = str(zona.id)
new_price = int(request.form.get(f'price_{z_id}', '0').replace('.', ''))
new_comm = int(request.form.get(f'comm_{z_id}', '0').replace('.', ''))
db.session.add(PrecioHistorico(
producto_id=id,
zona_id=zona.id,
price=new_price,
commission=new_comm,
fecha_activacion=fecha_activacion,
))
db.session.commit()
flash(f"Precios actualizados. Entrarán en vigencia el {fecha_activacion}.", "success")
return redirect(url_for('admin.manage_products'))
@admin_bp.route('/productos/precios/cancelar/<int:id>', methods=['POST'])
@admin_required
def cancel_scheduled_price(id):
ph = db.session.get(PrecioHistorico, id)
if ph is not None:
db.session.delete(ph)
db.session.commit()
flash("Cambio de precio programado cancelado.", "info")
return redirect(url_for('admin.manage_products'))
@admin_bp.route('/api/productos/<int:id>/historial')
@admin_required
def api_product_history(id):
rows = (
db.session.query(Zona.name, PrecioHistorico.price, PrecioHistorico.fecha_activacion)
.join(PrecioHistorico, PrecioHistorico.zona_id == Zona.id)
.filter(PrecioHistorico.producto_id == id)
.order_by(PrecioHistorico.fecha_activacion.asc())
.all()
)
history = []
for zona_name, price, fecha in rows:
if isinstance(fecha, datetime):
fecha_str = fecha.strftime('%Y-%m-%d %H:%M:%S')
else:
fecha_str = str(fecha)
history.append({'zona': zona_name, 'price': price, 'fecha': fecha_str})
return jsonify(history)
# ============================================================
# RENDICIONES
# ============================================================
@admin_bp.route('/rendiciones')
@admin_required
def admin_rendiciones():
mes_seleccionado = request.args.get('mes')
anio_seleccionado = request.args.get('anio')
dia_seleccionado = request.args.get('dia')
zona_id_seleccionada = request.args.get('zona_id')
modulo_id_seleccionado = request.args.get('modulo_id')
if request.args.get('mes') is None:
hoy = date.today()
mes_seleccionado = f"{hoy.month:02d}"
anio_seleccionado = str(hoy.year)
dia_seleccionado = f"{hoy.day:02d}"
mes_seleccionado = mes_seleccionado.zfill(2)
rendiciones_completas = rendiciones_service.get_filtered_rendiciones(
mes_seleccionado, anio_seleccionado, dia_seleccionado,
zona_id_seleccionada, modulo_id_seleccionado,
)
workers, modulos, zonas, anios_disponibles = rendiciones_service.get_filter_catalogs()
dias_disponibles = [f"{d:02d}" for d in range(1, 32)]
return render_template('admin_rendiciones.html',
rendiciones=rendiciones_completas,
workers=workers,
modulos=modulos,
zonas=zonas,
mes_actual=mes_seleccionado,
anio_actual=anio_seleccionado,
dia_actual=dia_seleccionado,
zona_actual=zona_id_seleccionada,
modulo_actual=modulo_id_seleccionado,
anios_disponibles=anios_disponibles,
dias_disponibles=dias_disponibles)
@admin_bp.route('/rendiciones/delete/<int:id>', methods=['POST'])
@admin_required
def delete_rendicion(id):
RendicionItem.query.filter_by(rendicion_id=id).delete()
rendicion = db.session.get(Rendicion, id)
if rendicion is not None:
db.session.delete(rendicion)
db.session.commit()
flash("Rendición eliminada.", "info")
return redirect(url_for('admin.admin_rendiciones'))
@admin_bp.route('/rendiciones/edit/<int:id>', methods=['POST'])
@admin_required
def edit_rendicion(id):
fecha = request.form.get('fecha')
worker_id = request.form.get('worker_id')
modulo_id = request.form.get('modulo_id')
companion_id = request.form.get('companion_id') or None
if companion_id and worker_id == companion_id:
flash("Error: No puedes asignarte a ti mismo como acompañante.", "danger")
return redirect(url_for('admin.admin_rendiciones'))
worker_comision = 1 if request.form.get('worker_comision') else 0
companion_comision = 1 if request.form.get('companion_comision') else 0
def clean_money(val):
if not val:
return 0
return int(str(val).replace('.', '').replace('$', ''))
try:
debito = clean_money(request.form.get('venta_debito'))
credito = clean_money(request.form.get('venta_credito'))
mp = clean_money(request.form.get('venta_mp'))
efectivo = clean_money(request.form.get('venta_efectivo'))
bol_debito = int(request.form.get('boletas_debito') or 0)
bol_credito = int(request.form.get('boletas_credito') or 0)
bol_mp = int(request.form.get('boletas_mp') or 0)
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
gastos = clean_money(request.form.get('gastos'))
observaciones = request.form.get('observaciones', '').strip()
# 1. Update product quantities (rendicion_items)
for key, value in request.form.items():
if key.startswith('qty_'):
ri_id = int(key.split('_')[1])
nueva_qty = int(value or 0)
item = db.session.get(RendicionItem, ri_id)
if item is not None:
item.cantidad = nueva_qty
# 2. Update the main rendicion row
rendicion = db.session.get(Rendicion, id)
if rendicion is not None:
rendicion.fecha = datetime.strptime(fecha, '%Y-%m-%d').date()
rendicion.worker_id = int(worker_id)
rendicion.modulo_id = int(modulo_id)
rendicion.companion_id = int(companion_id) if companion_id else None
rendicion.worker_comision = bool(worker_comision)
rendicion.companion_comision = bool(companion_comision)
rendicion.venta_debito = debito
rendicion.venta_credito = credito
rendicion.venta_mp = mp
rendicion.venta_efectivo = efectivo
rendicion.boletas_debito = bol_debito
rendicion.boletas_credito = bol_credito
rendicion.boletas_mp = bol_mp
rendicion.boletas_efectivo = bol_efectivo
rendicion.gastos = gastos
rendicion.observaciones = observaciones
db.session.commit()
flash("Rendición y productos actualizados correctamente.", "success")
except Exception as e:
db.session.rollback()
flash(f"Error al actualizar: {str(e)}", "danger")
return redirect(url_for('admin.admin_rendiciones'))
# ============================================================
# REPORTS
# ============================================================
@admin_bp.route('/reportes')
@admin_required
def admin_reportes_index():
modulos_rows = (
db.session.query(Modulo, Zona)
.join(Zona, Modulo.zona_id == Zona.id)
.order_by(Zona.name, Modulo.name)
.all()
)
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
return render_template('admin_reportes_index.html', modulos=modulos)
@admin_bp.route('/reportes/modulo/<int:modulo_id>')
@admin_required
def report_modulo_periodo(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
data = report_service.get_modulo_periodo_data(modulo_id, anio, mes, dia_f, worker_id)
return render_template('admin_report_modulo.html',
modulo_name=mod_name, modulo_id=modulo_id,
mes_nombre=f"{mes}/{anio}",
dias_en_periodo=data['dias_en_periodo'],
data_por_dia=data['data_por_dia'],
totales_mes=data['totales_mes'],
dias_activos=data['dias_activos'],
workers_list=workers_list, worker_actual=worker_id,
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
anios_disponibles=anios_list)
@admin_bp.route('/reportes/modulo/<int:modulo_id>/comisiones')
@admin_required
def report_modulo_comisiones(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
data = report_service.get_comisiones_data(modulo_id, anio, mes, dia_f, worker_id)
return render_template('admin_report_comisiones.html',
modulo_name=mod_name, modulo_id=modulo_id,
mes_nombre=f"{mes}/{anio}",
workers_data=data['workers_data'],
dias_en_periodo=data['dias_en_periodo'],
workers_list=workers_list, worker_actual=worker_id,
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
anios_disponibles=anios_list)
@admin_bp.route('/reportes/modulo/<int:modulo_id>/horarios')
@admin_required
def report_modulo_horarios(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
data = report_service.get_horarios_data(modulo_id, anio, mes, dia_f, worker_id)
return render_template('admin_report_horarios.html',
modulo_name=mod_name, modulo_id=modulo_id,
mes_nombre=f"{mes}/{anio}",
workers_data=data['workers_data'],
dias_en_periodo=data['dias_en_periodo'],
workers_list=workers_list, worker_actual=worker_id,
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
anios_disponibles=anios_list)
@admin_bp.route('/reportes/modulo/<int:modulo_id>/centros_comerciales')
@admin_required
def report_modulo_centros_comerciales(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
data = report_service.get_cc_data(modulo_id, anio, mes, dia_f, worker_id)
return render_template('admin_report_cc.html',
modulo_name=mod_name, modulo_id=modulo_id,
mes_nombre=f"{mes}/{anio}",
dias_en_periodo=data['dias_en_periodo'],
data_por_dia=data['data_por_dia'],
totales=data['totales'],
workers_list=workers_list, worker_actual=worker_id,
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
anios_disponibles=anios_list)
@admin_bp.route('/reportes/modulo/<int:modulo_id>/calculo_iva')
@admin_required
def report_modulo_calculo_iva(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
data = report_service.get_iva_data(modulo_id, anio, mes, dia_f, worker_id)
return render_template('admin_report_iva.html',
modulo_name=mod_name, modulo_id=modulo_id,
mes_nombre=f"{mes}/{anio}",
dias_en_periodo=data['dias_en_periodo'],
data_por_dia=data['data_por_dia'],
totales=data['totales'],
workers_list=workers_list, worker_actual=worker_id,
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
anios_disponibles=anios_list)

44
routes/auth_bp.py Normal file
View File

@@ -0,0 +1,44 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from werkzeug.security import check_password_hash
from database import get_db_connection
from utils import validate_rut, format_rut
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/', methods=['GET', 'POST'])
def index():
if 'user_id' in session:
if session.get('is_admin'):
return redirect(url_for('admin.admin_rendiciones'))
return redirect(url_for('worker.worker_dashboard'))
if request.method == 'POST':
raw_rut = request.form['rut']
password = request.form['password']
rut = format_rut(raw_rut) if validate_rut(raw_rut) else raw_rut
conn = get_db_connection()
c = conn.cursor()
c.execute("SELECT id, password_hash, is_admin FROM workers WHERE rut = ?", (rut,))
user = c.fetchone()
conn.close()
if user and check_password_hash(user[1], password):
session['user_id'] = user[0]
session['is_admin'] = user[2]
session['rut'] = rut
if user[2]:
return redirect(url_for('admin.admin_rendiciones'))
else:
return redirect(url_for('worker.worker_dashboard'))
else:
flash("RUT o contraseña incorrectos.", "danger")
return render_template('login.html')
@auth_bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('auth.index'))

189
routes/worker_bp.py Normal file
View File

@@ -0,0 +1,189 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from datetime import date
from database import get_db_connection
from utils import login_required
worker_bp = Blueprint('worker', __name__)
@worker_bp.route('/dashboard')
@login_required
def worker_dashboard():
conn = get_db_connection()
c = conn.cursor()
user_id = session['user_id']
c.execute('''
SELECT r.id, r.fecha, w.name, m.name,
r.venta_debito, r.venta_credito, r.venta_mp, r.venta_efectivo, r.gastos, r.observaciones,
c_w.name, r.worker_id, r.companion_id, r.modulo_id,
r.worker_comision, r.companion_comision,
r.boletas_debito, r.boletas_credito, r.boletas_mp, r.boletas_efectivo
FROM rendiciones r
JOIN workers w ON r.worker_id = w.id
JOIN modulos m ON r.modulo_id = m.id
LEFT JOIN workers c_w ON r.companion_id = c_w.id
WHERE r.worker_id = ? OR r.companion_id = ?
ORDER BY r.fecha DESC, r.id DESC
''', (user_id, user_id))
rendiciones_basicas = c.fetchall()
rendiciones_completas = []
for r in rendiciones_basicas:
c.execute('''
SELECT p.name, ri.cantidad, ri.precio_historico, ri.comision_historica,
(ri.cantidad * ri.precio_historico) as total_linea,
(ri.cantidad * ri.comision_historica) as total_comision
FROM rendicion_items ri
JOIN productos p ON ri.producto_id = p.id
WHERE ri.rendicion_id = ?
''', (r[0],))
items = c.fetchall()
total_calculado = sum(item[4] for item in items)
comision_total = sum(item[5] for item in items)
rol = "Titular" if r[11] == user_id else "Acompañante"
r_completa = r + (items, total_calculado, comision_total, rol)
rendiciones_completas.append(r_completa)
conn.close()
return render_template('worker_history.html', rendiciones=rendiciones_completas)
@worker_bp.route('/rendicion/nueva', methods=['GET', 'POST'])
@login_required
def new_rendicion():
conn = get_db_connection()
c = conn.cursor()
c.execute('''SELECT w.modulo_id, m.name, z.id, z.name
FROM workers w
JOIN modulos m ON w.modulo_id = m.id
JOIN zonas z ON m.zona_id = z.id
WHERE w.id = ?''', (session['user_id'],))
worker_info = c.fetchone()
if not worker_info:
conn.close()
return "Error: No tienes un módulo asignado. Contacta al administrador."
modulo_id, modulo_name, zona_id, zona_name = worker_info
if request.method == 'POST':
fecha = request.form.get('fecha')
hora_entrada = request.form.get('hora_entrada')
hora_salida = request.form.get('hora_salida')
companion_hora_entrada = request.form.get('companion_hora_entrada')
companion_hora_salida = request.form.get('companion_hora_salida')
def clean_and_validate(val):
if val is None or val.strip() == "":
return 0
try:
return int(val.replace('.', ''))
except ValueError:
return 0
debito = clean_and_validate(request.form.get('venta_debito'))
credito = clean_and_validate(request.form.get('venta_credito'))
mp = clean_and_validate(request.form.get('venta_mp'))
efectivo = clean_and_validate(request.form.get('venta_efectivo'))
bol_debito = int(request.form.get('boletas_debito') or 0)
bol_credito = int(request.form.get('boletas_credito') or 0)
bol_mp = int(request.form.get('boletas_mp') or 0)
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
gastos = clean_and_validate(request.form.get('gastos')) or 0
obs = request.form.get('observaciones', '').strip()
companion_id = request.form.get('companion_id')
if companion_id == "":
companion_id = None
companion_hora_entrada = None
companion_hora_salida = None
if debito is None or credito is None or mp is None or efectivo is None or not fecha or not hora_entrada or not hora_salida:
flash("Error: Todos los campos obligatorios deben estar rellenos.", "danger")
return redirect(url_for('worker.new_rendicion'))
c.execute("SELECT tipo FROM workers WHERE id = ?", (session['user_id'],))
worker_tipo = c.fetchone()[0]
worker_comision = 1 if worker_tipo == 'Full Time' else 0
companion_comision = 0
if companion_id:
c.execute("SELECT tipo FROM workers WHERE id = ?", (companion_id,))
comp_tipo = c.fetchone()
if comp_tipo and comp_tipo[0] == 'Full Time':
companion_comision = 1
total_digital = debito + credito + mp
total_ventas_general = total_digital + efectivo
c.execute('''INSERT INTO rendiciones
(worker_id, companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
venta_debito, venta_credito, venta_mp, venta_efectivo, boletas_debito, boletas_credito, boletas_mp, boletas_efectivo, gastos, observaciones, worker_comision, companion_comision)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(session['user_id'], companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
debito, credito, mp, efectivo, bol_debito, bol_credito, bol_mp, bol_efectivo, gastos, obs, worker_comision, companion_comision))
rendicion_id = c.lastrowid
for key, value in request.form.items():
if key.startswith('qty_') and value and int(value) > 0:
prod_id = int(key.split('_')[1])
cantidad = int(value)
# 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:
c.execute('''INSERT INTO rendicion_items
(rendicion_id, producto_id, cantidad, precio_historico, comision_historica)
VALUES (?, ?, ?, ?, ?)''',
(rendicion_id, prod_id, cantidad, prod_data[0], prod_data[1]))
conn.commit()
flash(f"Rendición enviada exitosamente. Total General Declarado: ${total_ventas_general:,}".replace(',', '.'), "success")
return redirect(url_for('worker.worker_dashboard'))
c.execute('''
SELECT id, name FROM workers
WHERE id != ? AND modulo_id = ? AND is_admin = 0
ORDER BY name
''', (session['user_id'], modulo_id))
otros_trabajadores = c.fetchall()
# 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()
has_commission = any(prod[3] > 0 for prod in productos)
return render_template('worker_dashboard.html',
modulo_name=modulo_name,
zona_name=zona_name,
productos=productos,
has_commission=has_commission,
otros_trabajadores=otros_trabajadores,
today=date.today().strftime('%Y-%m-%d'))

View File

@@ -1,731 +0,0 @@
import sqlite3
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
from utils import admin_required, validate_rut, format_rut, validate_phone, format_phone, generate_random_password, get_report_params, get_common_report_data
import calendar
def register_admin_routes(app):
@app.route('/admin/workers', methods=['GET', 'POST'])
@admin_required
def manage_workers():
conn = get_db_connection()
c = conn.cursor()
form_data = {}
if request.method == 'POST':
raw_rut = request.form['rut']
raw_phone = request.form['phone']
name = request.form['name'].strip()
modulo_id = request.form.get('modulo_id')
tipo = request.form.get('tipo', 'Full Time')
form_data = request.form
if not validate_rut(raw_rut):
flash("El RUT ingresado no es válido.", "danger")
elif not validate_phone(raw_phone):
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
elif not modulo_id:
flash("Debes asignar un módulo al trabajador.", "danger")
else:
rut = format_rut(raw_rut)
phone = format_phone(raw_phone)
password = generate_random_password()
p_hash = generate_password_hash(password)
try:
c.execute("INSERT INTO workers (rut, name, phone, password_hash, is_admin, modulo_id, tipo) VALUES (?, ?, ?, ?, 0, ?, ?)",
(rut, name, phone, p_hash, modulo_id, tipo))
conn.commit()
flash(f"Trabajador guardado. Contraseña temporal: <strong>{password}</strong>", "success")
return redirect(url_for('manage_workers'))
except sqlite3.IntegrityError:
flash("El RUT ya existe en el sistema.", "danger")
c.execute('''SELECT w.id, w.rut, w.name, w.phone, m.name, w.modulo_id, w.tipo
FROM workers w
LEFT JOIN modulos m ON w.modulo_id = m.id
WHERE w.is_admin = 0''')
workers = c.fetchall()
c.execute('''SELECT m.id, m.name, z.name
FROM modulos m
JOIN zonas z ON m.zona_id = z.id
ORDER BY z.name, m.name''')
modulos = c.fetchall()
conn.close()
return render_template('admin_workers.html', workers=workers, form=form_data, modulos=modulos)
@app.route('/admin/workers/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def edit_worker(id):
conn = get_db_connection()
c = conn.cursor()
if request.method == 'POST':
raw_phone = request.form['phone']
name = request.form['name'].strip()
modulo_id = request.form.get('modulo_id')
tipo = request.form.get('tipo', 'Full Time')
if not validate_phone(raw_phone):
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
return redirect(url_for('edit_worker', id=id))
elif not modulo_id:
flash("Debes seleccionar un módulo.", "danger")
return redirect(url_for('edit_worker', id=id))
c.execute("UPDATE workers SET name=?, phone=?, modulo_id=?, tipo=? WHERE id=?",
(name, format_phone(raw_phone), modulo_id, tipo, id))
conn.commit()
flash("Trabajador actualizado exitosamente.", "success")
conn.close()
return redirect(url_for('manage_workers'))
c.execute("SELECT id, rut, name, phone, modulo_id FROM workers WHERE id=?", (id,))
worker = c.fetchone()
c.execute('''SELECT m.id, m.name, z.name
FROM modulos m
JOIN zonas z ON m.zona_id = z.id
ORDER BY z.name, m.name''')
modulos = c.fetchall()
conn.close()
if not worker: return redirect(url_for('manage_workers'))
return render_template('edit_worker.html', worker=worker, modulos=modulos)
@app.route('/admin/workers/delete/<int:id>', methods=['POST'])
@admin_required
def delete_worker(id):
conn = get_db_connection()
c = conn.cursor()
c.execute("DELETE FROM workers WHERE id=?", (id,))
conn.commit()
conn.close()
flash("Trabajador eliminado.", "info")
return redirect(url_for('manage_workers'))
@app.route('/admin/workers/reset_password/<int:id>', methods=['POST'])
@admin_required
def admin_reset_password(id):
conn = get_db_connection()
c = conn.cursor()
c.execute("SELECT name FROM workers WHERE id=?", (id,))
worker = c.fetchone()
if not worker:
conn.close()
return redirect(url_for('manage_workers'))
new_password = generate_random_password()
p_hash = generate_password_hash(new_password)
c.execute("UPDATE workers SET password_hash=? WHERE id=?", (p_hash, id))
conn.commit()
conn.close()
flash(f"Contraseña de {worker[0]} restablecida. La nueva contraseña es: <strong>{new_password}</strong>", "warning")
return redirect(url_for('manage_workers'))
@app.route('/admin/estructura', methods=['GET', 'POST'])
@admin_required
def manage_structure():
conn = get_db_connection()
c = conn.cursor()
if request.method == 'POST':
action = request.form.get('action')
if action == 'add_zona':
name = request.form.get('zona_name').strip()
try:
c.execute("INSERT INTO zonas (name) VALUES (?)", (name,))
conn.commit()
flash("Zona guardada exitosamente.", "success")
except sqlite3.IntegrityError:
flash("Ese nombre de Zona ya existe.", "danger")
elif action == 'add_modulo':
name = request.form.get('modulo_name').strip()
zona_id = request.form.get('zona_id')
if not zona_id:
flash("Debes seleccionar una Zona válida.", "danger")
else:
c.execute("INSERT INTO modulos (zona_id, name) VALUES (?, ?)", (zona_id, name))
conn.commit()
flash("Módulo guardado exitosamente.", "success")
return redirect(url_for('manage_structure'))
c.execute("SELECT id, name FROM zonas ORDER BY name")
zonas = c.fetchall()
c.execute('''SELECT m.id, m.name, z.name
FROM modulos m
JOIN zonas z ON m.zona_id = z.id
ORDER BY z.name, m.name''')
modulos = c.fetchall()
conn.close()
return render_template('admin_structure.html', zonas=zonas, modulos=modulos)
@app.route('/admin/estructura/delete/<string:type>/<int:id>', methods=['POST'])
@admin_required
def delete_structure(type, id):
conn = get_db_connection()
c = conn.cursor()
try:
if type == 'zona':
c.execute("SELECT id FROM modulos WHERE zona_id=?", (id,))
if c.fetchone():
flash("No puedes eliminar una Zona que tiene Módulos asignados.", "danger")
else:
c.execute("DELETE FROM zonas WHERE id=?", (id,))
flash("Zona eliminada.", "info")
elif type == 'modulo':
c.execute("SELECT id FROM workers WHERE modulo_id=?", (id,))
if c.fetchone():
flash("No puedes eliminar un Módulo que tiene Trabajadores asignados.", "danger")
else:
c.execute("DELETE FROM modulos WHERE id=?", (id,))
flash("Módulo eliminado.", "info")
conn.commit()
except Exception as e:
flash("Error al eliminar el registro.", "danger")
finally:
conn.close()
return redirect(url_for('manage_structure'))
@app.route('/admin/productos', methods=['GET', 'POST'])
@admin_required
def manage_products():
conn = get_db_connection()
c = conn.cursor()
if request.method == 'POST':
name = request.form.get('name').strip()
try:
# 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]))
conn.commit()
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()
# 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())
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()
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(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
def admin_rendiciones():
# Capturamos todos los filtros
mes_seleccionado = request.args.get('mes')
anio_seleccionado = request.args.get('anio')
dia_seleccionado = request.args.get('dia')
zona_id_seleccionada = request.args.get('zona_id')
modulo_id_seleccionado = request.args.get('modulo_id')
# Si no viene la variable 'mes' en la URL, significa que es la primera vez que entramos
if request.args.get('mes') is None:
hoy = date.today()
mes_seleccionado = f"{hoy.month:02d}"
anio_seleccionado = str(hoy.year)
dia_seleccionado = f"{hoy.day:02d}" # <-- Forzamos el día actual
mes_seleccionado = mes_seleccionado.zfill(2)
conn = get_db_connection()
c = conn.cursor()
# Construimos la consulta base tipo Lego
query = '''
SELECT r.id, r.fecha, w.name, m.name,
r.venta_debito, r.venta_credito, r.venta_mp, r.venta_efectivo, r.gastos, r.observaciones,
c_w.name, r.worker_id, r.companion_id, r.modulo_id,
r.worker_comision, r.companion_comision,
r.boletas_debito, r.boletas_credito, r.boletas_mp, r.boletas_efectivo
FROM rendiciones r
JOIN workers w ON r.worker_id = w.id
JOIN modulos m ON r.modulo_id = m.id
LEFT JOIN workers c_w ON r.companion_id = c_w.id
WHERE strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?
'''
params = [mes_seleccionado, anio_seleccionado]
# Añadimos las piezas extra si el usuario las seleccionó
if dia_seleccionado:
query += " AND strftime('%d', r.fecha) = ?"
params.append(dia_seleccionado.zfill(2))
if zona_id_seleccionada:
query += " AND m.zona_id = ?"
params.append(zona_id_seleccionada)
if modulo_id_seleccionado:
query += " AND r.modulo_id = ?"
params.append(modulo_id_seleccionado)
query += " ORDER BY r.fecha DESC, r.id DESC"
c.execute(query, tuple(params))
rendiciones_basicas = c.fetchall()
rendiciones_completas = []
for r in rendiciones_basicas:
c.execute('''
SELECT p.name, ri.cantidad, ri.precio_historico, ri.comision_historica,
(ri.cantidad * ri.precio_historico) as total_linea,
(ri.cantidad * ri.comision_historica) as total_comision,
ri.id
FROM rendicion_items ri
JOIN productos p ON ri.producto_id = p.id
WHERE ri.rendicion_id = ?
''', (r[0],))
items = c.fetchall()
total_calculado = sum(item[4] for item in items)
comision_total = sum(item[5] for item in items)
r_completa = r + (items, total_calculado, comision_total)
rendiciones_completas.append(r_completa)
# Cargar catálogos para los selects
c.execute("SELECT id, name, tipo, modulo_id FROM workers WHERE is_admin = 0 ORDER BY name")
workers = c.fetchall()
# Ahora traemos el zona_id para poder filtrar los módulos por zona en el frontend
c.execute("SELECT id, name, zona_id FROM modulos ORDER BY name")
modulos = c.fetchall()
c.execute("SELECT id, name FROM zonas ORDER BY name")
zonas = c.fetchall()
c.execute("SELECT DISTINCT strftime('%Y', fecha) FROM rendiciones ORDER BY 1 DESC")
anios_db = c.fetchall()
anios_disponibles = [row[0] for row in anios_db] if anios_db else [str(date.today().year)]
if str(date.today().year) not in anios_disponibles:
anios_disponibles.insert(0, str(date.today().year))
conn.close()
dias_disponibles = [f"{d:02d}" for d in range(1, 32)]
return render_template('admin_rendiciones.html',
rendiciones=rendiciones_completas,
workers=workers,
modulos=modulos,
zonas=zonas,
mes_actual=mes_seleccionado,
anio_actual=anio_seleccionado,
dia_actual=dia_seleccionado,
zona_actual=zona_id_seleccionada,
modulo_actual=modulo_id_seleccionado,
anios_disponibles=anios_disponibles,
dias_disponibles=dias_disponibles)
@app.route('/admin/rendiciones/delete/<int:id>', methods=['POST'])
@admin_required
def delete_rendicion(id):
conn = get_db_connection()
c = conn.cursor()
c.execute("DELETE FROM rendicion_items WHERE rendicion_id=?", (id,))
c.execute("DELETE FROM rendiciones WHERE id=?", (id,))
conn.commit()
conn.close()
flash("Rendición eliminada.", "info")
return redirect(url_for('admin_rendiciones'))
@app.route('/admin/rendiciones/edit/<int:id>', methods=['POST'])
@admin_required
def edit_rendicion(id):
conn = get_db_connection()
c = conn.cursor()
# Obtener datos básicos
fecha = request.form.get('fecha')
worker_id = request.form.get('worker_id')
modulo_id = request.form.get('modulo_id') # Asegúrate de tener el input hidden en el HTML
companion_id = request.form.get('companion_id') or None
if companion_id and worker_id == companion_id:
flash("Error: No puedes asignarte a ti mismo como acompañante.", "danger")
return redirect(url_for('admin_rendiciones'))
worker_comision = 1 if request.form.get('worker_comision') else 0
companion_comision = 1 if request.form.get('companion_comision') else 0
# Limpiador de dinero para manejar los puntos de miles
def clean_money(val):
if not val: return 0
return int(str(val).replace('.', '').replace('$', ''))
try:
debito = clean_money(request.form.get('venta_debito'))
credito = clean_money(request.form.get('venta_credito'))
mp = clean_money(request.form.get('venta_mp'))
efectivo = clean_money(request.form.get('venta_efectivo'))
bol_debito = int(request.form.get('boletas_debito') or 0)
bol_credito = int(request.form.get('boletas_credito') or 0)
bol_mp = int(request.form.get('boletas_mp') or 0)
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
gastos = clean_money(request.form.get('gastos'))
observaciones = request.form.get('observaciones', '').strip()
# 1. Actualizar cantidades de productos
# Recorremos el formulario buscando las cantidades editadas
for key, value in request.form.items():
if key.startswith('qty_'):
# En el modal el name es 'qty_{{ item[6] }}' donde item[6] es el ID de rendicion_items
ri_id = key.split('_')[1]
nueva_qty = int(value or 0)
# IMPORTANTE: Usamos 'precio_historico' que es el nombre real en tu DB
c.execute('''UPDATE rendicion_items
SET cantidad = ?
WHERE id = ?''', (nueva_qty, ri_id))
# 2. Actualizar la rendición principal
c.execute('''
UPDATE rendiciones
SET fecha=?, worker_id=?, modulo_id=?, companion_id=?,
venta_debito=?, venta_credito=?, venta_mp=?, venta_efectivo=?,
boletas_debito=?, boletas_credito=?, boletas_mp=?, boletas_efectivo=?,
gastos=?, observaciones=?, worker_comision=?, companion_comision=?
WHERE id=?
''', (fecha, worker_id, modulo_id, companion_id,
debito, credito, mp, efectivo,
bol_debito, bol_credito, bol_mp, bol_efectivo,
gastos, observaciones, worker_comision, companion_comision, id))
conn.commit()
flash("Rendición y productos actualizados correctamente.", "success")
except Exception as e:
conn.rollback()
flash(f"Error al actualizar: {str(e)}", "danger")
finally:
conn.close()
return redirect(url_for('admin_rendiciones'))
@app.route('/admin/reportes')
@admin_required
def admin_reportes_index():
conn = get_db_connection()
c = conn.cursor()
c.execute('''
SELECT m.id, m.name, z.name
FROM modulos m
JOIN zonas z ON m.zona_id = z.id
ORDER BY z.name, m.name
''')
modulos = c.fetchall()
conn.close()
return render_template('admin_reportes_index.html', modulos=modulos)
@app.route('/admin/reportes/modulo/<int:modulo_id>')
@admin_required
def report_modulo_periodo(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
conn = get_db_connection()
c = conn.cursor()
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
params = [modulo_id, mes, anio]
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
if worker_id: where_clause += " AND r.worker_id = ?"; params.append(worker_id)
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(r.venta_debito), SUM(r.venta_credito), SUM(r.venta_mp), SUM(r.venta_efectivo), SUM(r.gastos) FROM rendiciones r {where_clause} GROUP BY dia", tuple(params))
finanzas = c.fetchall()
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(ri.cantidad * ri.comision_historica) FROM rendicion_items ri JOIN rendiciones r ON ri.rendicion_id = r.id {where_clause} AND (r.worker_comision = 1 OR r.companion_comision = 1) GROUP BY dia", tuple(params))
comisiones = {row[0]: row[1] for row in c.fetchall()}
_, num_dias = calendar.monthrange(int(anio), int(mes))
dias_en_periodo = [f'{d:02d}' for d in range(1, num_dias + 1)]
data_por_dia = {d: {'debito':0,'credito':0,'mp':0,'efectivo':0,'gastos':0,'comision':0,'venta_total':0} for d in dias_en_periodo}
for r in finanzas:
d = r[0]
vt = sum(filter(None, r[1:5]))
data_por_dia[d].update({'debito':r[1] or 0,'credito':r[2] or 0,'mp':r[3] or 0,'efectivo':r[4] or 0,'gastos':r[5] or 0,'venta_total':vt,'comision':comisiones.get(d, 0)})
totales_mes = {k: sum(d[k] for d in data_por_dia.values()) for k in data_por_dia['01'].keys()}
dias_activos = sum(1 for d in data_por_dia.values() if d['venta_total'] > 0)
conn.close()
return render_template('admin_report_modulo.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", dias_en_periodo=dias_en_periodo, data_por_dia=data_por_dia, totales_mes=totales_mes, dias_activos=dias_activos, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
@app.route('/admin/reportes/modulo/<int:modulo_id>/comisiones')
@admin_required
def report_modulo_comisiones(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
conn = get_db_connection()
c = conn.cursor()
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
params = [modulo_id, mes, anio]
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
if worker_id: where_clause += " AND (r.worker_id = ? OR r.companion_id = ?)"; params.extend([worker_id, worker_id])
c.execute(f"SELECT r.id, strftime('%d', r.fecha) as dia, w.id, w.name, w.tipo, r.worker_comision, cw.id, cw.name, cw.tipo, r.companion_comision, (SELECT SUM(cantidad * comision_historica) FROM rendicion_items WHERE rendicion_id = r.id) FROM rendiciones r JOIN workers w ON r.worker_id = w.id LEFT JOIN workers cw ON r.companion_id = cw.id {where_clause}", tuple(params))
rendiciones = c.fetchall()
workers_data = {}
for r in rendiciones:
total_com = r[10] or 0
# Lógica simplificada: reparte si ambos comisionan
for idx, wid, wname, wtipo, wcom in [(0, r[2], r[3], r[4], r[5]), (1, r[6], r[7], r[8], r[9])]:
if wid and wcom:
if wid not in workers_data: workers_data[wid] = {'name':wname,'tipo':wtipo,'dias':{},'total':0,'enabled':True}
val = total_com / 2 if (r[5] and r[9]) else total_com
workers_data[wid]['dias'][r[1]] = workers_data[wid]['dias'].get(r[1], 0) + val
workers_data[wid]['total'] += val
_, num_dias = calendar.monthrange(int(anio), int(mes))
dias_en_periodo = [f'{d:02d}' for d in range(1, num_dias + 1)]
conn.close()
return render_template('admin_report_comisiones.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", workers_data=dict(sorted(workers_data.items(), key=lambda x:x[1]['name'])), dias_en_periodo=dias_en_periodo, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
@app.route('/admin/reportes/modulo/<int:modulo_id>/horarios')
@admin_required
def report_modulo_horarios(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
conn = get_db_connection()
c = conn.cursor()
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
params = [modulo_id, mes, anio]
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
if worker_id: where_clause += " AND (r.worker_id = ? OR r.companion_id = ?)"; params.extend([worker_id, worker_id])
c.execute(f"SELECT r.fecha, w.id, w.name, r.hora_entrada, r.hora_salida, cw.id, cw.name, r.companion_hora_entrada, r.companion_hora_salida FROM rendiciones r JOIN workers w ON r.worker_id = w.id LEFT JOIN workers cw ON r.companion_id = cw.id {where_clause}", tuple(params))
rendiciones = c.fetchall()
workers_data = {}
def calc_h(i, o):
if not i or not o: return 0, "0:00"
try:
t1, t2 = datetime.strptime(i, '%H:%M'), datetime.strptime(o, '%H:%M')
d = t2 - t1
return d.seconds/3600, f"{d.seconds//3600}:{(d.seconds%3600)//60:02d}"
except: return 0, "0:00"
for r in rendiciones:
d = r[0][-2:]
for wid, wname, win, wout in [(r[1], r[2], r[3], r[4]), (r[5], r[6], r[7], r[8])]:
if wid:
if wid not in workers_data: workers_data[wid] = {'name':wname,'dias':{},'total_horas':0}
h_dec, h_str = calc_h(win, wout)
workers_data[wid]['dias'][d] = {'in':win,'out':wout,'hrs':h_str}
workers_data[wid]['total_horas'] += h_dec
for w in workers_data.values():
th = w['total_horas']
w['total_hrs_str'] = f"{int(th)}:{int((th-int(th))*60):02d}"
_, num_dias = calendar.monthrange(int(anio), int(mes))
dias_en_periodo = [{'num':f'{d:02d}','name':['L','M','M','J','V','S','D'][date(int(anio),int(mes),d).weekday()]} for d in range(1, num_dias+1)]
conn.close()
return render_template('admin_report_horarios.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", workers_data=workers_data, dias_en_periodo=dias_en_periodo, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
@app.route('/admin/reportes/modulo/<int:modulo_id>/centros_comerciales')
@admin_required
def report_modulo_centros_comerciales(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
conn = get_db_connection()
c = conn.cursor()
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
params = [modulo_id, mes, anio]
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
if worker_id: where_clause += " AND r.worker_id = ?"; params.append(worker_id)
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(r.boletas_debito + r.boletas_credito + r.boletas_mp), SUM(r.boletas_efectivo), SUM(r.venta_debito + r.venta_credito + r.venta_mp + r.venta_efectivo) FROM rendiciones r {where_clause} GROUP BY dia", tuple(params))
resultados = c.fetchall()
_, num_dias = calendar.monthrange(int(anio), int(mes))
data_por_dia = {f'{d:02d}': {'red_compra':0,'efectivo':0,'total_trans':0,'venta_neta':0} for d in range(1, num_dias+1)}
totales = {'red_compra':0,'efectivo':0,'total_trans':0,'venta_neta':0}
for r in resultados:
dia, rc, ef, vt = r[0], r[1] or 0, r[2] or 0, r[3] or 0
vn = round(vt/1.19)
data_por_dia[dia] = {'red_compra':rc,'efectivo':ef,'total_trans':rc+ef,'venta_neta':vn}
for k in totales: totales[k] += data_por_dia[dia][k]
dias_en_periodo = [{'num':f'{d:02d}','name':['L','M','M','J','V','S','D'][date(int(anio),int(mes),d).weekday()]} for d in range(1, num_dias+1)]
conn.close()
return render_template('admin_report_cc.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", dias_en_periodo=dias_en_periodo, data_por_dia=data_por_dia, totales=totales, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
@app.route('/admin/reportes/modulo/<int:modulo_id>/calculo_iva')
@admin_required
def report_modulo_calculo_iva(modulo_id):
anio, mes, dia_f, worker_id = get_report_params()
conn = get_db_connection()
c = conn.cursor()
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
params = [modulo_id, mes, anio]
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
if worker_id: where_clause += " AND r.worker_id = ?"; params.append(worker_id)
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(r.venta_efectivo), SUM(r.venta_debito + r.venta_credito + r.venta_mp) FROM rendiciones r {where_clause} GROUP BY dia", tuple(params))
resultados = c.fetchall()
_, num_dias = calendar.monthrange(int(anio), int(mes))
data_por_dia = {f'{d:02d}': {'efectivo':0,'tbk':0,'total':0,'porcentaje':0} for d in range(1, num_dias+1)}
totales = {'efectivo':0,'tbk':0,'total':0,'porcentaje':0}
for r in resultados:
dia, ef, tbk = r[0], r[1] or 0, r[2] or 0
tt = ef + tbk
data_por_dia[dia] = {'efectivo':ef,'tbk':tbk,'total':tt,'porcentaje':round((ef/tt)*100) if tt>0 else 0}
totales['efectivo'] += ef; totales['tbk'] += tbk; totales['total'] += tt
if totales['total'] > 0: totales['porcentaje'] = round((totales['efectivo']/totales['total'])*100)
dias_en_periodo = [{'num':f'{d:02d}','name':['L','M','M','J','V','S','D'][date(int(anio),int(mes),d).weekday()]} for d in range(1, num_dias+1)]
conn.close()
return render_template('admin_report_iva.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", dias_en_periodo=dias_en_periodo, data_por_dia=data_por_dia, totales=totales, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)

View File

@@ -1,43 +0,0 @@
from flask import render_template, request, redirect, url_for, flash, session
from werkzeug.security import check_password_hash
from database import get_db_connection
from utils import validate_rut, format_rut
def register_auth_routes(app):
@app.route('/', methods=['GET', 'POST'])
def index():
if 'user_id' in session:
if session.get('is_admin'):
return redirect(url_for('admin_rendiciones'))
return redirect(url_for('worker_dashboard'))
if request.method == 'POST':
raw_rut = request.form['rut']
password = request.form['password']
rut = format_rut(raw_rut) if validate_rut(raw_rut) else raw_rut
conn = get_db_connection()
c = conn.cursor()
c.execute("SELECT id, password_hash, is_admin FROM workers WHERE rut = ?", (rut,))
user = c.fetchone()
conn.close()
if user and check_password_hash(user[1], password):
session['user_id'] = user[0]
session['is_admin'] = user[2]
session['rut'] = rut
if user[2]:
return redirect(url_for('admin_rendiciones'))
else:
return redirect(url_for('worker_dashboard'))
else:
flash("RUT o contraseña incorrectos.", "danger")
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('index'))

View File

@@ -1,188 +0,0 @@
from flask import render_template, request, redirect, url_for, flash, session
from datetime import date
from database import get_db_connection
from utils import login_required
def register_worker_routes(app):
@app.route('/dashboard')
@login_required
def worker_dashboard():
conn = get_db_connection()
c = conn.cursor()
user_id = session['user_id']
c.execute('''
SELECT r.id, r.fecha, w.name, m.name,
r.venta_debito, r.venta_credito, r.venta_mp, r.venta_efectivo, r.gastos, r.observaciones,
c_w.name, r.worker_id, r.companion_id, r.modulo_id,
r.worker_comision, r.companion_comision,
r.boletas_debito, r.boletas_credito, r.boletas_mp, r.boletas_efectivo
FROM rendiciones r
JOIN workers w ON r.worker_id = w.id
JOIN modulos m ON r.modulo_id = m.id
LEFT JOIN workers c_w ON r.companion_id = c_w.id
WHERE r.worker_id = ? OR r.companion_id = ?
ORDER BY r.fecha DESC, r.id DESC
''', (user_id, user_id))
rendiciones_basicas = c.fetchall()
rendiciones_completas = []
for r in rendiciones_basicas:
c.execute('''
SELECT p.name, ri.cantidad, ri.precio_historico, ri.comision_historica,
(ri.cantidad * ri.precio_historico) as total_linea,
(ri.cantidad * ri.comision_historica) as total_comision
FROM rendicion_items ri
JOIN productos p ON ri.producto_id = p.id
WHERE ri.rendicion_id = ?
''', (r[0],))
items = c.fetchall()
total_calculado = sum(item[4] for item in items)
comision_total = sum(item[5] for item in items)
rol = "Titular" if r[11] == user_id else "Acompañante"
r_completa = r + (items, total_calculado, comision_total, rol)
rendiciones_completas.append(r_completa)
conn.close()
return render_template('worker_history.html', rendiciones=rendiciones_completas)
@app.route('/rendicion/nueva', methods=['GET', 'POST'])
@login_required
def new_rendicion():
conn = get_db_connection()
c = conn.cursor()
c.execute('''SELECT w.modulo_id, m.name, z.id, z.name
FROM workers w
JOIN modulos m ON w.modulo_id = m.id
JOIN zonas z ON m.zona_id = z.id
WHERE w.id = ?''', (session['user_id'],))
worker_info = c.fetchone()
if not worker_info:
conn.close()
return "Error: No tienes un módulo asignado. Contacta al administrador."
modulo_id, modulo_name, zona_id, zona_name = worker_info
if request.method == 'POST':
fecha = request.form.get('fecha')
hora_entrada = request.form.get('hora_entrada')
hora_salida = request.form.get('hora_salida')
companion_hora_entrada = request.form.get('companion_hora_entrada')
companion_hora_salida = request.form.get('companion_hora_salida')
def clean_and_validate(val):
if val is None or val.strip() == "":
return 0
try:
return int(val.replace('.', ''))
except ValueError:
return 0
debito = clean_and_validate(request.form.get('venta_debito'))
credito = clean_and_validate(request.form.get('venta_credito'))
mp = clean_and_validate(request.form.get('venta_mp'))
efectivo = clean_and_validate(request.form.get('venta_efectivo'))
bol_debito = int(request.form.get('boletas_debito') or 0)
bol_credito = int(request.form.get('boletas_credito') or 0)
bol_mp = int(request.form.get('boletas_mp') or 0)
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
gastos = clean_and_validate(request.form.get('gastos')) or 0
obs = request.form.get('observaciones', '').strip()
companion_id = request.form.get('companion_id')
if companion_id == "":
companion_id = None
companion_hora_entrada = None
companion_hora_salida = None
if debito is None or credito is None or mp is None or efectivo is None or not fecha or not hora_entrada or not hora_salida:
flash("Error: Todos los campos obligatorios deben estar rellenos.", "danger")
return redirect(url_for('new_rendicion'))
c.execute("SELECT tipo FROM workers WHERE id = ?", (session['user_id'],))
worker_tipo = c.fetchone()[0]
worker_comision = 1 if worker_tipo == 'Full Time' else 0
companion_comision = 0
if companion_id:
c.execute("SELECT tipo FROM workers WHERE id = ?", (companion_id,))
comp_tipo = c.fetchone()
if comp_tipo and comp_tipo[0] == 'Full Time':
companion_comision = 1
total_digital = debito + credito + mp
total_ventas_general = total_digital + efectivo
c.execute('''INSERT INTO rendiciones
(worker_id, companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
venta_debito, venta_credito, venta_mp, venta_efectivo, boletas_debito, boletas_credito, boletas_mp, boletas_efectivo, gastos, observaciones, worker_comision, companion_comision)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(session['user_id'], companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
debito, credito, mp, efectivo, bol_debito, bol_credito, bol_mp, bol_efectivo, gastos, obs, worker_comision, companion_comision))
rendicion_id = c.lastrowid
for key, value in request.form.items():
if key.startswith('qty_') and value and int(value) > 0:
prod_id = int(key.split('_')[1])
cantidad = int(value)
# 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:
c.execute('''INSERT INTO rendicion_items
(rendicion_id, producto_id, cantidad, precio_historico, comision_historica)
VALUES (?, ?, ?, ?, ?)''',
(rendicion_id, prod_id, cantidad, prod_data[0], prod_data[1]))
conn.commit()
flash(f"Rendición enviada exitosamente. Total General Declarado: ${total_ventas_general:,}".replace(',', '.'), "success")
return redirect(url_for('worker_dashboard'))
c.execute('''
SELECT id, name FROM workers
WHERE id != ? AND modulo_id = ? AND is_admin = 0
ORDER BY name
''', (session['user_id'], modulo_id))
otros_trabajadores = c.fetchall()
# 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()
has_commission = any(prod[3] > 0 for prod in productos)
return render_template('worker_dashboard.html',
modulo_name=modulo_name,
zona_name=zona_name,
productos=productos,
has_commission=has_commission,
otros_trabajadores=otros_trabajadores,
today=date.today().strftime('%Y-%m-%d'))

0
services/__init__.py Normal file
View File

View File

@@ -0,0 +1,90 @@
from datetime import date
from sqlalchemy import func
from sqlalchemy.orm import aliased
from models.models import db, Rendicion, RendicionItem, Worker, Modulo, Zona, Producto
def get_filter_catalogs():
workers = db.session.query(
Worker.id, Worker.name, Worker.tipo, Worker.modulo_id,
).filter(Worker.is_admin == False).order_by(Worker.name).all()
modulos = db.session.query(
Modulo.id, Modulo.name, Modulo.zona_id,
).order_by(Modulo.name).all()
zonas = db.session.query(Zona.id, Zona.name).order_by(Zona.name).all()
anios_rows = db.session.query(
func.strftime('%Y', Rendicion.fecha).label('anio'),
).distinct().order_by(func.strftime('%Y', Rendicion.fecha).desc()).all()
anios_disponibles = [row[0] for row in anios_rows] if anios_rows else [str(date.today().year)]
if str(date.today().year) not in anios_disponibles:
anios_disponibles.insert(0, str(date.today().year))
return workers, modulos, zonas, anios_disponibles
def get_filtered_rendiciones(mes, anio, dia, zona_id, modulo_id):
Companion = aliased(Worker)
filters = [
func.strftime('%m', Rendicion.fecha) == mes,
func.strftime('%Y', Rendicion.fecha) == anio,
]
if dia:
filters.append(func.strftime('%d', Rendicion.fecha) == dia.zfill(2))
if zona_id:
filters.append(Modulo.zona_id == zona_id)
if modulo_id:
filters.append(Rendicion.modulo_id == modulo_id)
rendiciones = db.session.query(
Rendicion.id,
Rendicion.fecha,
Worker.name.label('worker_name'),
Modulo.name.label('modulo_name'),
Rendicion.venta_debito, Rendicion.venta_credito,
Rendicion.venta_mp, Rendicion.venta_efectivo,
Rendicion.gastos, Rendicion.observaciones,
Companion.name.label('companion_name'),
Rendicion.worker_id, Rendicion.companion_id, Rendicion.modulo_id,
Rendicion.worker_comision, Rendicion.companion_comision,
Rendicion.boletas_debito, Rendicion.boletas_credito,
Rendicion.boletas_mp, Rendicion.boletas_efectivo,
).join(Worker, Rendicion.worker_id == Worker.id
).join(Modulo, Rendicion.modulo_id == Modulo.id
).outerjoin(Companion, Rendicion.companion_id == Companion.id
).filter(*filters
).order_by(Rendicion.fecha.desc(), Rendicion.id.desc()).all()
if not rendiciones:
return []
rendicion_ids = [r.id for r in rendiciones]
items_rows = db.session.query(
Producto.name,
RendicionItem.cantidad,
RendicionItem.precio_historico,
RendicionItem.comision_historica,
(RendicionItem.cantidad * RendicionItem.precio_historico).label('total_linea'),
(RendicionItem.cantidad * RendicionItem.comision_historica).label('total_comision'),
RendicionItem.id,
RendicionItem.rendicion_id,
).join(Producto, RendicionItem.producto_id == Producto.id
).filter(RendicionItem.rendicion_id.in_(rendicion_ids)).all()
items_by_rendicion = {}
for it in items_rows:
items_by_rendicion.setdefault(it.rendicion_id, []).append(
(it[0], it[1], it[2], it[3], it.total_linea, it.total_comision, it.id),
)
rendiciones_completas = []
for r in rendiciones:
items = items_by_rendicion.get(r.id, [])
total_calculado = sum(item[4] for item in items)
comision_total = sum(item[5] for item in items)
rendiciones_completas.append(tuple(r) + (items, total_calculado, comision_total))
return rendiciones_completas

293
services/report_service.py Normal file
View File

@@ -0,0 +1,293 @@
import calendar
from datetime import date, datetime
from sqlalchemy import func
from sqlalchemy.orm import aliased
from models.models import db, Modulo, Worker, Rendicion, RendicionItem
Companion = aliased(Worker, name='companion')
WEEKDAY_SHORT = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
def _fecha_filters(anio, mes, dia_f):
filters = [
func.strftime('%m', Rendicion.fecha) == mes,
func.strftime('%Y', Rendicion.fecha) == anio,
]
if dia_f:
filters.append(func.strftime('%d', Rendicion.fecha) == dia_f.zfill(2))
return filters
def _dias_en_periodo(anio, mes):
_, num_dias = calendar.monthrange(int(anio), int(mes))
return [f'{d:02d}' for d in range(1, num_dias + 1)]
def _dias_con_nombre(anio, mes):
_, num_dias = calendar.monthrange(int(anio), int(mes))
return [
{'num': f'{d:02d}', 'name': WEEKDAY_SHORT[date(int(anio), int(mes), d).weekday()]}
for d in range(1, num_dias + 1)
]
def get_modulo_workers_and_anios(modulo_id):
modulo = db.session.get(Modulo, modulo_id)
mod_name = modulo.name if modulo else "Módulo"
workers = db.session.query(Worker.id, Worker.name).filter(
Worker.modulo_id == modulo_id,
Worker.is_admin == False,
).distinct().order_by(Worker.name).all()
workers_list = [(w.id, w.name) for w in workers]
anios_rows = db.session.query(
func.strftime('%Y', Rendicion.fecha).label('anio'),
).distinct().order_by(func.strftime('%Y', Rendicion.fecha).desc()).all()
anios_list = [row[0] for row in anios_rows]
if str(date.today().year) not in anios_list:
anios_list.insert(0, str(date.today().year))
return mod_name, workers_list, anios_list
def _calcular_horas(hora_in, hora_out):
if not hora_in or not hora_out:
return 0, "0:00"
try:
t1 = datetime.strptime(hora_in, '%H:%M')
t2 = datetime.strptime(hora_out, '%H:%M')
d = t2 - t1
return d.seconds / 3600, f"{d.seconds // 3600}:{(d.seconds % 3600) // 60:02d}"
except (ValueError, TypeError):
return 0, "0:00"
def get_modulo_periodo_data(modulo_id, anio, mes, dia_f, worker_id):
filters = [
Rendicion.modulo_id == modulo_id,
*_fecha_filters(anio, mes, dia_f),
]
if worker_id:
filters.append(Rendicion.worker_id == worker_id)
finanzas = db.session.query(
func.strftime('%d', Rendicion.fecha).label('dia'),
func.sum(Rendicion.venta_debito),
func.sum(Rendicion.venta_credito),
func.sum(Rendicion.venta_mp),
func.sum(Rendicion.venta_efectivo),
func.sum(Rendicion.gastos),
).filter(*filters).group_by('dia').all()
comision_filters = list(filters) + [
db.or_(Rendicion.worker_comision == True, Rendicion.companion_comision == True),
]
comisiones_rows = db.session.query(
func.strftime('%d', Rendicion.fecha).label('dia'),
func.sum(RendicionItem.cantidad * RendicionItem.comision_historica),
).join(Rendicion, RendicionItem.rendicion_id == Rendicion.id).filter(
*comision_filters,
).group_by('dia').all()
comisiones = {row[0]: row[1] for row in comisiones_rows}
dias_en_periodo = _dias_en_periodo(anio, mes)
data_por_dia = {d: {
'debito': 0, 'credito': 0, 'mp': 0, 'efectivo': 0,
'gastos': 0, 'comision': 0, 'venta_total': 0,
} for d in dias_en_periodo}
for r in finanzas:
d = r[0]
debito, credito, mp, efectivo, gastos = (
r[1] or 0, r[2] or 0, r[3] or 0, r[4] or 0, r[5] or 0,
)
vt = debito + credito + mp + efectivo
data_por_dia[d] = {
'debito': debito, 'credito': credito, 'mp': mp, 'efectivo': efectivo,
'gastos': gastos, 'venta_total': vt, 'comision': comisiones.get(d, 0),
}
totales_mes = {k: sum(d[k] for d in data_por_dia.values()) for k in data_por_dia['01']}
dias_activos = sum(1 for d in data_por_dia.values() if d['venta_total'] > 0)
return {
'dias_en_periodo': dias_en_periodo,
'data_por_dia': data_por_dia,
'totales_mes': totales_mes,
'dias_activos': dias_activos,
}
def get_comisiones_data(modulo_id, anio, mes, dia_f, worker_id):
filters = [
Rendicion.modulo_id == modulo_id,
*_fecha_filters(anio, mes, dia_f),
]
if worker_id:
filters.append(db.or_(Rendicion.worker_id == worker_id, Rendicion.companion_id == worker_id))
items_subq = db.session.query(
func.sum(RendicionItem.cantidad * RendicionItem.comision_historica),
).filter(RendicionItem.rendicion_id == Rendicion.id).correlate(Rendicion).scalar_subquery()
rendiciones = db.session.query(
Rendicion.id,
func.strftime('%d', Rendicion.fecha).label('dia'),
Worker.id.label('worker_id'),
Worker.name.label('worker_name'),
Worker.tipo.label('worker_tipo'),
Rendicion.worker_comision,
Companion.id.label('companion_id'),
Companion.name.label('companion_name'),
Companion.tipo.label('companion_tipo'),
Rendicion.companion_comision,
items_subq.label('total_com'),
).join(Worker, Rendicion.worker_id == Worker.id
).outerjoin(Companion, Rendicion.companion_id == Companion.id
).filter(*filters).all()
workers_data = {}
for r in rendiciones:
total_com = r.total_com or 0
for wid, wname, wtipo, wcom in [
(r.worker_id, r.worker_name, r.worker_tipo, r.worker_comision),
(r.companion_id, r.companion_name, r.companion_tipo, r.companion_comision),
]:
if wid and wcom:
if wid not in workers_data:
workers_data[wid] = {
'name': wname, 'tipo': wtipo, 'dias': {}, 'total': 0, 'enabled': True,
}
val = total_com / 2 if (r.worker_comision and r.companion_comision) else total_com
workers_data[wid]['dias'][r.dia] = workers_data[wid]['dias'].get(r.dia, 0) + val
workers_data[wid]['total'] += val
return {
'workers_data': dict(sorted(workers_data.items(), key=lambda x: x[1]['name'])),
'dias_en_periodo': _dias_en_periodo(anio, mes),
}
def get_horarios_data(modulo_id, anio, mes, dia_f, worker_id):
filters = [
Rendicion.modulo_id == modulo_id,
*_fecha_filters(anio, mes, dia_f),
]
if worker_id:
filters.append(db.or_(Rendicion.worker_id == worker_id, Rendicion.companion_id == worker_id))
rendiciones = db.session.query(
Rendicion.fecha,
Worker.id.label('worker_id'),
Worker.name.label('worker_name'),
Rendicion.hora_entrada,
Rendicion.hora_salida,
Companion.id.label('companion_id'),
Companion.name.label('companion_name'),
Rendicion.companion_hora_entrada,
Rendicion.companion_hora_salida,
).join(Worker, Rendicion.worker_id == Worker.id
).outerjoin(Companion, Rendicion.companion_id == Companion.id
).filter(*filters).all()
workers_data = {}
for r in rendiciones:
d = r.fecha.strftime('%d')
for wid, wname, win, wout in [
(r.worker_id, r.worker_name, r.hora_entrada, r.hora_salida),
(r.companion_id, r.companion_name, r.companion_hora_entrada, r.companion_hora_salida),
]:
if wid:
if wid not in workers_data:
workers_data[wid] = {'name': wname, 'dias': {}, 'total_horas': 0}
h_dec, h_str = _calcular_horas(win, wout)
workers_data[wid]['dias'][d] = {'in': win, 'out': wout, 'hrs': h_str}
workers_data[wid]['total_horas'] += h_dec
for w in workers_data.values():
th = w['total_horas']
w['total_hrs_str'] = f"{int(th)}:{int((th - int(th)) * 60):02d}"
return {
'workers_data': workers_data,
'dias_en_periodo': _dias_con_nombre(anio, mes),
}
def get_cc_data(modulo_id, anio, mes, dia_f, worker_id):
filters = [
Rendicion.modulo_id == modulo_id,
*_fecha_filters(anio, mes, dia_f),
]
if worker_id:
filters.append(Rendicion.worker_id == worker_id)
resultados = db.session.query(
func.strftime('%d', Rendicion.fecha).label('dia'),
func.sum(Rendicion.boletas_debito + Rendicion.boletas_credito + Rendicion.boletas_mp),
func.sum(Rendicion.boletas_efectivo),
func.sum(Rendicion.venta_debito + Rendicion.venta_credito + Rendicion.venta_mp + Rendicion.venta_efectivo),
).filter(*filters).group_by('dia').all()
dias_en_periodo = _dias_en_periodo(anio, mes)
data_por_dia = {d: {'red_compra': 0, 'efectivo': 0, 'total_trans': 0, 'venta_neta': 0} for d in dias_en_periodo}
totales = {'red_compra': 0, 'efectivo': 0, 'total_trans': 0, 'venta_neta': 0}
for r in resultados:
dia = r[0]
rc = r[1] or 0
ef = r[2] or 0
vt = r[3] or 0
vn = round(vt / 1.19)
data_por_dia[dia] = {'red_compra': rc, 'efectivo': ef, 'total_trans': rc + ef, 'venta_neta': vn}
for k in totales:
totales[k] += data_por_dia[dia][k]
return {
'dias_en_periodo': _dias_con_nombre(anio, mes),
'data_por_dia': data_por_dia,
'totales': totales,
}
def get_iva_data(modulo_id, anio, mes, dia_f, worker_id):
filters = [
Rendicion.modulo_id == modulo_id,
*_fecha_filters(anio, mes, dia_f),
]
if worker_id:
filters.append(Rendicion.worker_id == worker_id)
resultados = db.session.query(
func.strftime('%d', Rendicion.fecha).label('dia'),
func.sum(Rendicion.venta_efectivo),
func.sum(Rendicion.venta_debito + Rendicion.venta_credito + Rendicion.venta_mp),
).filter(*filters).group_by('dia').all()
dias_en_periodo = _dias_en_periodo(anio, mes)
data_por_dia = {d: {'efectivo': 0, 'tbk': 0, 'total': 0, 'porcentaje': 0} for d in dias_en_periodo}
totales = {'efectivo': 0, 'tbk': 0, 'total': 0, 'porcentaje': 0}
for r in resultados:
dia = r[0]
ef = r[1] or 0
tbk = r[2] or 0
tt = ef + tbk
data_por_dia[dia] = {
'efectivo': ef, 'tbk': tbk, 'total': tt,
'porcentaje': round((ef / tt) * 100) if tt > 0 else 0,
}
totales['efectivo'] += ef
totales['tbk'] += tbk
totales['total'] += tt
if totales['total'] > 0:
totales['porcentaje'] = round((totales['efectivo'] / totales['total']) * 100)
return {
'dias_en_periodo': _dias_con_nombre(anio, mes),
'data_por_dia': data_por_dia,
'totales': totales,
}

52
static/css/components.css Normal file
View File

@@ -0,0 +1,52 @@
/* ============================================================
HOVER EFFECTS — theme-aware
============================================================ */
/* .hover-shadow: lift on hover with info-colored border
Uses --bs-info so the border matches the theme palette. */
.hover-shadow {
transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease;
}
.hover-shadow:hover {
transform: translateY(-5px);
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .15) !important;
border-color: var(--bs-info) !important;
}
/* .hover-card: tertiary background that blends with the page */
.hover-card {
transition: transform .2s ease, box-shadow .2s ease;
background-color: var(--bs-tertiary-bg);
}
.hover-card:hover {
transform: translateY(-3px);
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .12) !important;
}
.transition-all {
transition: all .3s ease-in-out;
}
/* Dark mode gets a deeper shadow */
[data-bs-theme="dark"] .hover-shadow:hover {
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .3) !important;
}
[data-bs-theme="dark"] .hover-card:hover {
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .25) !important;
}
/* ============================================================
BAGUETTE EASTER EGG
============================================================ */
@keyframes baguetteRoll {
0% { transform: rotate(0deg) scale(1.2); }
50% { transform: rotate(180deg) scale(1.5); }
100% { transform: rotate(360deg) scale(1); }
}
.baguette-spin {
display: inline-block;
animation: baguetteRoll 1s ease-in-out;
font-style: normal;
}

View File

@@ -0,0 +1,36 @@
.table-container {
max-height: 75vh;
overflow-y: auto;
overflow-x: auto;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 2;
background-color: var(--bs-body-bg);
border-right: 2px solid var(--bs-border-color) !important;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--bs-body-bg);
box-shadow: inset 0 -1px 0 var(--bs-border-color);
}
thead th.sticky-col {
z-index: 3;
}
.numeric-cell {
font-family: 'Courier New', Courier, monospace;
}
/* .total-column: uses theme-aware secondary background
so the highlight is visible in both light and dark mode. */
.total-column {
font-weight: bold;
background-color: var(--bs-secondary-bg) !important;
}

View File

@@ -0,0 +1,42 @@
document.addEventListener("DOMContentLoaded", function () {
window.formatHelpers.bindMoneyInput('.money-input');
const editModal = document.getElementById('editProductModal');
if (editModal) {
editModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
if (!button || !button.hasAttribute('data-id')) return;
const id = button.getAttribute('data-id');
const name = button.getAttribute('data-name');
const price = button.getAttribute('data-price');
const commission = button.getAttribute('data-commission');
const zonaId = button.getAttribute('data-zona');
const form = editModal.querySelector('#editProductForm');
form.action = `/admin/productos/edit/${id}`;
editModal.querySelector('#edit_name').value = name;
editModal.querySelector('#edit_price').value = price;
editModal.querySelector('#edit_commission').value = commission;
editModal.querySelector('#edit_zona_id').value = zonaId;
editModal.querySelectorAll('.money-input').forEach(input => {
input.dispatchEvent(new Event('input'));
});
});
}
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 => {
const name = row.cells[0].textContent.toLowerCase();
row.style.display = name.includes(term) ? '' : 'none';
});
});
}
});

View File

@@ -0,0 +1,137 @@
document.addEventListener("DOMContentLoaded", function () {
window.formatHelpers.bindMoneyInput('.money-input');
window.updateBadge = function (selectElement, badgeId) {
const option = selectElement.options[selectElement.selectedIndex];
const tipo = option ? option.getAttribute('data-tipo') : null;
const badgeDiv = document.getElementById(badgeId);
if (!badgeDiv) return;
if (!tipo) {
badgeDiv.innerHTML = '';
return;
}
const color = (tipo === 'Full Time') ? 'bg-success' : 'bg-secondary';
badgeDiv.innerHTML = `<span class="badge ${color}">${tipo}</span>`;
};
window.toggleCompDiv = function (id, select) {
const compDiv = document.getElementById(`comp_com_div_${id}`);
if (compDiv) compDiv.style.display = select.value ? 'flex' : 'none';
window.updateBadge(select, `badge_comp_${id}`);
window.updateComisionToggle(select, `cc_${id}`);
};
window.updateComisionToggle = function (selectElement, toggleId) {
const option = selectElement.options[selectElement.selectedIndex];
const tipoJornada = option ? option.getAttribute('data-tipo') : null;
const toggleSwitch = document.getElementById(toggleId);
if (toggleSwitch && tipoJornada) {
toggleSwitch.checked = (tipoJornada === 'Full Time');
} else if (toggleSwitch && !selectElement.value) {
toggleSwitch.checked = false;
}
const baseId = toggleId.split('_')[1];
const targetBadge = toggleId.startsWith('wc') ? `badge_worker_${baseId}` : `badge_comp_${baseId}`;
window.updateBadge(selectElement, targetBadge);
};
window.recalcProductLine = function (input) {
const qty = parseInt(input.value) || 0;
const price = parseInt(input.getAttribute('data-price')) || 0;
const rid = input.getAttribute('data-rid');
const row = input.closest('tr');
const lineTotal = qty * price;
const lineCell = row.querySelector('.item-total-line');
if (lineCell) lineCell.innerText = '$' + lineTotal.toLocaleString('es-CL');
const modal = document.getElementById(`editRendicion${rid}`);
let newSysTotal = 0;
if (modal) {
modal.querySelectorAll('.prod-qty-input').forEach(inp => {
newSysTotal += (parseInt(inp.value) || 0) * (parseInt(inp.getAttribute('data-price')) || 0);
});
}
const sysTotalEl = document.getElementById(`sys_total_${rid}`);
if (sysTotalEl) sysTotalEl.innerText = '$' + newSysTotal.toLocaleString('es-CL');
};
window.calcTotalEdit = function (id) {
const getVal = (inputId) => window.formatHelpers.getMoneyInputValue(inputId);
const total = getVal(`edit_debito_${id}`) + getVal(`edit_credito_${id}`)
+ getVal(`edit_mp_${id}`) + getVal(`edit_efectivo_${id}`);
const el = document.getElementById(`display_nuevo_total_${id}`);
if (el) el.innerText = '$' + total.toLocaleString('es-CL');
};
const editModals = document.querySelectorAll('[id^="editRendicion"]');
const editForms = document.querySelectorAll('form[action*="/admin/rendiciones/edit/"]');
const errorModalEl = document.getElementById('errorPersonaModal');
const errorModal = errorModalEl ? new bootstrap.Modal(errorModalEl) : null;
const errorBody = document.getElementById('errorPersonaModalBody');
editForms.forEach(form => {
form.addEventListener('submit', function (e) {
const workerId = this.querySelector('select[name="worker_id"]').value;
const companionId = this.querySelector('select[name="companion_id"]').value;
if (companionId && workerId === companionId) {
e.preventDefault();
if (errorModal && errorBody) {
errorBody.innerHTML = "<strong>Error:</strong> El trabajador titular y el acompañante no pueden ser la misma persona. Por favor, selecciona a alguien más.";
errorModal.show();
} else {
alert("Un trabajador no puede ser su propio acompañante. Por favor, corrige la selección.");
}
}
});
});
editModals.forEach(modal => {
modal.addEventListener('show.bs.modal', function () {
const rid = this.id.replace('editRendicion', '');
const workerSelect = this.querySelector('select[name="worker_id"]');
if (workerSelect) window.updateBadge(workerSelect, `badge_worker_${rid}`);
const compSelect = this.querySelector('select[name="companion_id"]');
if (compSelect && compSelect.value) window.updateBadge(compSelect, `badge_comp_${rid}`);
});
modal.addEventListener('hidden.bs.modal', function () {
const form = this.querySelector('form');
if (!form) return;
form.reset();
const rid = this.id.replace('editRendicion', '');
window.calcTotalEdit(rid);
this.querySelectorAll('.prod-qty-input').forEach(inp => window.recalcProductLine(inp));
});
});
const zonaSelect = document.getElementById('zonaSelect');
const moduloSelect = document.getElementById('moduloSelect');
if (zonaSelect && moduloSelect) {
const moduloOptions = Array.from(moduloSelect.options);
function filterModulos() {
const selectedZona = zonaSelect.value;
moduloOptions.forEach(option => {
if (option.value === "") {
option.style.display = '';
} else if (!selectedZona || option.dataset.zona === selectedZona) {
option.style.display = '';
} else {
option.style.display = 'none';
if (option.selected) moduloSelect.value = "";
}
});
}
zonaSelect.addEventListener('change', filterModulos);
filterModulos();
}
});

View File

@@ -0,0 +1,80 @@
document.addEventListener("DOMContentLoaded", function () {
const editWorkerModal = document.getElementById('editWorkerModal');
const confirmResetModal = document.getElementById('confirmResetPass');
if (editWorkerModal) {
editWorkerModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
if (!button || !button.hasAttribute('data-id')) return;
const id = button.getAttribute('data-id');
const name = button.getAttribute('data-name');
const editForm = editWorkerModal.querySelector('#editWorkerForm');
const resetForm = confirmResetModal ? confirmResetModal.querySelector('form') : null;
editForm.action = "/admin/workers/edit/" + id;
if (resetForm) resetForm.action = "/admin/workers/reset_password/" + id;
if (confirmResetModal) {
confirmResetModal.querySelector('.modal-body').textContent =
`¿Estás seguro de generar una nueva contraseña para ${name}? La anterior dejará de funcionar.`;
}
editWorkerModal.querySelector('#edit_worker_rut').value = button.getAttribute('data-rut');
editWorkerModal.querySelector('#edit_worker_name').value = name;
editWorkerModal.querySelector('#edit_worker_phone').value = button.getAttribute('data-phone');
editWorkerModal.querySelector('#edit_worker_modulo').value = button.getAttribute('data-modulo');
editWorkerModal.querySelector('#edit_worker_tipo').value = button.getAttribute('data-tipo');
});
}
if (editWorkerModal && confirmResetModal) {
const btnCancelar = confirmResetModal.querySelector('.btn-secondary');
const btnCerrarX = confirmResetModal.querySelector('.btn-close');
const reabrirEdicion = () => {
const modalEdicion = new bootstrap.Modal(editWorkerModal);
modalEdicion.show();
};
if (btnCancelar) btnCancelar.addEventListener('click', reabrirEdicion);
if (btnCerrarX) btnCerrarX.addEventListener('click', reabrirEdicion);
}
const rutInput = document.getElementById('rutInput');
if (rutInput) {
window.formatHelpers.bindRutInput('#rutInput');
}
window.formatHelpers.bindPhoneInput('.phone-input, #phoneInput');
const searchInputWorker = document.getElementById('searchWorker');
const moduleSelectFilter = document.getElementById('filterModule');
const typeSelectFilter = document.getElementById('filterType');
const workerRows = document.querySelectorAll('.worker-row');
function filterWorkers() {
if (!searchInputWorker) return;
const searchTerm = searchInputWorker.value.toLowerCase();
const selectedModule = moduleSelectFilter ? moduleSelectFilter.value : 'all';
const selectedType = typeSelectFilter ? typeSelectFilter.value : 'all';
workerRows.forEach(row => {
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;
row.style.display = (matchesSearch && matchesModule && matchesType) ? '' : 'none';
});
}
if (searchInputWorker) searchInputWorker.addEventListener('input', filterWorkers);
if (moduleSelectFilter) moduleSelectFilter.addEventListener('change', filterWorkers);
if (typeSelectFilter) typeSelectFilter.addEventListener('change', filterWorkers);
});

View File

@@ -0,0 +1,71 @@
window.formatHelpers = (function () {
function formatRutInput(input) {
let value = input.value.replace(/[^0-9kK]/g, '').toUpperCase();
if (value.length > 9) value = value.slice(0, 9);
if (value.length > 1) {
let body = value.slice(0, -1);
let dv = value.slice(-1);
body = body.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
input.value = `${body}-${dv}`;
} else {
input.value = value;
}
}
function bindRutInput(selector) {
document.querySelectorAll(selector).forEach(function (inp) {
inp.addEventListener('input', function () { formatRutInput(inp); });
});
}
function formatPhone(input) {
let value = input.value.replace(/\D/g, '');
if (value.startsWith('56')) value = value.substring(2);
value = value.substring(0, 9);
if (value.length > 5) input.value = value.replace(/(\d{1})(\d{4})(\d+)/, '$1 $2 $3');
else if (value.length > 1) input.value = value.replace(/(\d{1})(\d+)/, '$1 $2');
else input.value = value;
}
function bindPhoneInput(selector) {
document.querySelectorAll(selector).forEach(function (inp) {
inp.addEventListener('input', function () { formatPhone(inp); });
});
}
function formatMoneyInput(input) {
if (input.dataset.formatted === '1') return;
let value = input.value.replace(/\D/g, '');
input.value = value === '' ? '' : parseInt(value, 10).toLocaleString('es-CL');
input.dataset.formatted = '1';
}
function bindMoneyInput(selector) {
document.querySelectorAll(selector).forEach(function (input) {
formatMoneyInput(input);
input.addEventListener('input', function () {
let value = input.value.replace(/\D/g, '');
input.value = value === '' ? '' : parseInt(value, 10).toLocaleString('es-CL');
});
});
}
function parseMoney(value) {
return parseInt(String(value).replace(/\D/g, ''), 10) || 0;
}
function getMoneyInputValue(id) {
return parseMoney(document.getElementById(id).value);
}
return {
formatRutInput: formatRutInput,
bindRutInput: bindRutInput,
formatPhone: formatPhone,
bindPhoneInput: bindPhoneInput,
formatMoneyInput: formatMoneyInput,
bindMoneyInput: bindMoneyInput,
parseMoney: parseMoney,
getMoneyInputValue: getMoneyInputValue
};
})();

3
static/js/login.js Normal file
View File

@@ -0,0 +1,3 @@
document.addEventListener("DOMContentLoaded", function () {
window.formatHelpers.bindRutInput('#rutInput');
});

30
static/js/navbar.js Normal file
View File

@@ -0,0 +1,30 @@
document.addEventListener("DOMContentLoaded", function () {
const brandIcon = document.getElementById("brandIcon");
if (!brandIcon) return;
let clickCount = 0;
let clickResetTimer;
brandIcon.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
clickCount++;
clearTimeout(clickResetTimer);
clickResetTimer = setTimeout(() => { clickCount = 0; }, 800);
if (clickCount >= 5) {
clickCount = 0;
clearTimeout(clickResetTimer);
const originalClass = this.className;
this.className = "fs-3 me-2 baguette-spin";
this.innerHTML = "&#129366;";
setTimeout(() => {
this.className = originalClass;
this.innerHTML = "";
}, 1000);
}
});
});

View File

@@ -0,0 +1,55 @@
document.addEventListener("DOMContentLoaded", function () {
if (typeof Chart === "undefined") return;
const COLORS = ['#0d6efd', '#198754', '#dc3545', '#ffc107', '#0dcaf0'];
let priceChartInstance = null;
window.showHistory = async function (prodId, prodName) {
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
document.getElementById('chartModalTitle').innerText = 'Fluctuación de Precio: ' + prodName;
modal.show();
const res = await fetch(`/admin/api/productos/${prodId}/historial`);
const data = await res.json();
const zonas = [...new Set(data.map(d => d.zona))];
const fechas = [...new Set(data.map(d => d.fecha.split(' ')[0]))].sort();
const datasets = zonas.map((zona, index) => {
let lastPrice = 0;
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;
}
return lastPrice;
});
return {
label: zona,
data: dataPoints,
borderColor: COLORS[index % COLORS.length],
backgroundColor: COLORS[index % COLORS.length],
stepped: true,
borderWidth: 2
};
});
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: v => '$' + v.toLocaleString('es-CL') }
}
}
}
});
};
});

View File

@@ -0,0 +1,206 @@
document.addEventListener("DOMContentLoaded", function () {
window.formatHelpers.bindMoneyInput('.money-input');
const companionSelect = document.getElementById('companion_select');
if (companionSelect) {
companionSelect.addEventListener('change', function () {
const timeDiv = document.getElementById('companion_times_div');
const compIn = document.getElementById('comp_in');
const compOut = document.getElementById('comp_out');
if (this.value) {
timeDiv.style.display = 'block';
compIn.required = true;
compOut.required = true;
} else {
timeDiv.style.display = 'none';
compIn.required = false;
compOut.required = false;
compIn.value = '';
compOut.value = '';
}
});
}
function getVal(id) {
return window.formatHelpers.getMoneyInputValue(id);
}
function checkWarnings() {
const totalProductos = getVal('total_productos_calc');
const totalDeclarado = getVal('total_general');
const gastos = getVal('gastos');
const efectivo = getVal('venta_efectivo');
let warnings = [];
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>.");
}
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';
}
}
const inputsCantidad = document.querySelectorAll('input[name^="qty_"]');
const displayTotalProductos = document.getElementById('total_productos_calc');
function calcularVentaProductos() {
if (!displayTotalProductos) return;
let granTotal = 0;
const filas = document.querySelectorAll('tbody tr');
filas.forEach(fila => {
const inputQty = fila.querySelector('input[name^="qty_"]');
if (inputQty) {
if (parseInt(inputQty.value) < 0) inputQty.value = 0;
const cantidad = parseInt(inputQty.value) || 0;
const precioTexto = fila.cells[1].innerText.replace(/\D/g, '');
const precio = parseInt(precioTexto) || 0;
granTotal += (cantidad * precio);
}
});
displayTotalProductos.value = granTotal.toLocaleString('es-CL');
checkWarnings();
}
inputsCantidad.forEach(input => {
input.addEventListener('keydown', function (e) {
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
if (e.key < '0' || e.key > '9') e.preventDefault();
});
input.addEventListener('input', function () {
this.value = this.value.replace(/\D/g, '');
calcularVentaProductos();
});
});
const inputsVenta = document.querySelectorAll('.sale-input');
const displayDigital = document.getElementById('total_digital');
const displayGeneral = document.getElementById('total_general');
function calcularTotales() {
const debito = getVal('venta_debito');
const credito = getVal('venta_credito');
const mp = getVal('venta_mp');
const efectivo = getVal('venta_efectivo');
const totalDigital = debito + credito + mp;
const totalGeneral = totalDigital + efectivo;
if (displayDigital) displayDigital.value = totalDigital.toLocaleString('es-CL');
if (displayGeneral) displayGeneral.value = totalGeneral.toLocaleString('es-CL');
checkWarnings();
}
inputsVenta.forEach(input => {
input.addEventListener('input', calcularTotales);
});
document.querySelectorAll('.money-input').forEach(function (input) {
input.addEventListener('keydown', function (e) {
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
if (e.key < '0' || e.key > '9') e.preventDefault();
});
input.addEventListener('focus', function () {
if (this.value === '0') this.value = '';
});
input.addEventListener('blur', function () {
if (this.value.trim() === '' || this.value.trim() === '0') this.value = '0';
calcularTotales();
});
input.addEventListener('input', function () {
let value = this.value.replace(/\D/g, '');
if (value !== '') this.value = parseInt(value, 10).toLocaleString('es-CL');
calcularTotales();
});
});
const submitModal = document.getElementById('confirmSubmitModal');
const mainForm = document.querySelector('form');
const alertModalEl = document.getElementById('globalAlertModal');
const alertModal = alertModalEl ? new bootstrap.Modal(alertModalEl) : null;
function mostrarError(mensaje) {
const body = document.getElementById('globalAlertModalBody');
if (body) body.textContent = mensaje;
if (alertModal) alertModal.show();
}
function validarFormulario() {
if (!mainForm) return false;
const requiredInputs = mainForm.querySelectorAll('[required]');
let valid = true;
requiredInputs.forEach(input => {
const isMoney = input.classList.contains('money-input');
if (!input.value.trim() || (isMoney && input.value === '')) {
input.classList.add('is-invalid');
valid = false;
} else {
input.classList.remove('is-invalid');
}
});
return valid;
}
if (submitModal) {
const confirmBtn = submitModal.querySelector('button[type="submit"]');
if (confirmBtn) {
confirmBtn.addEventListener('click', function (e) {
e.preventDefault();
if (validarFormulario()) {
mainForm.submit();
} else {
const submitInstance = bootstrap.Modal.getInstance(submitModal);
if (submitInstance) submitInstance.hide();
mostrarError("Por favor, rellena los campos obligatorios (Fecha y Hora) antes de enviar.");
}
});
}
}
if (mainForm) {
mainForm.addEventListener('submit', function (e) {
const requiredInputs = this.querySelectorAll('[required]');
let valid = true;
requiredInputs.forEach(input => {
const isMoney = input.classList.contains('money-input');
if (!input.value.trim() || (isMoney && input.value === '')) {
input.classList.add('is-invalid');
valid = false;
} else {
input.classList.remove('is-invalid');
}
});
if (!valid) {
e.preventDefault();
if (alertModalEl) {
const inst = bootstrap.Modal.getOrCreateInstance(alertModalEl);
mostrarError("Por favor, rellena todos los campos obligatorios antes de enviar.");
inst.show();
} else {
alert("Por favor, rellena todos los campos obligatorios.");
}
}
});
}
document.querySelectorAll('.money-input').forEach(input => {
if (!input.value.trim()) input.value = '0';
});
});

View File

@@ -1,4 +1,7 @@
/* navbar */
/* ============================================================
GLOBAL THEME — applies to both light and dark modes
============================================================ */
.navbar {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
@@ -13,44 +16,45 @@
transition: color 0.2s ease-in-out;
}
[data-bs-theme="light"] #theme-icon.bi-moon-stars {
color: #5856d6;
}
[data-bs-theme="dark"] #theme-icon.bi-sun {
color: #ffc107;
}
#theme-switcher.nav-link {
color: inherit !important;
}
/* form edicion de trabajador */
/* Theme toggle button — needs to be visible in both modes */
#theme-toggle-btn {
color: var(--bs-body-color);
}
#theme-toggle-btn:hover {
color: var(--bs-emphasis-color);
}
/* Make .text-info readable in BOTH modes
Dark mode keeps the bright cyan with subtle glow,
light mode uses a darker, eye-friendly teal. */
[data-bs-theme="dark"] .text-info {
color: #38bdf8 !important;
text-shadow: 0 0 10px rgba(56, 189, 248, 0.2);
}
[data-bs-theme="light"] .text-info {
color: #0a6c7e !important;
text-shadow: none;
}
/* ============================================================
DARK MODE — custom overrides
============================================================ */
[data-bs-theme="dark"] .form-control[readonly] {
background-color: #1a1d21;
border-color: #373b3e;
color: #e3e6e8;
opacity: 1;
cursor: not-allowed;
border-color: #373b3e;
color: #e3e6e8;
opacity: 1;
cursor: not-allowed;
}
[data-bs-theme="dark"] .text-muted-rut {
color: #adb5bd !important; /* Un gris medio para el label "RUT (No editable)" */
color: #adb5bd !important;
}
/* botones acciones */
@media (max-width: 576px) {
.col-barcode {
display: none;
}
.btn-edit-sm,
.btn-del-sm {
padding: 4px 7px;
font-size: 0.75rem;
}
}
/* fila calculo total dashboard trabajador */
[data-bs-theme="dark"] .custom-total-row {
background-color: rgba(30, 41, 59, 0.7);
@@ -65,7 +69,85 @@
padding-right: 0;
}
.text-info {
color: #38bdf8 !important; /* Azul cielo moderno */
text-shadow: 0 0 10px rgba(56, 189, 248, 0.2);
}
[data-bs-theme="dark"] #theme-icon.bi-sun {
color: #ffc107;
}
/* ============================================================
LIGHT MODE — custom overrides (eye-friendly defaults)
============================================================ */
/* Soft warm-gray body to reduce glare */
[data-bs-theme="light"] body {
background-color: #f4f6f8;
color: #1f2937;
}
/* Subtle, lower-contrast borders in light mode */
[data-bs-theme="light"] .card,
[data-bs-theme="light"] .navbar,
[data-bs-theme="light"] .modal-content,
[data-bs-theme="light"] .list-group-item {
border-color: #d8dde2;
}
/* Card backgrounds pop gently on the off-white body */
[data-bs-theme="light"] .card {
background-color: #ffffff;
box-shadow: 0 .125rem .25rem rgba(15, 23, 42, .06) !important;
}
[data-bs-theme="light"] .form-control[readonly] {
background-color: #f1f3f5;
border-color: #d8dde2;
color: #495057;
opacity: 1;
cursor: not-allowed;
}
[data-bs-theme="light"] .text-muted-rut {
color: #6c757d !important;
}
[data-bs-theme="light"] .custom-total-row {
background-color: #eef2f6;
color: #1f2937;
border-top: 2px solid #cbd5e1;
}
[data-bs-theme="light"] #total_productos_calc {
box-shadow: none;
outline: none;
text-align: right;
padding-right: 0;
}
[data-bs-theme="light"] #theme-icon.bi-moon-stars {
color: #5856d6;
}
/* Make Bootstrap's bg-dark-subtle in cards/headers lighter and warmer in light mode */
[data-bs-theme="light"] .bg-dark-subtle {
background-color: #eef2f6 !important;
}
/* Lighten the harshness of bg-body-tertiary slightly */
[data-bs-theme="light"] .bg-body-tertiary {
background-color: #eaeef2 !important;
}
/* ============================================================
RESPONSIVE — small-screen action buttons
============================================================ */
@media (max-width: 576px) {
.col-barcode {
display: none;
}
.btn-edit-sm,
.btn-del-sm {
padding: 4px 7px;
font-size: 0.75rem;
}
}

View File

@@ -1,29 +1,20 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal, edit_product_modal %}
{% from "macros/ui.html" import flashed_messages %}
{% block title %}Catálogo de Productos{% endblock %}
{% block head %}
<!-- HEAD -->
{% endblock %}
{% block content %}
<h2 class="mb-4">Catálogo de Productos por Zona</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ flashed_messages() }}
{{ edit_product_modal(zonas) }}
<div class="card mb-4 shadow-sm">
<div class="card-header bg-primary text-white">Agregar Producto Maestro</div>
<div class="card-body">
<form method="POST" action="{{ url_for('manage_products') }}">
<form method="POST" action="{{ url_for('admin.manage_products') }}">
<div class="row g-3">
<div class="col-md-10">
<input type="text" class="form-control" name="name" placeholder="Nombre del Producto" required>
@@ -69,7 +60,7 @@
id='deleteProd' ~ prod.id,
title='Eliminar Producto',
message='¿Eliminar "' ~ prod.name ~ '"? Esto fallará si el producto ya tiene ventas registradas.',
action_url=url_for('delete_product', id=prod.id),
action_url=url_for('admin.delete_product', id=prod.id),
btn_class='btn-danger',
btn_text='Eliminar'
) }}
@@ -85,7 +76,7 @@
<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) }}">
<form method="POST" action="{{ url_for('admin.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>
@@ -156,7 +147,7 @@
<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">
<form action="{{ url_for('admin.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>
@@ -196,132 +187,6 @@
{% 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) {
editModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
// Atributos extraídos del botón
const id = button.getAttribute('data-id');
const name = button.getAttribute('data-name');
const price = button.getAttribute('data-price');
const commission = button.getAttribute('data-commission');
const zonaId = button.getAttribute('data-zona');
// Actualizamos el destino del formulario
const form = editModal.querySelector('#editProductForm');
form.action = `/admin/productos/edit/${id}`;
// Llenamos los inputs
editModal.querySelector('#edit_name').value = name;
editModal.querySelector('#edit_price').value = price;
editModal.querySelector('#edit_commission').value = commission;
editModal.querySelector('#edit_zona_id').value = zonaId;
// Forzamos el formato de miles (puntos) inmediatamente
editModal.querySelectorAll('.money-input').forEach(input => {
input.dispatchEvent(new Event('input'));
});
});
}
document.querySelectorAll('.money-input').forEach(function(input) {
input.addEventListener('input', function(e) {
let value = this.value.replace(/\D/g, '');
if (value !== '') {
this.value = parseInt(value, 10).toLocaleString('es-CL');
} else {
this.value = '';
}
});
});
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>
<script src="{{ url_for('static', filename='js/product-history-chart.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_productos.js') }}"></script>
{% endblock %}

View File

@@ -1,11 +1,9 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import alert_modal, rendicion_detail_modal, confirm_modal, edit_rendicion_modal %}
{% from "macros/ui.html" import flashed_messages %}
{% block title %}Historial de Rendiciones{% endblock %}
{% block head %}
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Historial de Rendiciones</h2>
@@ -13,7 +11,7 @@
<div class="card shadow-sm mb-4 border-0">
<div class="card-body bg-body-tertiary rounded p-3">
<form method="GET" action="{{ url_for('admin_rendiciones') }}" id="filterForm">
<form method="GET" action="{{ url_for('admin.admin_rendiciones') }}" id="filterForm">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small text-muted mb-1">Año</label>
@@ -77,13 +75,7 @@
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ flashed_messages() }}
<div class="card shadow-sm">
<div class="card-body p-0">
@@ -123,7 +115,7 @@
id='deleteRendicion' ~ r[0],
title='Eliminar Rendición',
message='¿Estás seguro de que deseas eliminar la rendición #' ~ r[0] ~ '? Esta acción no se puede deshacer.',
action_url=url_for('delete_rendicion', id=r[0]),
action_url=url_for('admin.delete_rendicion', id=r[0]),
btn_class='btn-danger',
btn_text='Eliminar'
) }}
@@ -142,171 +134,5 @@
{% endblock %}
{% block scripts %}
<script>
// Función para manejar las etiquetas de jornada (Full/Part Time)
function updateBadge(selectElement, badgeId) {
const option = selectElement.options[selectElement.selectedIndex];
const tipo = option.getAttribute('data-tipo');
const badgeDiv = document.getElementById(badgeId);
if (!badgeDiv) return;
if (!tipo) {
badgeDiv.innerHTML = '';
return;
}
const color = (tipo === 'Full Time') ? 'bg-success' : 'bg-secondary';
badgeDiv.innerHTML = `<span class="badge ${color}">${tipo}</span>`;
}
function toggleCompDiv(id, select) {
const compDiv = document.getElementById(`comp_com_div_${id}`);
compDiv.style.display = select.value ? 'flex' : 'none';
updateBadge(select, `badge_comp_${id}`);
updateComisionToggle(select, `cc_${id}`);
}
function updateComisionToggle(selectElement, toggleId) {
const option = selectElement.options[selectElement.selectedIndex];
const tipoJornada = option.getAttribute('data-tipo');
const toggleSwitch = document.getElementById(toggleId);
if (toggleSwitch && tipoJornada) {
// Explicitly set to true if Full Time, false otherwise
toggleSwitch.checked = (tipoJornada === 'Full Time');
} else if (toggleSwitch && !selectElement.value) {
// If "Sin acompañante" is selected, turn it off
toggleSwitch.checked = false;
}
// Actualizar el badge también
const baseId = toggleId.split('_')[1];
const targetBadge = toggleId.startsWith('wc') ? `badge_worker_${baseId}` : `badge_comp_${baseId}`;
updateBadge(selectElement, targetBadge);
}
// Recalcular total de la línea de producto y el total del sistema
function recalcProductLine(input) {
const qty = parseInt(input.value) || 0;
const price = parseInt(input.getAttribute('data-price')) || 0;
const rid = input.getAttribute('data-rid');
const row = input.closest('tr');
// Actualizar línea individual
const lineTotal = qty * price;
row.querySelector('.item-total-line').innerText = '$' + lineTotal.toLocaleString('es-CL');
// Recalcular total general del sistema en el modal
const modal = document.getElementById(`editRendicion${rid}`);
let newSysTotal = 0;
modal.querySelectorAll('.prod-qty-input').forEach(inp => {
newSysTotal += (parseInt(inp.value) || 0) * (parseInt(inp.getAttribute('data-price')) || 0);
});
document.getElementById(`sys_total_${rid}`).innerText = '$' + newSysTotal.toLocaleString('es-CL');
}
function calcTotalEdit(id) {
const getVal = (inputId) => parseInt(document.getElementById(inputId).value.replace(/\D/g, '')) || 0;
const total = getVal(`edit_debito_${id}`) + getVal(`edit_credito_${id}`) + getVal(`edit_mp_${id}`) + getVal(`edit_efectivo_${id}`);
document.getElementById(`display_nuevo_total_${id}`).innerText = '$' + total.toLocaleString('es-CL');
}
document.addEventListener('DOMContentLoaded', function() {
const editModals = document.querySelectorAll('[id^="editRendicion"]');
const editForms = document.querySelectorAll('form[action*="/admin/rendiciones/edit/"]');
const errorModalEl = document.getElementById('errorPersonaModal');
const errorModal = new bootstrap.Modal(errorModalEl);
const errorBody = document.getElementById('errorPersonaModalBody');
editForms.forEach(form => {
form.addEventListener('submit', function(e) {
const workerId = this.querySelector('select[name="worker_id"]').value;
const companionId = this.querySelector('select[name="companion_id"]').value;
if (companionId && workerId === companionId) {
e.preventDefault();
errorBody.innerHTML = "<strong>Error:</strong> El trabajador titular y el acompañante no pueden ser la misma persona. Por favor, selecciona a alguien más.";
errorModal.show();
}
});
});
editModals.forEach(modal => {
// Inicializar badges al abrir
modal.addEventListener('show.bs.modal', function() {
const rid = this.id.replace('editRendicion', '');
updateBadge(this.querySelector('select[name="worker_id"]'), `badge_worker_${rid}`);
const compSelect = this.querySelector('select[name="companion_id"]');
if (compSelect.value) updateBadge(compSelect, `badge_comp_${rid}`);
});
modal.addEventListener('hidden.bs.modal', function () {
const form = this.querySelector('form');
if (form) {
form.reset();
const rid = this.id.replace('editRendicion', '');
calcTotalEdit(rid);
// Resetear los subtotales visuales de productos
this.querySelectorAll('.prod-qty-input').forEach(inp => recalcProductLine(inp));
}
});
});
});
function validarNombresDiferentes(rendicionId) {
const workerSelect = document.querySelector(`select[name="worker_id"]`);
const companionSelect = document.querySelector(`select[name="companion_id"]`);
if (companionSelect.value && workerSelect.value === companionSelect.value) {
alert("Error: El trabajador titular y el acompañante no pueden ser la misma persona.");
return false;
}
return true;
}
// Vincula esta validación al evento submit del formulario de edición
document.querySelectorAll('form[action*="edit_rendicion"]').forEach(form => {
form.addEventListener('submit', function(e) {
const workerSelect = this.querySelector('select[name="worker_id"]');
const companionSelect = this.querySelector('select[name="companion_id"]');
if (companionSelect.value && workerSelect.value === companionSelect.value) {
e.preventDefault();
alert("Un trabajador no puede ser su propio acompañante. Por favor, corrige la selección.");
}
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const zonaSelect = document.getElementById('zonaSelect');
const moduloSelect = document.getElementById('moduloSelect');
const moduloOptions = Array.from(moduloSelect.options);
function filterModulos() {
const selectedZona = zonaSelect.value;
moduloOptions.forEach(option => {
if (option.value === "") {
// Siempre mostramos "Todos los Módulos"
option.style.display = '';
} else if (!selectedZona || option.dataset.zona === selectedZona) {
option.style.display = '';
} else {
option.style.display = 'none';
// Si el módulo seleccionado acaba de ocultarse, reseteamos el select
if (option.selected) {
moduloSelect.value = "";
}
}
});
}
zonaSelect.addEventListener('change', filterModulos);
// Ejecutar al cargar la página por si ya viene con una zona filtrada
filterModulos();
});
</script>
<script src="{{ url_for('static', filename='js/admin_rendiciones.js') }}"></script>
{% endblock %}

View File

@@ -1,48 +1,13 @@
{% extends "macros/base.html" %}
{% from "macros/modals.html" import report_filters %}
{% from "macros/ui.html" import back_link %}
{% block title %}Reporte: Centros Comerciales - {{ modulo_name }}{% endblock %}
{% block styles %}
<style>
.table-container {
max-height: 75vh;
overflow-y: auto;
overflow-x: auto;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 2;
background-color: var(--bs-body-bg);
border-right: 2px solid var(--bs-border-color) !important;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--bs-body-bg);
box-shadow: inset 0 -1px 0 var(--bs-border-color);
}
thead th.sticky-col {
z-index: 3;
}
.numeric-cell {
font-family: 'Courier New', Courier, monospace;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
<i class="bi bi-arrow-left"></i> Volver al Menú
</a>
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
<h2>Registro Centros Comerciales</h2>
</div>
<div class="text-end">
@@ -51,7 +16,7 @@
</div>
</div>
{{ report_filters(
url_for('report_modulo_periodo', modulo_id=modulo_id),
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
workers_list,
worker_actual,
dia_actual,

View File

@@ -1,30 +1,13 @@
{% extends "macros/base.html" %}
{% from "macros/modals.html" import report_filters %}
{% from "macros/ui.html" import back_link %}
{% block title %}Reporte: Comisiones - {{ modulo_name }}{% endblock %}
{% block styles %}
<style>
.numeric-cell {
text-align: right;
font-family: 'Courier New', Courier, monospace;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 10;
background-color: #f8f9fa !important;
border-right: 2px solid #dee2e6;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
<i class="bi bi-arrow-left"></i> Volver al Menú
</a>
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
<h2>Reporte de Comisiones</h2>
</div>
<div class="text-end">
@@ -33,7 +16,7 @@
</div>
</div>
{{ report_filters(
url_for('report_modulo_periodo', modulo_id=modulo_id),
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
workers_list,
worker_actual,
dia_actual,

View File

@@ -1,44 +1,13 @@
{% extends "macros/base.html" %}
{% from "macros/modals.html" import report_filters %}
{% from "macros/ui.html" import back_link %}
{% block title %}Reporte: Horarios - {{ modulo_name }}{% endblock %}
{% block styles %}
<style>
.table-container {
max-height: 75vh;
overflow-y: auto;
overflow-x: auto;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 2;
background-color: var(--bs-body-bg);
border-right: 2px solid var(--bs-border-color) !important;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--bs-body-bg);
box-shadow: inset 0 -1px 0 var(--bs-border-color);
}
thead th.sticky-col {
z-index: 3;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
<i class="bi bi-arrow-left"></i> Volver al Menú
</a>
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
<h2>Control de Horarios</h2>
</div>
<div class="text-end">
@@ -47,7 +16,7 @@
</div>
</div>
{{ report_filters(
url_for('report_modulo_periodo', modulo_id=modulo_id),
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
workers_list,
worker_actual,
dia_actual,

View File

@@ -1,48 +1,13 @@
{% extends "macros/base.html" %}
{% from "macros/modals.html" import report_filters %}
{% from "macros/ui.html" import back_link %}
{% block title %}Reporte: Cálculo de IVA - {{ modulo_name }}{% endblock %}
{% block styles %}
<style>
.table-container {
max-height: 75vh;
overflow-y: auto;
overflow-x: auto;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 2;
background-color: var(--bs-body-bg);
border-right: 2px solid var(--bs-border-color) !important;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--bs-body-bg);
box-shadow: inset 0 -1px 0 var(--bs-border-color);
}
thead th.sticky-col {
z-index: 3;
}
.numeric-cell {
font-family: 'Courier New', Courier, monospace;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
<i class="bi bi-arrow-left"></i> Volver al Menú
</a>
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
<h2>Cálculo de IVA</h2>
</div>
<div class="text-end">
@@ -51,7 +16,7 @@
</div>
</div>
{{ report_filters(
url_for('report_modulo_periodo', modulo_id=modulo_id),
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
workers_list,
worker_actual,
dia_actual,

View File

@@ -1,35 +1,13 @@
{% extends "macros/base.html" %}
{% from "macros/modals.html" import report_filters %}
{% from "macros/ui.html" import back_link %}
{% block title %}Reporte: Finanzas - {{ modulo_name }}{% endblock %}
{% block styles %}
<style>
.numeric-cell {
text-align: right;
font-family: 'Courier New', Courier, monospace;
font-weight: 500;
}
.total-column {
font-weight: bold;
background-color: #e9ecef !important;
}
.sticky-col {
position: sticky;
left: 0;
z-index: 10;
background-color: #f8f9fa !important;
border-right: 2px solid #dee2e6;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
<i class="bi bi-arrow-left"></i> Volver al Menú
</a>
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
<h2>Resumen Financiero y Medios de Pago</h2>
</div>
<div class="text-end">
@@ -74,7 +52,7 @@
</div>
{{ report_filters(
url_for('report_modulo_periodo', modulo_id=modulo_id),
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
workers_list,
worker_actual,
dia_actual,

View File

@@ -17,7 +17,7 @@
<h4 class="text-info border-bottom border-secondary pb-2 mb-4">
<i class="bi bi-geo-alt-fill me-2"></i>Zona: {{ zona_name }}
</h4>
<div class="row g-4">
{% for mod in lista_modulos %}
<div class="col-md-4 col-sm-6">
@@ -45,23 +45,4 @@
{{ reportes_menu_modal(mod[0], mod[1]) }}
{% endfor %}
{% endfor %}
<style>
.hover-shadow:hover {
transform: translateY(-5px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.3)!important;
border-color: #0dcaf0 !important;
}
.hover-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
background-color: #1e2125;
}
.hover-card:hover {
transform: translateY(-3px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.25)!important;
}
.transition-all {
transition: all .3s ease-in-out;
}
</style>
{% endblock %}

View File

@@ -1,18 +1,13 @@
{% extends "macros/base.html" %}
{% import "macros/modals.html" as modals %}
{% from "macros/ui.html" import flashed_messages %}
{% block title %}Estructura Operativa{% endblock %}
{% block content %}
<h2 class="mb-4">Estructura Operativa</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ flashed_messages() }}
<div class="row">
<div class="col-md-6 mb-4">
@@ -21,7 +16,7 @@
<h5 class="mb-0">Gestión de Zonas</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('manage_structure') }}" class="mb-4">
<form method="POST" action="{{ url_for('admin.manage_structure') }}" class="mb-4">
<input type="hidden" name="action" value="add_zona">
<div class="input-group">
<input type="text" class="form-control" name="zona_name" placeholder="Nombre de la Zona (ej: Norte)" required>
@@ -53,7 +48,7 @@
'deleteZona' ~ zona[0],
'Eliminar Zona',
'¿Estás seguro de eliminar la zona "' ~ zona[1] ~ '"? Esto podría afectar a los módulos asociados.',
url_for('delete_structure', type='zona', id=zona[0]),
url_for('admin.delete_structure', type='zona', id=zona[0]),
'btn-danger',
'Eliminar'
) }}
@@ -76,7 +71,7 @@
<h5 class="mb-0">Gestión de Módulos</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('manage_structure') }}" class="mb-4">
<form method="POST" action="{{ url_for('admin.manage_structure') }}" class="mb-4">
<input type="hidden" name="action" value="add_modulo">
<div class="row g-2">
<div class="col-md-5">
@@ -122,7 +117,7 @@
'deleteModulo' ~ modulo[0],
'Eliminar Módulo',
'¿Deseas eliminar el módulo "' ~ modulo[1] ~ '"?',
url_for('delete_structure', type='modulo', id=modulo[0]),
url_for('admin.delete_structure', type='modulo', id=modulo[0]),
'btn-danger',
'Eliminar'
) }}

View File

@@ -1,29 +1,20 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal, edit_worker_modal %}
{% from "macros/ui.html" import flashed_messages %}
{% block title %}Gestión de Trabajadores{% endblock %}
{% block head %}
<!-- HEAD -->
{% endblock %}
{% block content %}
<h2 class="mb-4">Gestión de Trabajadores</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ flashed_messages() }}
{{ edit_worker_modal(modulos) }}
<div class="card mb-4">
<div class="card-header bg-primary text-white">Agregar Nuevo Trabajador</div>
<div class="card-body">
<form method="POST" action="{{ url_for('manage_workers') }}">
<form method="POST" action="{{ url_for('admin.manage_workers') }}">
<div class="row g-3">
<div class="col-md-2">
<label class="form-label">RUT</label>
@@ -134,7 +125,7 @@
id='delWorker' ~ worker[0],
title='Eliminar Trabajador',
message='¿Eliminar a ' ~ worker[2] ~ '?',
action_url=url_for('delete_worker', id=worker[0]),
action_url=url_for('admin.delete_worker', id=worker[0]),
btn_class='btn-danger'
) }}
</td>
@@ -159,116 +150,5 @@
) }}
{% endblock %}
{% block scripts %}
<script>
const editWorkerModal = document.getElementById('editWorkerModal');
const confirmResetModal = document.getElementById('confirmResetPass');
if (editWorkerModal) {
editWorkerModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
// Si el modal se abre desde el botón "Editar" de la tabla
if (button && button.hasAttribute('data-id')) {
const id = button.getAttribute('data-id');
const name = button.getAttribute('data-name');
const editForm = editWorkerModal.querySelector('#editWorkerForm');
const resetForm = confirmResetModal.querySelector('form');
editForm.action = "/admin/workers/edit/" + id;
resetForm.action = "/admin/workers/reset_password/" + id;
confirmResetModal.querySelector('.modal-body').textContent = `¿Estás seguro de generar una nueva contraseña para ${name}? La anterior dejará de funcionar.`;
editWorkerModal.querySelector('#edit_worker_rut').value = button.getAttribute('data-rut');
editWorkerModal.querySelector('#edit_worker_name').value = name;
editWorkerModal.querySelector('#edit_worker_phone').value = button.getAttribute('data-phone');
editWorkerModal.querySelector('#edit_worker_modulo').value = button.getAttribute('data-modulo');
editWorkerModal.querySelector('#edit_worker_tipo').value = button.getAttribute('data-tipo');
}
});
}
// Lógica para reabrir el modal de edición al cancelar el de confirmación
if (confirmResetModal) {
// Buscamos el botón de cancelar dentro del modal de confirmación
const btnCancelar = confirmResetModal.querySelector('.btn-secondary');
const btnCerrarX = confirmResetModal.querySelector('.btn-close');
const reabrirEdicion = () => {
const modalEdicion = new bootstrap.Modal(editWorkerModal);
modalEdicion.show();
};
btnCancelar.addEventListener('click', reabrirEdicion);
btnCerrarX.addEventListener('click', reabrirEdicion);
}
document.getElementById('rutInput').addEventListener('input', function(e) {
let value = this.value.replace(/[^0-9kK]/g, '').toUpperCase();
// Bloquear el largo interno a 9 caracteres (8 cuerpo + 1 dv)
if (value.length > 9) {
value = value.slice(0, 9);
}
if (value.length > 1) {
let body = value.slice(0, -1);
let dv = value.slice(-1);
body = body.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
this.value = `${body}-${dv}`;
} else {
this.value = value;
}
});
function formatPhone(input) {
let value = input.value.replace(/\D/g, '');
if (value.startsWith('56')) value = value.substring(2);
value = value.substring(0, 9);
if (value.length > 5) input.value = value.replace(/(\d{1})(\d{4})(\d+)/, '$1 $2 $3');
else if (value.length > 1) input.value = value.replace(/(\d{1})(\d+)/, '$1 $2');
else input.value = value;
}
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>
<script src="{{ url_for('static', filename='js/admin_workers.js') }}"></script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "macros/base.html" %}
{% from "macros/ui.html" import flashed_messages %}
{% block title %}Inicio de sesión{% endblock %}
@@ -10,14 +11,8 @@
<h4 class="mb-0">Iniciar Sesión</h4>
</div>
<div class="card-body p-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ flashed_messages() }}
<form method="POST">
<div class="mb-3">
<label class="form-label">RUT</label>
@@ -43,22 +38,5 @@
{% endblock %}
{% block scripts %}
<script>
document.getElementById('rutInput').addEventListener('input', function(e) {
let value = this.value.replace(/[^0-9kK]/g, '').toUpperCase();
if (value.length > 9) {
value = value.slice(0, 9);
}
if (value.length > 1) {
let body = value.slice(0, -1);
let dv = value.slice(-1);
body = body.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
this.value = `${body}-${dv}`;
} else {
this.value = value;
}
});
</script>
{% endblock %}
<script src="{{ url_for('static', filename='js/login.js') }}"></script>
{% endblock %}

View File

@@ -6,6 +6,8 @@
<title>Sistema de Rendiciones - {% block title %}{% endblock %}</title>
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/x-icon">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/report-tables.css') }}">
<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">
</head>
@@ -21,7 +23,9 @@
<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>
<script src="{{ url_for('static', filename='js/format-helpers.js') }}"></script>
<script src="{{ url_for('static', filename='js/navbar.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
</html>

View File

@@ -275,7 +275,7 @@
</div>
<div>
<span class="text-muted d-block mb-1">Observaciones:</span>
<p class="mb-0 bg-dark p-2 rounded border border-secondary text-wrap text-break" style="font-size: 0.9em;">
<p class="mb-0 bg-body-tertiary p-2 rounded border border-secondary text-wrap text-break" style="font-size: 0.9em;">
{{ rendicion[9] if rendicion[9] else "Sin observaciones." }}
</p>
</div>
@@ -301,7 +301,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<form method="POST" action="{{ url_for('edit_rendicion', id=rendicion[0]) }}" id="editRendicionForm_{{ rendicion[0] }}">
<form method="POST" action="{{ url_for('admin.edit_rendicion', id=rendicion[0]) }}" id="editRendicionForm_{{ rendicion[0] }}">
<div class="row">
<div class="col-md-8 mb-3">
<div class="card shadow-sm h-100">
@@ -400,32 +400,32 @@
<label class="small text-muted mb-0">Débito</label>
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_debito_{{ rendicion[0] }}" name="venta_debito" value="{{ '{:,.0f}'.format(rendicion[4] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
<div class="input-group input-group-sm">
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_debito" value="{{ rendicion[16] or 0 }}">
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_debito" value="{{ rendicion[16] or 0 }}">
</div>
</div>
<div class="col-6">
<label class="small text-muted mb-0">Crédito</label>
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_credito_{{ rendicion[0] }}" name="venta_credito" value="{{ '{:,.0f}'.format(rendicion[5] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
<div class="input-group input-group-sm">
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_credito" value="{{ rendicion[17] or 0 }}">
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_credito" value="{{ rendicion[17] or 0 }}">
</div>
</div>
<div class="col-6">
<label class="small text-muted mb-0">Mercado Pago</label>
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_mp_{{ rendicion[0] }}" name="venta_mp" value="{{ '{:,.0f}'.format(rendicion[6] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
<div class="input-group input-group-sm">
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_mp" value="{{ rendicion[18] or 0 }}">
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_mp" value="{{ rendicion[18] or 0 }}">
</div>
</div>
<div class="col-6">
<label class="small text-muted mb-0">Efectivo</label>
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_efectivo_{{ rendicion[0] }}" name="venta_efectivo" value="{{ '{:,.0f}'.format(rendicion[7] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
<div class="input-group input-group-sm">
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_efectivo" value="{{ rendicion[19] or 0 }}">
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_efectivo" value="{{ rendicion[19] or 0 }}">
</div>
</div>
</div>
@@ -443,13 +443,13 @@
<div class="mb-2">
<label class="small text-danger fw-bold">Monto Gastos</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-dark border-danger text-danger">-$</span>
<span class="input-group-text bg-danger-subtle border-danger text-danger">-$</span>
<input type="text" class="form-control money-input border-danger text-end" name="gastos" value="{{ '{:,.0f}'.format(rendicion[8] or 0).replace(',', '.') }}" required>
</div>
</div>
<div class="mb-0">
<label class="small text-muted">Observaciones</label>
<textarea class="form-control form-control-sm bg-dark text-white" name="observaciones" rows="2">{{ rendicion[9] }}</textarea>
<textarea class="form-control form-control-sm bg-body text-body" name="observaciones" rows="2">{{ rendicion[9] }}</textarea>
</div>
</div>
</div>
@@ -486,7 +486,7 @@
<h5 class="card-title mb-0">Detalle de Ventas</h5>
</div>
<p class="text-muted small flex-grow-1">Análisis detallado de ventas diarias, productos vendidos y consolidado mensual.</p>
<a href="{{ url_for('report_modulo_periodo', modulo_id=modulo_id) }}" class="btn btn-primary w-100 mt-3">Generar Reporte</a>
<a href="{{ url_for('admin.report_modulo_periodo', modulo_id=modulo_id) }}" class="btn btn-primary w-100 mt-3">Generar Reporte</a>
</div>
</div>
</div>
@@ -501,7 +501,7 @@
<h5 class="card-title mb-0">Comisiones</h5>
</div>
<p class="text-muted small flex-grow-1">Cálculo de comisiones generadas por los trabajadores en este módulo.</p>
<a href="{{ url_for('report_modulo_comisiones', modulo_id=modulo_id) }}" class="btn btn-success w-100 mt-3">Generar Reporte</a>
<a href="{{ url_for('admin.report_modulo_comisiones', modulo_id=modulo_id) }}" class="btn btn-success w-100 mt-3">Generar Reporte</a>
</div>
</div>
</div>
@@ -516,7 +516,7 @@
<h5 class="card-title mb-0">Control de Horarios</h5>
</div>
<p class="text-muted small flex-grow-1">Registro de horas de entrada y salida de los trabajadores y acompañantes.</p>
<a href="{{ url_for('report_modulo_horarios', modulo_id=modulo_id) }}" class="btn btn-warning w-100 mt-3">Generar Reporte</a>
<a href="{{ url_for('admin.report_modulo_horarios', modulo_id=modulo_id) }}" class="btn btn-warning w-100 mt-3">Generar Reporte</a>
</div>
</div>
</div>
@@ -531,7 +531,7 @@
<h5 class="card-title mb-0">Centros Comerciales</h5>
</div>
<p class="text-muted small flex-grow-1">Reporte de datos exigidos por la administración del centro comercial.</p>
<a href="{{ url_for('report_modulo_centros_comerciales', modulo_id=modulo_id) }}" class="btn btn-info w-100 mt-3">Generar Reporte</a>
<a href="{{ url_for('admin.report_modulo_centros_comerciales', modulo_id=modulo_id) }}" class="btn btn-info w-100 mt-3">Generar Reporte</a>
</div>
</div>
</div>
@@ -546,7 +546,7 @@
<h5 class="card-title mb-0">Cálculo de IVA</h5>
</div>
<p class="text-muted small flex-grow-1">Proyección de impuestos basados en las ventas declaradas.</p>
<a href="{{ url_for('report_modulo_calculo_iva', modulo_id=modulo_id) }}" class="btn btn-secondary w-100 mt-3">Generar Reporte</a>
<a href="{{ url_for('admin.report_modulo_calculo_iva', modulo_id=modulo_id) }}" class="btn btn-secondary w-100 mt-3">Generar Reporte</a>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary shadow-sm border-bottom mb-4">
<div class="container">
<a class="navbar-brand d-flex flex-column align-items-start text-primary-emphasis" href="{{ url_for('index') }}" style="gap: 0;">
<a class="navbar-brand d-flex flex-column align-items-start text-primary-emphasis" href="{{ url_for('auth.index') }}" style="gap: 0;">
<div class="d-flex align-items-center">
<i id="brandIcon" class="bi bi-receipt-cutoff fs-3 text-info me-2"></i>
@@ -21,33 +21,33 @@
<ul class="navbar-nav me-auto">
{% if session.get('is_admin') %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if 'rendiciones' in request.path }}" href="{{ url_for('admin_rendiciones') }}">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if 'rendiciones' in request.path }}" href="{{ url_for('admin.admin_rendiciones') }}">
<i class="bi bi-journal-text me-1"></i> Rendiciones
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'manage_workers' }}" href="{{ url_for('manage_workers') }}">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'admin.manage_workers' }}" href="{{ url_for('admin.manage_workers') }}">
<i class="bi bi-people me-1"></i> Trabajadores
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'manage_structure' }}" href="{{ url_for('manage_structure') }}">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'admin.manage_structure' }}" href="{{ url_for('admin.manage_structure') }}">
<i class="bi bi-diagram-3 me-1"></i> Estructura
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'manage_products' }}" href="{{ url_for('manage_products') }}">
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'admin.manage_products' }}" href="{{ url_for('admin.manage_products') }}">
<i class="bi bi-box-seam me-1"></i> Productos
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'reporte' in request.endpoint %}active fw-bold text-white{% endif %}" href="{{ url_for('admin_reportes_index') }}">
<a class="nav-link {% if request.endpoint and 'reporte' in request.endpoint %}active fw-bold text-white{% endif %}" href="{{ url_for('admin.admin_reportes_index') }}">
<i class="bi bi-graph-up me-1"></i> Reportes
</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link {{ 'active fw-bold' if request.endpoint == 'worker_dashboard' }}" href="{{ url_for('worker_dashboard') }}">
<a class="nav-link {{ 'active fw-bold' if request.endpoint == 'worker.worker_dashboard' }}" href="{{ url_for('worker.worker_dashboard') }}">
<i class="bi bi-speedometer2 me-1"></i> Mis Rendiciones
</a>
</li>
@@ -63,7 +63,7 @@
<i class="bi bi-person-circle me-1 text-info"></i> {{ session.get('rut') }}
</span>
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger btn-sm rounded-pill px-3">
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger btn-sm rounded-pill px-3">
Salir
</a>
</div>
@@ -72,52 +72,3 @@
</div>
</nav>
<style>
@keyframes baguetteRoll {
0% { transform: rotate(0deg) scale(1.2); }
50% { transform: rotate(180deg) scale(1.5); }
100% { transform: rotate(360deg) scale(1); }
}
.baguette-spin {
display: inline-block;
animation: baguetteRoll 1s ease-in-out;
font-style: normal;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
const brandIcon = document.getElementById("brandIcon");
if (brandIcon) {
let clickCount = 0;
let clickResetTimer;
brandIcon.addEventListener("click", function(e) {
e.preventDefault();
e.stopPropagation();
clickCount++;
clearTimeout(clickResetTimer);
clickResetTimer = setTimeout(() => {
clickCount = 0;
}, 800);
if (clickCount >= 5) {
clickCount = 0;
clearTimeout(clickResetTimer);
const originalClass = this.className;
this.className = "fs-3 me-2 baguette-spin";
this.innerHTML = "&#129366;";
setTimeout(() => {
this.className = originalClass;
this.innerHTML = "";
}, 1000);
}
});
}
});
</script>

22
templates/macros/ui.html Normal file
View File

@@ -0,0 +1,22 @@
{% macro flashed_messages() %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endmacro %}
{% macro back_link(url, text='Volver', icon='bi-arrow-left') %}
<a href="{{ url }}" class="btn btn-outline-secondary btn-sm mb-2">
<i class="bi {{ icon }}"></i> {{ text }}
</a>
{% endmacro %}
{% macro page_header(title, icon='', actions='') %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>{% if icon %}<i class="bi {{ icon }} me-2"></i>{% endif %}{{ title }}</h2>
{% if actions %}{{ actions|safe }}{% endif %}
</div>
{% endmacro %}

View File

@@ -1,19 +1,14 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal, alert_modal %}
{% from "macros/ui.html" import flashed_messages, back_link %}
{% block title %}Rendición de Caja{% endblock %}
{% block head %}
<!-- HEAD -->
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('worker_dashboard') }}" class="btn btn-outline-secondary btn-sm mb-2">
<i class="bi bi-arrow-left"></i> Volver al Historial
</a>
{{ back_link(url_for('worker.worker_dashboard'), 'Volver al Historial') }}
<h2>Nueva Rendición de Caja</h2>
</div>
<div class="text-end text-muted">
@@ -22,13 +17,7 @@
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ flashed_messages() }}
<form method="POST">
<div class="card mb-4 shadow-sm">
@@ -241,228 +230,5 @@
{% endblock %}
{% block scripts %}
<script>
document.getElementById('companion_select').addEventListener('change', function() {
const timeDiv = document.getElementById('companion_times_div');
const compIn = document.getElementById('comp_in');
const compOut = document.getElementById('comp_out');
if (this.value) {
timeDiv.style.display = 'block';
compIn.required = true;
compOut.required = true;
} else {
timeDiv.style.display = 'none';
compIn.required = false;
compOut.required = false;
compIn.value = '';
compOut.value = '';
}
});
</script>
<script>
const inputsCantidad = document.querySelectorAll('input[name^="qty_"]');
const displayTotalProductos = document.getElementById('total_productos_calc');
function calcularVentaProductos() {
let granTotal = 0;
const filas = document.querySelectorAll('tbody tr');
filas.forEach(fila => {
const inputQty = fila.querySelector('input[name^="qty_"]');
if (inputQty) {
if (parseInt(inputQty.value) < 0) {
inputQty.value = 0;
}
const cantidad = parseInt(inputQty.value) || 0;
const precioTexto = fila.cells[1].innerText.replace(/\D/g, '');
const precio = parseInt(precioTexto) || 0;
granTotal += (cantidad * precio);
}
});
displayTotalProductos.value = granTotal.toLocaleString('es-CL');
checkWarnings();
}
inputsCantidad.forEach(input => {
input.addEventListener('keydown', function(e) {
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
if (e.key < '0' || e.key > '9') {
e.preventDefault();
}
});
input.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '');
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>
document.addEventListener('DOMContentLoaded', function() {
const submitModal = document.getElementById('confirmSubmitModal');
const mainForm = document.querySelector('form');
const alertModalEl = document.getElementById('globalAlertModal');
const alertModal = new bootstrap.Modal(alertModalEl);
const confirmBtn = submitModal.querySelector('button[type="submit"]');
function mostrarError(mensaje) {
document.getElementById('globalAlertModalBody').textContent = mensaje;
alertModal.show();
}
function validarFormulario() {
const requiredInputs = mainForm.querySelectorAll('[required]');
let valid = true;
requiredInputs.forEach(input => {
const isMoney = input.classList.contains('money-input');
if (!input.value.trim() || (isMoney && input.value === '')) {
input.classList.add('is-invalid');
valid = false;
} else {
input.classList.remove('is-invalid');
}
});
return valid;
}
confirmBtn.addEventListener('click', function(e) {
e.preventDefault();
if (validarFormulario()) {
mainForm.submit();
} else {
bootstrap.Modal.getInstance(submitModal).hide();
mostrarError("Por favor, rellena los campos obligatorios (Fecha y Hora) antes de enviar.");
}
});
document.querySelectorAll('.money-input').forEach(input => {
if (!input.value.trim()) input.value = '0';
});
});
</script>
<script>
const inputsVenta = document.querySelectorAll('.sale-input');
const displayDigital = document.getElementById('total_digital');
const displayGeneral = document.getElementById('total_general');
function calcularTotales() {
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 debito = getVal('venta_debito');
const credito = getVal('venta_credito');
const mp = getVal('venta_mp');
const efectivo = getVal('venta_efectivo');
const totalDigital = debito + credito + mp;
const totalGeneral = totalDigital + efectivo; // Ya no restamos los gastos aquí
document.getElementById('total_digital').value = totalDigital.toLocaleString('es-CL');
document.getElementById('total_general').value = totalGeneral.toLocaleString('es-CL');
checkWarnings();
}
inputsVenta.forEach(input => {
input.addEventListener('input', calcularTotales);
});
document.querySelector('form').addEventListener('submit', function(e) {
const requiredInputs = this.querySelectorAll('[required]');
let valid = true;
requiredInputs.forEach(input => {
const isMoney = input.classList.contains('money-input');
if (!input.value.trim() || (isMoney && input.value === '')) {
input.classList.add('is-invalid');
valid = false;
} else {
input.classList.remove('is-invalid');
}
});
if (!valid) {
e.preventDefault();
const alertModalEl = document.getElementById('globalAlertModal');
if (alertModalEl) {
const alertModal = bootstrap.Modal.getOrCreateInstance(alertModalEl);
document.getElementById('globalAlertModalBody').textContent = "Por favor, rellena todos los campos obligatorios antes de enviar.";
alertModal.show();
} else {
alert("Por favor, rellena todos los campos obligatorios.");
}
}
});
document.querySelectorAll('.money-input').forEach(function(input) {
input.addEventListener('keydown', function(e) {
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
if (e.key < '0' || e.key > '9') {
e.preventDefault();
}
});
input.addEventListener('focus', function() {
if (this.value === '0') this.value = '';
});
input.addEventListener('blur', function() {
if (this.value.trim() === '' || this.value.trim() === '0') {
this.value = '0';
}
calcularTotales();
});
input.addEventListener('input', function() {
let value = this.value.replace(/\D/g, '');
if (value !== '') {
this.value = parseInt(value, 10).toLocaleString('es-CL');
}
calcularTotales();
});
});
</script>
<script src="{{ url_for('static', filename='js/worker_dashboard.js') }}"></script>
{% endblock %}

View File

@@ -1,23 +1,18 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import rendicion_detail_modal %}
{% from "macros/ui.html" import flashed_messages %}
{% block title %}Mis Rendiciones{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 gap-3">
<h2 class="mb-0">Mis Rendiciones</h2>
<a href="{{ url_for('new_rendicion') }}" class="btn btn-success shadow-sm align-self-start align-self-md-auto">
<a href="{{ url_for('worker.new_rendicion') }}" class="btn btn-success shadow-sm align-self-start align-self-md-auto">
<i class="bi bi-plus-circle me-2"></i>Nueva Rendición
</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{{ flashed_messages() }}
<div class="card shadow-sm border-0">
<div class="card-body p-0">

View File

@@ -43,7 +43,7 @@ def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('index'))
return redirect(url_for('auth.index'))
return f(*args, **kwargs)
return decorated_function
@@ -52,7 +52,7 @@ def admin_required(f):
def decorated_function(*args, **kwargs):
if 'user_id' not in session or not session.get('is_admin'):
flash("Acceso denegado. Se requieren permisos de administrador.", "danger")
return redirect(url_for('index'))
return redirect(url_for('auth.index'))
return f(*args, **kwargs)
return decorated_function
@@ -63,24 +63,4 @@ def get_report_params():
mes = request.args.get('mes', f"{hoy.month:02d}")
dia = request.args.get('dia')
worker_id = request.args.get('worker_id')
return anio, mes, dia, worker_id
def get_common_report_data(c, modulo_id):
"""Obtiene nombre del módulo y lista de trabajadores para los filtros."""
c.execute("SELECT name FROM modulos WHERE id = ?", (modulo_id,))
modulo_info = c.fetchone()
c.execute('''
SELECT DISTINCT w.id, w.name
FROM workers w
WHERE w.modulo_id = ? AND w.is_admin = 0
ORDER BY w.name
''', (modulo_id,))
workers = c.fetchall()
c.execute("SELECT DISTINCT strftime('%Y', fecha) as anio FROM rendiciones ORDER BY anio DESC")
anios = [row[0] for row in c.fetchall()]
if str(date.today().year) not in anios:
anios.insert(0, str(date.today().year))
return modulo_info[0] if modulo_info else "Módulo", workers, anios
return anio, mes, dia, worker_id