diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0148436 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile index e8a25f7..d3b5509 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["python", "app.py"] diff --git a/app.py b/app.py index 2974e5f..67c8d76 100644 --- a/app.py +++ b/app.py @@ -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) \ No newline at end of file + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/docker-compose.yml b/docker-compose.yml index 33f94e6..9ac1886 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + 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 diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/models.py b/models/models.py new file mode 100644 index 0000000..73f8d68 --- /dev/null +++ b/models/models.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 0545178..78684e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +Flask-SQLAlchemy==3.1.1 +Werkzeug==3.1.6 +SQLAlchemy==2.0.45 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin_bp.py b/routes/admin_bp.py new file mode 100644 index 0000000..61f1c07 --- /dev/null +++ b/routes/admin_bp.py @@ -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: {password}", "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/', 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/', 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/', 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: {new_password}", "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//', 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/', 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/', 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/', 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//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/', 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/', 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/') +@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//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//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//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//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) diff --git a/routes/auth_bp.py b/routes/auth_bp.py new file mode 100644 index 0000000..d5954c0 --- /dev/null +++ b/routes/auth_bp.py @@ -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')) diff --git a/routes/worker_bp.py b/routes/worker_bp.py new file mode 100644 index 0000000..5b33a49 --- /dev/null +++ b/routes/worker_bp.py @@ -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')) diff --git a/routes_admin.py b/routes_admin.py deleted file mode 100644 index 768fb4e..0000000 --- a/routes_admin.py +++ /dev/null @@ -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: {password}", "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/', 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/', 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/', 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: {new_password}", "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//', 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/', 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/', 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/', 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//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/', 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/', 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/') - @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//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//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//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//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) \ No newline at end of file diff --git a/routes_auth.py b/routes_auth.py deleted file mode 100644 index ee0f8cb..0000000 --- a/routes_auth.py +++ /dev/null @@ -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')) \ No newline at end of file diff --git a/routes_worker.py b/routes_worker.py deleted file mode 100644 index 1786b69..0000000 --- a/routes_worker.py +++ /dev/null @@ -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')) \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/rendiciones_service.py b/services/rendiciones_service.py new file mode 100644 index 0000000..374b76f --- /dev/null +++ b/services/rendiciones_service.py @@ -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 diff --git a/services/report_service.py b/services/report_service.py new file mode 100644 index 0000000..f02f640 --- /dev/null +++ b/services/report_service.py @@ -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, + } diff --git a/static/css/components.css b/static/css/components.css new file mode 100644 index 0000000..d65711a --- /dev/null +++ b/static/css/components.css @@ -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; +} diff --git a/static/css/report-tables.css b/static/css/report-tables.css new file mode 100644 index 0000000..386328d --- /dev/null +++ b/static/css/report-tables.css @@ -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; +} diff --git a/static/js/admin_productos.js b/static/js/admin_productos.js new file mode 100644 index 0000000..f229266 --- /dev/null +++ b/static/js/admin_productos.js @@ -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'; + }); + }); + } +}); diff --git a/static/js/admin_rendiciones.js b/static/js/admin_rendiciones.js new file mode 100644 index 0000000..6ce69a6 --- /dev/null +++ b/static/js/admin_rendiciones.js @@ -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 = `${tipo}`; + }; + + 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 = "Error: 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(); + } +}); diff --git a/static/js/admin_workers.js b/static/js/admin_workers.js new file mode 100644 index 0000000..ad1408a --- /dev/null +++ b/static/js/admin_workers.js @@ -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); +}); diff --git a/static/js/format-helpers.js b/static/js/format-helpers.js new file mode 100644 index 0000000..9032931 --- /dev/null +++ b/static/js/format-helpers.js @@ -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 + }; +})(); diff --git a/static/js/login.js b/static/js/login.js new file mode 100644 index 0000000..106e814 --- /dev/null +++ b/static/js/login.js @@ -0,0 +1,3 @@ +document.addEventListener("DOMContentLoaded", function () { + window.formatHelpers.bindRutInput('#rutInput'); +}); diff --git a/static/js/navbar.js b/static/js/navbar.js new file mode 100644 index 0000000..4c82227 --- /dev/null +++ b/static/js/navbar.js @@ -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 = "🥖"; + + setTimeout(() => { + this.className = originalClass; + this.innerHTML = ""; + }, 1000); + } + }); +}); diff --git a/static/js/product-history-chart.js b/static/js/product-history-chart.js new file mode 100644 index 0000000..9c97804 --- /dev/null +++ b/static/js/product-history-chart.js @@ -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') } + } + } + } + }); + }; +}); diff --git a/static/js/worker_dashboard.js b/static/js/worker_dashboard.js new file mode 100644 index 0000000..155cd72 --- /dev/null +++ b/static/js/worker_dashboard.js @@ -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 Total Venta por Productos no coincide con el Total Ventas Declaradas."); + } + if (gastos > efectivo) { + warnings.push("El Monto de Gastos es mayor que el Efectivo declarado."); + } + + const warningContainer = document.getElementById('discrepancy_warning'); + const warningText = document.getElementById('discrepancy_text'); + if (warnings.length > 0) { + warningText.innerHTML = warnings.join(""); + 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'; + }); +}); diff --git a/static/style.css b/static/style.css index 098474d..b4288b3 100644 --- a/static/style.css +++ b/static/style.css @@ -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); -} \ No newline at end of file +[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; + } +} diff --git a/templates/admin_productos.html b/templates/admin_productos.html index 6cca5b8..d718f9c 100644 --- a/templates/admin_productos.html +++ b/templates/admin_productos.html @@ -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 %} - -{% endblock %} - {% block content %} Catálogo de Productos por Zona -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - {{ message|safe }} - {% endfor %} - {% endif %} -{% endwith %} +{{ flashed_messages() }} {{ edit_product_modal(zonas) }} Agregar Producto Maestro - + @@ -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 @@ - + Precios: {{ prod.name }} @@ -156,7 +147,7 @@ ${{ "{:,.0f}".format(futuro.price).replace(',', '.') }} ${{ "{:,.0f}".format(futuro.commission).replace(',', '.') }} - + @@ -196,132 +187,6 @@ {% block scripts %} - - - - + + {% endblock %} \ No newline at end of file diff --git a/templates/admin_rendiciones.html b/templates/admin_rendiciones.html index 8675b34..e41e264 100644 --- a/templates/admin_rendiciones.html +++ b/templates/admin_rendiciones.html @@ -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 %} Historial de Rendiciones @@ -13,7 +11,7 @@ - + Año @@ -77,13 +75,7 @@ -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - {{ message|safe }} - {% endfor %} - {% endif %} -{% endwith %} +{{ flashed_messages() }} @@ -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 %} - - - + {% endblock %} \ No newline at end of file diff --git a/templates/admin_report_cc.html b/templates/admin_report_cc.html index 9e3a996..d584b92 100644 --- a/templates/admin_report_cc.html +++ b/templates/admin_report_cc.html @@ -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 %} - -{% endblock %} - {% block content %} - - Volver al Menú - + {{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }} Registro Centros Comerciales @@ -51,7 +16,7 @@ {{ 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, diff --git a/templates/admin_report_comisiones.html b/templates/admin_report_comisiones.html index e7db5f1..8b4cfea 100644 --- a/templates/admin_report_comisiones.html +++ b/templates/admin_report_comisiones.html @@ -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 %} - -{% endblock %} - {% block content %} - - Volver al Menú - + {{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }} Reporte de Comisiones @@ -33,7 +16,7 @@ {{ 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, diff --git a/templates/admin_report_horarios.html b/templates/admin_report_horarios.html index 89ae014..7e731ab 100644 --- a/templates/admin_report_horarios.html +++ b/templates/admin_report_horarios.html @@ -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 %} - -{% endblock %} - {% block content %} - - Volver al Menú - + {{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }} Control de Horarios @@ -47,7 +16,7 @@ {{ 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, diff --git a/templates/admin_report_iva.html b/templates/admin_report_iva.html index befa518..34bf7bd 100644 --- a/templates/admin_report_iva.html +++ b/templates/admin_report_iva.html @@ -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 %} - -{% endblock %} - {% block content %} - - Volver al Menú - + {{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }} Cálculo de IVA @@ -51,7 +16,7 @@ {{ 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, diff --git a/templates/admin_report_modulo.html b/templates/admin_report_modulo.html index aea257d..14713c6 100644 --- a/templates/admin_report_modulo.html +++ b/templates/admin_report_modulo.html @@ -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 %} - -{% endblock %} - {% block content %} - - Volver al Menú - + {{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }} Resumen Financiero y Medios de Pago @@ -74,7 +52,7 @@ {{ 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, diff --git a/templates/admin_reportes_index.html b/templates/admin_reportes_index.html index 57005ca..2ea86bb 100644 --- a/templates/admin_reportes_index.html +++ b/templates/admin_reportes_index.html @@ -17,7 +17,7 @@ Zona: {{ zona_name }} - + {% for mod in lista_modulos %} @@ -45,23 +45,4 @@ {{ reportes_menu_modal(mod[0], mod[1]) }} {% endfor %} {% endfor %} - - {% endblock %} \ No newline at end of file diff --git a/templates/admin_structure.html b/templates/admin_structure.html index 7dcfbaa..291f6bd 100644 --- a/templates/admin_structure.html +++ b/templates/admin_structure.html @@ -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 %} Estructura Operativa -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - {{ message|safe }} - {% endfor %} - {% endif %} -{% endwith %} +{{ flashed_messages() }} @@ -21,7 +16,7 @@ Gestión de Zonas - + @@ -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 @@ Gestión de Módulos - + @@ -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' ) }} diff --git a/templates/admin_workers.html b/templates/admin_workers.html index c11ac05..15f0e23 100644 --- a/templates/admin_workers.html +++ b/templates/admin_workers.html @@ -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 %} - -{% endblock %} - {% block content %} Gestión de Trabajadores -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - {{ message|safe }} - {% endfor %} - {% endif %} -{% endwith %} +{{ flashed_messages() }} {{ edit_worker_modal(modulos) }} Agregar Nuevo Trabajador - + RUT @@ -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' ) }} @@ -159,116 +150,5 @@ ) }} {% endblock %} {% block scripts %} - - + {% endblock %} diff --git a/templates/login.html b/templates/login.html index 650e680..5b7d88b 100644 --- a/templates/login.html +++ b/templates/login.html @@ -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 @@ Iniciar Sesión - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - {{ message|safe }} - {% endfor %} - {% endif %} - {% endwith %} - + {{ flashed_messages() }} + RUT @@ -43,22 +38,5 @@ {% endblock %} {% block scripts %} - -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/templates/macros/base.html b/templates/macros/base.html index 866340f..2eab3a1 100644 --- a/templates/macros/base.html +++ b/templates/macros/base.html @@ -6,6 +6,8 @@ Sistema de Rendiciones - {% block title %}{% endblock %} + + @@ -21,7 +23,9 @@ - + + + {% block scripts %}{% endblock %}