Major Refactor: Refactor the codebase to improve readability and maintainability
This commit is contained in:
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
tests/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
README.md
|
||||
LICENSE
|
||||
build-deploy.sh
|
||||
generar_unificado.py
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
33
Dockerfile
33
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"]
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
22
app.py
22
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)
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
name: rendiciones
|
||||
|
||||
services:
|
||||
rendiciones:
|
||||
ports:
|
||||
- 5500:5000
|
||||
volumes:
|
||||
- /home/shironeko/Documents/dockerVols/rendiciones/db:/app/db
|
||||
- /home/shironeko/Documents/dockerVols/rendiciones/static/cache:/app/static/cache
|
||||
container_name: rendiciones-server
|
||||
image: rendiciones:latest
|
||||
restart: unless-stopped
|
||||
rendiciones:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: rendiciones:latest
|
||||
container_name: rendiciones-server
|
||||
ports:
|
||||
- "5500:5000"
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- PYTHONUNBUFFERED=1
|
||||
volumes:
|
||||
- /home/shironeko/Documents/dockerVols/rendiciones/db:/app/db
|
||||
- /home/shironeko/Documents/dockerVols/rendiciones/static/cache:/app/static/cache
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:5000/login"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
0
models/__init__.py
Normal file
0
models/__init__.py
Normal file
96
models/models.py
Normal file
96
models/models.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
_TABLE_ARGS = {'sqlite_autoincrement': True}
|
||||
|
||||
|
||||
class Zona(db.Model):
|
||||
__tablename__ = 'zonas'
|
||||
__table_args__ = _TABLE_ARGS
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String, unique=True, nullable=False)
|
||||
|
||||
|
||||
class Modulo(db.Model):
|
||||
__tablename__ = 'modulos'
|
||||
__table_args__ = _TABLE_ARGS
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
zona_id = db.Column(db.Integer, db.ForeignKey('zonas.id'), nullable=False)
|
||||
name = db.Column(db.String, nullable=False)
|
||||
|
||||
|
||||
class Producto(db.Model):
|
||||
__tablename__ = 'productos'
|
||||
__table_args__ = _TABLE_ARGS
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String, unique=True, nullable=False)
|
||||
|
||||
|
||||
class PrecioHistorico(db.Model):
|
||||
__tablename__ = 'precios_historicos'
|
||||
__table_args__ = _TABLE_ARGS
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
producto_id = db.Column(db.Integer, db.ForeignKey('productos.id'), nullable=False)
|
||||
zona_id = db.Column(db.Integer, db.ForeignKey('zonas.id'), nullable=False)
|
||||
price = db.Column(db.Integer, nullable=False)
|
||||
commission = db.Column(db.Integer, nullable=False)
|
||||
fecha_activacion = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
|
||||
class Worker(db.Model):
|
||||
__tablename__ = 'workers'
|
||||
__table_args__ = _TABLE_ARGS
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
rut = db.Column(db.String, unique=True, nullable=False)
|
||||
name = db.Column(db.String, nullable=False)
|
||||
phone = db.Column(db.String, nullable=False)
|
||||
password_hash = db.Column(db.String, nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
modulo_id = db.Column(db.Integer, db.ForeignKey('modulos.id'))
|
||||
tipo = db.Column(db.String, default='Full Time')
|
||||
|
||||
|
||||
class Rendicion(db.Model):
|
||||
__tablename__ = 'rendiciones'
|
||||
__table_args__ = _TABLE_ARGS
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
worker_id = db.Column(db.Integer, db.ForeignKey('workers.id'), nullable=False)
|
||||
worker_comision = db.Column(db.Boolean, default=True)
|
||||
companion_id = db.Column(db.Integer, db.ForeignKey('workers.id'))
|
||||
modulo_id = db.Column(db.Integer, db.ForeignKey('modulos.id'), nullable=False)
|
||||
fecha = db.Column(db.Date, nullable=False)
|
||||
hora_entrada = db.Column(db.String, nullable=False)
|
||||
hora_salida = db.Column(db.String, nullable=False)
|
||||
companion_hora_entrada = db.Column(db.String)
|
||||
companion_hora_salida = db.Column(db.String)
|
||||
companion_comision = db.Column(db.Boolean, default=False)
|
||||
venta_debito = db.Column(db.Integer, default=0)
|
||||
venta_credito = db.Column(db.Integer, default=0)
|
||||
venta_mp = db.Column(db.Integer, default=0)
|
||||
venta_efectivo = db.Column(db.Integer, default=0)
|
||||
boletas_debito = db.Column(db.Integer, default=0)
|
||||
boletas_credito = db.Column(db.Integer, default=0)
|
||||
boletas_mp = db.Column(db.Integer, default=0)
|
||||
boletas_efectivo = db.Column(db.Integer, default=0)
|
||||
gastos = db.Column(db.Integer, default=0)
|
||||
observaciones = db.Column(db.Text)
|
||||
|
||||
|
||||
class RendicionItem(db.Model):
|
||||
__tablename__ = 'rendicion_items'
|
||||
__table_args__ = _TABLE_ARGS
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
rendicion_id = db.Column(db.Integer, db.ForeignKey('rendiciones.id'), nullable=False)
|
||||
producto_id = db.Column(db.Integer, db.ForeignKey('productos.id'), nullable=False)
|
||||
cantidad = db.Column(db.Integer, nullable=False)
|
||||
precio_historico = db.Column(db.Integer, nullable=False)
|
||||
comision_historica = db.Column(db.Integer, nullable=False)
|
||||
@@ -1,5 +1,4 @@
|
||||
Flask==3.1.3
|
||||
Flask-Login==0.6.3
|
||||
Flask-SocketIO==5.6.1
|
||||
requests==2.32.5
|
||||
eventlet==0.36.1
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Werkzeug==3.1.6
|
||||
SQLAlchemy==2.0.45
|
||||
|
||||
0
routes/__init__.py
Normal file
0
routes/__init__.py
Normal file
635
routes/admin_bp.py
Normal file
635
routes/admin_bp.py
Normal file
@@ -0,0 +1,635 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
from werkzeug.security import generate_password_hash
|
||||
from sqlalchemy import func, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from datetime import date, datetime
|
||||
|
||||
from models.models import (
|
||||
db, Zona, Modulo, Producto, PrecioHistorico,
|
||||
Worker, Rendicion, RendicionItem,
|
||||
)
|
||||
from utils import (
|
||||
admin_required, validate_rut, format_rut, validate_phone,
|
||||
format_phone, generate_random_password, get_report_params,
|
||||
)
|
||||
from services import report_service, rendiciones_service
|
||||
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WORKERS
|
||||
# ============================================================
|
||||
|
||||
@admin_bp.route('/workers', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def manage_workers():
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
raw_rut = request.form['rut']
|
||||
raw_phone = request.form['phone']
|
||||
name = request.form['name'].strip()
|
||||
modulo_id = request.form.get('modulo_id')
|
||||
tipo = request.form.get('tipo', 'Full Time')
|
||||
form_data = request.form
|
||||
|
||||
if not validate_rut(raw_rut):
|
||||
flash("El RUT ingresado no es válido.", "danger")
|
||||
elif not validate_phone(raw_phone):
|
||||
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
|
||||
elif not modulo_id:
|
||||
flash("Debes asignar un módulo al trabajador.", "danger")
|
||||
else:
|
||||
rut = format_rut(raw_rut)
|
||||
phone = format_phone(raw_phone)
|
||||
password = generate_random_password()
|
||||
p_hash = generate_password_hash(password)
|
||||
|
||||
new_worker = Worker(
|
||||
rut=rut, name=name, phone=phone, password_hash=p_hash,
|
||||
is_admin=False, modulo_id=int(modulo_id), tipo=tipo,
|
||||
)
|
||||
try:
|
||||
db.session.add(new_worker)
|
||||
db.session.commit()
|
||||
flash(f"Trabajador guardado. Contraseña temporal: <strong>{password}</strong>", "success")
|
||||
return redirect(url_for('admin.manage_workers'))
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash("El RUT ya existe en el sistema.", "danger")
|
||||
|
||||
# Build (id, rut, name, phone, modulo_name, modulo_id, tipo) tuples
|
||||
# to preserve the existing template contract.
|
||||
workers_rows = (
|
||||
db.session.query(Worker, Modulo)
|
||||
.outerjoin(Modulo, Worker.modulo_id == Modulo.id)
|
||||
.filter(Worker.is_admin == False)
|
||||
.all()
|
||||
)
|
||||
workers = [
|
||||
(w.id, w.rut, w.name, w.phone, m.name if m else None, w.modulo_id, w.tipo)
|
||||
for w, m in workers_rows
|
||||
]
|
||||
|
||||
modulos_rows = (
|
||||
db.session.query(Modulo, Zona)
|
||||
.join(Zona, Modulo.zona_id == Zona.id)
|
||||
.order_by(Zona.name, Modulo.name)
|
||||
.all()
|
||||
)
|
||||
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
|
||||
|
||||
return render_template('admin_workers.html', workers=workers, form=form_data, modulos=modulos)
|
||||
|
||||
|
||||
@admin_bp.route('/workers/edit/<int:id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def edit_worker(id):
|
||||
if request.method == 'POST':
|
||||
raw_phone = request.form['phone']
|
||||
name = request.form['name'].strip()
|
||||
modulo_id = request.form.get('modulo_id')
|
||||
tipo = request.form.get('tipo', 'Full Time')
|
||||
|
||||
if not validate_phone(raw_phone):
|
||||
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
|
||||
return redirect(url_for('admin.edit_worker', id=id))
|
||||
elif not modulo_id:
|
||||
flash("Debes seleccionar un módulo.", "danger")
|
||||
return redirect(url_for('admin.edit_worker', id=id))
|
||||
|
||||
worker = db.session.get(Worker, id)
|
||||
if worker is None:
|
||||
return redirect(url_for('admin.manage_workers'))
|
||||
|
||||
worker.name = name
|
||||
worker.phone = format_phone(raw_phone)
|
||||
worker.modulo_id = int(modulo_id)
|
||||
worker.tipo = tipo
|
||||
|
||||
db.session.commit()
|
||||
flash("Trabajador actualizado exitosamente.", "success")
|
||||
return redirect(url_for('admin.manage_workers'))
|
||||
|
||||
worker = db.session.get(Worker, id)
|
||||
|
||||
modulos_rows = (
|
||||
db.session.query(Modulo, Zona)
|
||||
.join(Zona, Modulo.zona_id == Zona.id)
|
||||
.order_by(Zona.name, Modulo.name)
|
||||
.all()
|
||||
)
|
||||
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
|
||||
|
||||
if not worker:
|
||||
return redirect(url_for('admin.manage_workers'))
|
||||
|
||||
worker_tuple = (worker.id, worker.rut, worker.name, worker.phone, worker.modulo_id)
|
||||
return render_template('edit_worker.html', worker=worker_tuple, modulos=modulos)
|
||||
|
||||
|
||||
@admin_bp.route('/workers/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_worker(id):
|
||||
worker = db.session.get(Worker, id)
|
||||
if worker is not None:
|
||||
db.session.delete(worker)
|
||||
db.session.commit()
|
||||
flash("Trabajador eliminado.", "info")
|
||||
return redirect(url_for('admin.manage_workers'))
|
||||
|
||||
|
||||
@admin_bp.route('/workers/reset_password/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_reset_password(id):
|
||||
worker = db.session.get(Worker, id)
|
||||
if worker is None:
|
||||
return redirect(url_for('admin.manage_workers'))
|
||||
|
||||
new_password = generate_random_password()
|
||||
worker.password_hash = generate_password_hash(new_password)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Contraseña de {worker.name} restablecida. La nueva contraseña es: <strong>{new_password}</strong>", "warning")
|
||||
return redirect(url_for('admin.manage_workers'))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# STRUCTURE (Zonas & Modulos)
|
||||
# ============================================================
|
||||
|
||||
@admin_bp.route('/estructura', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def manage_structure():
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'add_zona':
|
||||
name = request.form.get('zona_name').strip()
|
||||
try:
|
||||
db.session.add(Zona(name=name))
|
||||
db.session.commit()
|
||||
flash("Zona guardada exitosamente.", "success")
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash("Ese nombre de Zona ya existe.", "danger")
|
||||
|
||||
elif action == 'add_modulo':
|
||||
name = request.form.get('modulo_name').strip()
|
||||
zona_id = request.form.get('zona_id')
|
||||
if not zona_id:
|
||||
flash("Debes seleccionar una Zona válida.", "danger")
|
||||
else:
|
||||
db.session.add(Modulo(zona_id=int(zona_id), name=name))
|
||||
db.session.commit()
|
||||
flash("Módulo guardado exitosamente.", "success")
|
||||
|
||||
return redirect(url_for('admin.manage_structure'))
|
||||
|
||||
zonas = [(z.id, z.name) for z in Zona.query.order_by(Zona.name).all()]
|
||||
|
||||
modulos_rows = (
|
||||
db.session.query(Modulo, Zona)
|
||||
.join(Zona, Modulo.zona_id == Zona.id)
|
||||
.order_by(Zona.name, Modulo.name)
|
||||
.all()
|
||||
)
|
||||
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
|
||||
|
||||
return render_template('admin_structure.html', zonas=zonas, modulos=modulos)
|
||||
|
||||
|
||||
@admin_bp.route('/estructura/delete/<string:type>/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_structure(type, id):
|
||||
try:
|
||||
if type == 'zona':
|
||||
count = db.session.query(func.count(Modulo.id)).filter(Modulo.zona_id == id).scalar()
|
||||
if count:
|
||||
flash("No puedes eliminar una Zona que tiene Módulos asignados.", "danger")
|
||||
else:
|
||||
zona = db.session.get(Zona, id)
|
||||
if zona is not None:
|
||||
db.session.delete(zona)
|
||||
flash("Zona eliminada.", "info")
|
||||
|
||||
elif type == 'modulo':
|
||||
count = db.session.query(func.count(Worker.id)).filter(Worker.modulo_id == id).scalar()
|
||||
if count:
|
||||
flash("No puedes eliminar un Módulo que tiene Trabajadores asignados.", "danger")
|
||||
else:
|
||||
modulo = db.session.get(Modulo, id)
|
||||
if modulo is not None:
|
||||
db.session.delete(modulo)
|
||||
flash("Módulo eliminado.", "info")
|
||||
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
flash("Error al eliminar el registro.", "danger")
|
||||
|
||||
return redirect(url_for('admin.manage_structure'))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PRODUCTS
|
||||
# ============================================================
|
||||
|
||||
@admin_bp.route('/productos', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def manage_products():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name').strip()
|
||||
try:
|
||||
new_producto = Producto(name=name)
|
||||
db.session.add(new_producto)
|
||||
db.session.flush() # Populate new_producto.id
|
||||
|
||||
now = datetime.now()
|
||||
for zona in Zona.query.all():
|
||||
db.session.add(PrecioHistorico(
|
||||
producto_id=new_producto.id,
|
||||
zona_id=zona.id,
|
||||
price=0,
|
||||
commission=0,
|
||||
fecha_activacion=now,
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
flash("Producto maestro creado. No olvides configurar sus precios.", "success")
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash("Ese producto ya existe en el catálogo.", "danger")
|
||||
|
||||
return redirect(url_for('admin.manage_products'))
|
||||
|
||||
zonas = [(z.id, z.name) for z in Zona.query.order_by(Zona.name).all()]
|
||||
|
||||
# Find the most-recent vigente price for every (producto, zona) pair
|
||||
now = datetime.now()
|
||||
max_fecha_subq = (
|
||||
db.session.query(
|
||||
PrecioHistorico.producto_id.label('producto_id'),
|
||||
PrecioHistorico.zona_id.label('zona_id'),
|
||||
func.max(PrecioHistorico.fecha_activacion).label('max_fecha'),
|
||||
)
|
||||
.filter(PrecioHistorico.fecha_activacion <= now)
|
||||
.group_by(PrecioHistorico.producto_id, PrecioHistorico.zona_id)
|
||||
.subquery()
|
||||
)
|
||||
current_prices = (
|
||||
db.session.query(PrecioHistorico)
|
||||
.join(
|
||||
max_fecha_subq,
|
||||
and_(
|
||||
PrecioHistorico.producto_id == max_fecha_subq.c.producto_id,
|
||||
PrecioHistorico.zona_id == max_fecha_subq.c.zona_id,
|
||||
PrecioHistorico.fecha_activacion == max_fecha_subq.c.max_fecha,
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
price_map = {
|
||||
(p.producto_id, p.zona_id): (p.price, p.commission)
|
||||
for p in current_prices
|
||||
}
|
||||
|
||||
productos = Producto.query.order_by(Producto.name).all()
|
||||
productos_dict = {}
|
||||
for p in productos:
|
||||
productos_dict[p.id] = {'id': p.id, 'name': p.name, 'precios': {}, 'futuros': []}
|
||||
for z in zonas:
|
||||
z_id, z_name = z
|
||||
price, comm = price_map.get((p.id, z_id), (None, None))
|
||||
productos_dict[p.id]['precios'][z_id] = {
|
||||
'zona_name': z_name,
|
||||
'price': price or 0,
|
||||
'commission': comm or 0,
|
||||
}
|
||||
|
||||
# Scheduled future prices
|
||||
future_rows = (
|
||||
db.session.query(PrecioHistorico, Zona)
|
||||
.join(Zona, PrecioHistorico.zona_id == Zona.id)
|
||||
.filter(PrecioHistorico.fecha_activacion > now)
|
||||
.order_by(PrecioHistorico.fecha_activacion.asc())
|
||||
.all()
|
||||
)
|
||||
for ph, z in future_rows:
|
||||
if ph.producto_id in productos_dict:
|
||||
productos_dict[ph.producto_id]['futuros'].append({
|
||||
'id': ph.id,
|
||||
'zona_name': z.name,
|
||||
'price': ph.price,
|
||||
'commission': ph.commission,
|
||||
'fecha': ph.fecha_activacion,
|
||||
})
|
||||
|
||||
return render_template('admin_productos.html', zonas=zonas, productos=productos_dict.values())
|
||||
|
||||
|
||||
@admin_bp.route('/productos/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_product(id):
|
||||
try:
|
||||
PrecioHistorico.query.filter_by(producto_id=id).delete()
|
||||
producto = db.session.get(Producto, id)
|
||||
if producto is not None:
|
||||
db.session.delete(producto)
|
||||
db.session.commit()
|
||||
flash("Producto maestro y su historial eliminados.", "info")
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash("No puedes eliminar este producto porque ya tiene ventas registradas. Cámbiale el precio a 0 en su lugar.", "danger")
|
||||
return redirect(url_for('admin.manage_products'))
|
||||
|
||||
|
||||
@admin_bp.route('/productos/precios/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def update_product_prices(id):
|
||||
fecha_input = request.form.get('fecha_activacion')
|
||||
if fecha_input:
|
||||
fecha_activacion = datetime.strptime(fecha_input, '%Y-%m-%dT%H:%M').replace(second=0)
|
||||
else:
|
||||
fecha_activacion = datetime.now()
|
||||
|
||||
for zona in Zona.query.all():
|
||||
z_id = str(zona.id)
|
||||
new_price = int(request.form.get(f'price_{z_id}', '0').replace('.', ''))
|
||||
new_comm = int(request.form.get(f'comm_{z_id}', '0').replace('.', ''))
|
||||
|
||||
db.session.add(PrecioHistorico(
|
||||
producto_id=id,
|
||||
zona_id=zona.id,
|
||||
price=new_price,
|
||||
commission=new_comm,
|
||||
fecha_activacion=fecha_activacion,
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Precios actualizados. Entrarán en vigencia el {fecha_activacion}.", "success")
|
||||
return redirect(url_for('admin.manage_products'))
|
||||
|
||||
|
||||
@admin_bp.route('/productos/precios/cancelar/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def cancel_scheduled_price(id):
|
||||
ph = db.session.get(PrecioHistorico, id)
|
||||
if ph is not None:
|
||||
db.session.delete(ph)
|
||||
db.session.commit()
|
||||
flash("Cambio de precio programado cancelado.", "info")
|
||||
return redirect(url_for('admin.manage_products'))
|
||||
|
||||
|
||||
@admin_bp.route('/api/productos/<int:id>/historial')
|
||||
@admin_required
|
||||
def api_product_history(id):
|
||||
rows = (
|
||||
db.session.query(Zona.name, PrecioHistorico.price, PrecioHistorico.fecha_activacion)
|
||||
.join(PrecioHistorico, PrecioHistorico.zona_id == Zona.id)
|
||||
.filter(PrecioHistorico.producto_id == id)
|
||||
.order_by(PrecioHistorico.fecha_activacion.asc())
|
||||
.all()
|
||||
)
|
||||
history = []
|
||||
for zona_name, price, fecha in rows:
|
||||
if isinstance(fecha, datetime):
|
||||
fecha_str = fecha.strftime('%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
fecha_str = str(fecha)
|
||||
history.append({'zona': zona_name, 'price': price, 'fecha': fecha_str})
|
||||
return jsonify(history)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RENDICIONES
|
||||
# ============================================================
|
||||
|
||||
@admin_bp.route('/rendiciones')
|
||||
@admin_required
|
||||
def admin_rendiciones():
|
||||
mes_seleccionado = request.args.get('mes')
|
||||
anio_seleccionado = request.args.get('anio')
|
||||
dia_seleccionado = request.args.get('dia')
|
||||
zona_id_seleccionada = request.args.get('zona_id')
|
||||
modulo_id_seleccionado = request.args.get('modulo_id')
|
||||
|
||||
if request.args.get('mes') is None:
|
||||
hoy = date.today()
|
||||
mes_seleccionado = f"{hoy.month:02d}"
|
||||
anio_seleccionado = str(hoy.year)
|
||||
dia_seleccionado = f"{hoy.day:02d}"
|
||||
|
||||
mes_seleccionado = mes_seleccionado.zfill(2)
|
||||
|
||||
rendiciones_completas = rendiciones_service.get_filtered_rendiciones(
|
||||
mes_seleccionado, anio_seleccionado, dia_seleccionado,
|
||||
zona_id_seleccionada, modulo_id_seleccionado,
|
||||
)
|
||||
workers, modulos, zonas, anios_disponibles = rendiciones_service.get_filter_catalogs()
|
||||
|
||||
dias_disponibles = [f"{d:02d}" for d in range(1, 32)]
|
||||
|
||||
return render_template('admin_rendiciones.html',
|
||||
rendiciones=rendiciones_completas,
|
||||
workers=workers,
|
||||
modulos=modulos,
|
||||
zonas=zonas,
|
||||
mes_actual=mes_seleccionado,
|
||||
anio_actual=anio_seleccionado,
|
||||
dia_actual=dia_seleccionado,
|
||||
zona_actual=zona_id_seleccionada,
|
||||
modulo_actual=modulo_id_seleccionado,
|
||||
anios_disponibles=anios_disponibles,
|
||||
dias_disponibles=dias_disponibles)
|
||||
|
||||
|
||||
@admin_bp.route('/rendiciones/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_rendicion(id):
|
||||
RendicionItem.query.filter_by(rendicion_id=id).delete()
|
||||
rendicion = db.session.get(Rendicion, id)
|
||||
if rendicion is not None:
|
||||
db.session.delete(rendicion)
|
||||
db.session.commit()
|
||||
flash("Rendición eliminada.", "info")
|
||||
return redirect(url_for('admin.admin_rendiciones'))
|
||||
|
||||
|
||||
@admin_bp.route('/rendiciones/edit/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def edit_rendicion(id):
|
||||
fecha = request.form.get('fecha')
|
||||
worker_id = request.form.get('worker_id')
|
||||
modulo_id = request.form.get('modulo_id')
|
||||
companion_id = request.form.get('companion_id') or None
|
||||
if companion_id and worker_id == companion_id:
|
||||
flash("Error: No puedes asignarte a ti mismo como acompañante.", "danger")
|
||||
return redirect(url_for('admin.admin_rendiciones'))
|
||||
worker_comision = 1 if request.form.get('worker_comision') else 0
|
||||
companion_comision = 1 if request.form.get('companion_comision') else 0
|
||||
|
||||
def clean_money(val):
|
||||
if not val:
|
||||
return 0
|
||||
return int(str(val).replace('.', '').replace('$', ''))
|
||||
|
||||
try:
|
||||
debito = clean_money(request.form.get('venta_debito'))
|
||||
credito = clean_money(request.form.get('venta_credito'))
|
||||
mp = clean_money(request.form.get('venta_mp'))
|
||||
efectivo = clean_money(request.form.get('venta_efectivo'))
|
||||
|
||||
bol_debito = int(request.form.get('boletas_debito') or 0)
|
||||
bol_credito = int(request.form.get('boletas_credito') or 0)
|
||||
bol_mp = int(request.form.get('boletas_mp') or 0)
|
||||
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
|
||||
|
||||
gastos = clean_money(request.form.get('gastos'))
|
||||
observaciones = request.form.get('observaciones', '').strip()
|
||||
|
||||
# 1. Update product quantities (rendicion_items)
|
||||
for key, value in request.form.items():
|
||||
if key.startswith('qty_'):
|
||||
ri_id = int(key.split('_')[1])
|
||||
nueva_qty = int(value or 0)
|
||||
item = db.session.get(RendicionItem, ri_id)
|
||||
if item is not None:
|
||||
item.cantidad = nueva_qty
|
||||
|
||||
# 2. Update the main rendicion row
|
||||
rendicion = db.session.get(Rendicion, id)
|
||||
if rendicion is not None:
|
||||
rendicion.fecha = datetime.strptime(fecha, '%Y-%m-%d').date()
|
||||
rendicion.worker_id = int(worker_id)
|
||||
rendicion.modulo_id = int(modulo_id)
|
||||
rendicion.companion_id = int(companion_id) if companion_id else None
|
||||
rendicion.worker_comision = bool(worker_comision)
|
||||
rendicion.companion_comision = bool(companion_comision)
|
||||
rendicion.venta_debito = debito
|
||||
rendicion.venta_credito = credito
|
||||
rendicion.venta_mp = mp
|
||||
rendicion.venta_efectivo = efectivo
|
||||
rendicion.boletas_debito = bol_debito
|
||||
rendicion.boletas_credito = bol_credito
|
||||
rendicion.boletas_mp = bol_mp
|
||||
rendicion.boletas_efectivo = bol_efectivo
|
||||
rendicion.gastos = gastos
|
||||
rendicion.observaciones = observaciones
|
||||
|
||||
db.session.commit()
|
||||
flash("Rendición y productos actualizados correctamente.", "success")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f"Error al actualizar: {str(e)}", "danger")
|
||||
|
||||
return redirect(url_for('admin.admin_rendiciones'))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# REPORTS
|
||||
# ============================================================
|
||||
|
||||
@admin_bp.route('/reportes')
|
||||
@admin_required
|
||||
def admin_reportes_index():
|
||||
modulos_rows = (
|
||||
db.session.query(Modulo, Zona)
|
||||
.join(Zona, Modulo.zona_id == Zona.id)
|
||||
.order_by(Zona.name, Modulo.name)
|
||||
.all()
|
||||
)
|
||||
modulos = [(m.id, m.name, z.name) for m, z in modulos_rows]
|
||||
return render_template('admin_reportes_index.html', modulos=modulos)
|
||||
|
||||
|
||||
@admin_bp.route('/reportes/modulo/<int:modulo_id>')
|
||||
@admin_required
|
||||
def report_modulo_periodo(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
|
||||
data = report_service.get_modulo_periodo_data(modulo_id, anio, mes, dia_f, worker_id)
|
||||
|
||||
return render_template('admin_report_modulo.html',
|
||||
modulo_name=mod_name, modulo_id=modulo_id,
|
||||
mes_nombre=f"{mes}/{anio}",
|
||||
dias_en_periodo=data['dias_en_periodo'],
|
||||
data_por_dia=data['data_por_dia'],
|
||||
totales_mes=data['totales_mes'],
|
||||
dias_activos=data['dias_activos'],
|
||||
workers_list=workers_list, worker_actual=worker_id,
|
||||
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
|
||||
anios_disponibles=anios_list)
|
||||
|
||||
|
||||
@admin_bp.route('/reportes/modulo/<int:modulo_id>/comisiones')
|
||||
@admin_required
|
||||
def report_modulo_comisiones(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
|
||||
data = report_service.get_comisiones_data(modulo_id, anio, mes, dia_f, worker_id)
|
||||
|
||||
return render_template('admin_report_comisiones.html',
|
||||
modulo_name=mod_name, modulo_id=modulo_id,
|
||||
mes_nombre=f"{mes}/{anio}",
|
||||
workers_data=data['workers_data'],
|
||||
dias_en_periodo=data['dias_en_periodo'],
|
||||
workers_list=workers_list, worker_actual=worker_id,
|
||||
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
|
||||
anios_disponibles=anios_list)
|
||||
|
||||
|
||||
@admin_bp.route('/reportes/modulo/<int:modulo_id>/horarios')
|
||||
@admin_required
|
||||
def report_modulo_horarios(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
|
||||
data = report_service.get_horarios_data(modulo_id, anio, mes, dia_f, worker_id)
|
||||
|
||||
return render_template('admin_report_horarios.html',
|
||||
modulo_name=mod_name, modulo_id=modulo_id,
|
||||
mes_nombre=f"{mes}/{anio}",
|
||||
workers_data=data['workers_data'],
|
||||
dias_en_periodo=data['dias_en_periodo'],
|
||||
workers_list=workers_list, worker_actual=worker_id,
|
||||
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
|
||||
anios_disponibles=anios_list)
|
||||
|
||||
|
||||
@admin_bp.route('/reportes/modulo/<int:modulo_id>/centros_comerciales')
|
||||
@admin_required
|
||||
def report_modulo_centros_comerciales(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
|
||||
data = report_service.get_cc_data(modulo_id, anio, mes, dia_f, worker_id)
|
||||
|
||||
return render_template('admin_report_cc.html',
|
||||
modulo_name=mod_name, modulo_id=modulo_id,
|
||||
mes_nombre=f"{mes}/{anio}",
|
||||
dias_en_periodo=data['dias_en_periodo'],
|
||||
data_por_dia=data['data_por_dia'],
|
||||
totales=data['totales'],
|
||||
workers_list=workers_list, worker_actual=worker_id,
|
||||
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
|
||||
anios_disponibles=anios_list)
|
||||
|
||||
|
||||
@admin_bp.route('/reportes/modulo/<int:modulo_id>/calculo_iva')
|
||||
@admin_required
|
||||
def report_modulo_calculo_iva(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id)
|
||||
data = report_service.get_iva_data(modulo_id, anio, mes, dia_f, worker_id)
|
||||
|
||||
return render_template('admin_report_iva.html',
|
||||
modulo_name=mod_name, modulo_id=modulo_id,
|
||||
mes_nombre=f"{mes}/{anio}",
|
||||
dias_en_periodo=data['dias_en_periodo'],
|
||||
data_por_dia=data['data_por_dia'],
|
||||
totales=data['totales'],
|
||||
workers_list=workers_list, worker_actual=worker_id,
|
||||
dia_actual=dia_f, mes_actual=mes, anio_actual=anio,
|
||||
anios_disponibles=anios_list)
|
||||
44
routes/auth_bp.py
Normal file
44
routes/auth_bp.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from werkzeug.security import check_password_hash
|
||||
from database import get_db_connection
|
||||
from utils import validate_rut, format_rut
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/', methods=['GET', 'POST'])
|
||||
def index():
|
||||
if 'user_id' in session:
|
||||
if session.get('is_admin'):
|
||||
return redirect(url_for('admin.admin_rendiciones'))
|
||||
return redirect(url_for('worker.worker_dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
raw_rut = request.form['rut']
|
||||
password = request.form['password']
|
||||
|
||||
rut = format_rut(raw_rut) if validate_rut(raw_rut) else raw_rut
|
||||
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT id, password_hash, is_admin FROM workers WHERE rut = ?", (rut,))
|
||||
user = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
if user and check_password_hash(user[1], password):
|
||||
session['user_id'] = user[0]
|
||||
session['is_admin'] = user[2]
|
||||
session['rut'] = rut
|
||||
|
||||
if user[2]:
|
||||
return redirect(url_for('admin.admin_rendiciones'))
|
||||
else:
|
||||
return redirect(url_for('worker.worker_dashboard'))
|
||||
else:
|
||||
flash("RUT o contraseña incorrectos.", "danger")
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for('auth.index'))
|
||||
189
routes/worker_bp.py
Normal file
189
routes/worker_bp.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from datetime import date
|
||||
from database import get_db_connection
|
||||
from utils import login_required
|
||||
|
||||
worker_bp = Blueprint('worker', __name__)
|
||||
|
||||
@worker_bp.route('/dashboard')
|
||||
@login_required
|
||||
def worker_dashboard():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
user_id = session['user_id']
|
||||
|
||||
c.execute('''
|
||||
SELECT r.id, r.fecha, w.name, m.name,
|
||||
r.venta_debito, r.venta_credito, r.venta_mp, r.venta_efectivo, r.gastos, r.observaciones,
|
||||
c_w.name, r.worker_id, r.companion_id, r.modulo_id,
|
||||
r.worker_comision, r.companion_comision,
|
||||
r.boletas_debito, r.boletas_credito, r.boletas_mp, r.boletas_efectivo
|
||||
FROM rendiciones r
|
||||
JOIN workers w ON r.worker_id = w.id
|
||||
JOIN modulos m ON r.modulo_id = m.id
|
||||
LEFT JOIN workers c_w ON r.companion_id = c_w.id
|
||||
WHERE r.worker_id = ? OR r.companion_id = ?
|
||||
ORDER BY r.fecha DESC, r.id DESC
|
||||
''', (user_id, user_id))
|
||||
rendiciones_basicas = c.fetchall()
|
||||
|
||||
rendiciones_completas = []
|
||||
for r in rendiciones_basicas:
|
||||
c.execute('''
|
||||
SELECT p.name, ri.cantidad, ri.precio_historico, ri.comision_historica,
|
||||
(ri.cantidad * ri.precio_historico) as total_linea,
|
||||
(ri.cantidad * ri.comision_historica) as total_comision
|
||||
FROM rendicion_items ri
|
||||
JOIN productos p ON ri.producto_id = p.id
|
||||
WHERE ri.rendicion_id = ?
|
||||
''', (r[0],))
|
||||
items = c.fetchall()
|
||||
|
||||
total_calculado = sum(item[4] for item in items)
|
||||
comision_total = sum(item[5] for item in items)
|
||||
|
||||
rol = "Titular" if r[11] == user_id else "Acompañante"
|
||||
|
||||
r_completa = r + (items, total_calculado, comision_total, rol)
|
||||
rendiciones_completas.append(r_completa)
|
||||
|
||||
conn.close()
|
||||
return render_template('worker_history.html', rendiciones=rendiciones_completas)
|
||||
|
||||
@worker_bp.route('/rendicion/nueva', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def new_rendicion():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''SELECT w.modulo_id, m.name, z.id, z.name
|
||||
FROM workers w
|
||||
JOIN modulos m ON w.modulo_id = m.id
|
||||
JOIN zonas z ON m.zona_id = z.id
|
||||
WHERE w.id = ?''', (session['user_id'],))
|
||||
worker_info = c.fetchone()
|
||||
|
||||
if not worker_info:
|
||||
conn.close()
|
||||
return "Error: No tienes un módulo asignado. Contacta al administrador."
|
||||
|
||||
modulo_id, modulo_name, zona_id, zona_name = worker_info
|
||||
|
||||
if request.method == 'POST':
|
||||
fecha = request.form.get('fecha')
|
||||
hora_entrada = request.form.get('hora_entrada')
|
||||
hora_salida = request.form.get('hora_salida')
|
||||
companion_hora_entrada = request.form.get('companion_hora_entrada')
|
||||
companion_hora_salida = request.form.get('companion_hora_salida')
|
||||
|
||||
def clean_and_validate(val):
|
||||
if val is None or val.strip() == "":
|
||||
return 0
|
||||
try:
|
||||
return int(val.replace('.', ''))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
debito = clean_and_validate(request.form.get('venta_debito'))
|
||||
credito = clean_and_validate(request.form.get('venta_credito'))
|
||||
mp = clean_and_validate(request.form.get('venta_mp'))
|
||||
efectivo = clean_and_validate(request.form.get('venta_efectivo'))
|
||||
bol_debito = int(request.form.get('boletas_debito') or 0)
|
||||
bol_credito = int(request.form.get('boletas_credito') or 0)
|
||||
bol_mp = int(request.form.get('boletas_mp') or 0)
|
||||
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
|
||||
gastos = clean_and_validate(request.form.get('gastos')) or 0
|
||||
obs = request.form.get('observaciones', '').strip()
|
||||
companion_id = request.form.get('companion_id')
|
||||
|
||||
if companion_id == "":
|
||||
companion_id = None
|
||||
companion_hora_entrada = None
|
||||
companion_hora_salida = None
|
||||
|
||||
if debito is None or credito is None or mp is None or efectivo is None or not fecha or not hora_entrada or not hora_salida:
|
||||
flash("Error: Todos los campos obligatorios deben estar rellenos.", "danger")
|
||||
return redirect(url_for('worker.new_rendicion'))
|
||||
|
||||
c.execute("SELECT tipo FROM workers WHERE id = ?", (session['user_id'],))
|
||||
worker_tipo = c.fetchone()[0]
|
||||
worker_comision = 1 if worker_tipo == 'Full Time' else 0
|
||||
|
||||
companion_comision = 0
|
||||
if companion_id:
|
||||
c.execute("SELECT tipo FROM workers WHERE id = ?", (companion_id,))
|
||||
comp_tipo = c.fetchone()
|
||||
if comp_tipo and comp_tipo[0] == 'Full Time':
|
||||
companion_comision = 1
|
||||
|
||||
total_digital = debito + credito + mp
|
||||
total_ventas_general = total_digital + efectivo
|
||||
|
||||
c.execute('''INSERT INTO rendiciones
|
||||
(worker_id, companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
|
||||
venta_debito, venta_credito, venta_mp, venta_efectivo, boletas_debito, boletas_credito, boletas_mp, boletas_efectivo, gastos, observaciones, worker_comision, companion_comision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
(session['user_id'], companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
|
||||
debito, credito, mp, efectivo, bol_debito, bol_credito, bol_mp, bol_efectivo, gastos, obs, worker_comision, companion_comision))
|
||||
|
||||
rendicion_id = c.lastrowid
|
||||
|
||||
for key, value in request.form.items():
|
||||
if key.startswith('qty_') and value and int(value) > 0:
|
||||
prod_id = int(key.split('_')[1])
|
||||
cantidad = int(value)
|
||||
|
||||
# Buscar el precio vigente al momento de la venta
|
||||
c.execute('''
|
||||
SELECT price, commission
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = ? AND zona_id = ?
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
ORDER BY fecha_activacion DESC LIMIT 1
|
||||
''', (prod_id, zona_id))
|
||||
prod_data = c.fetchone()
|
||||
|
||||
if prod_data:
|
||||
c.execute('''INSERT INTO rendicion_items
|
||||
(rendicion_id, producto_id, cantidad, precio_historico, comision_historica)
|
||||
VALUES (?, ?, ?, ?, ?)''',
|
||||
(rendicion_id, prod_id, cantidad, prod_data[0], prod_data[1]))
|
||||
|
||||
conn.commit()
|
||||
flash(f"Rendición enviada exitosamente. Total General Declarado: ${total_ventas_general:,}".replace(',', '.'), "success")
|
||||
return redirect(url_for('worker.worker_dashboard'))
|
||||
|
||||
c.execute('''
|
||||
SELECT id, name FROM workers
|
||||
WHERE id != ? AND modulo_id = ? AND is_admin = 0
|
||||
ORDER BY name
|
||||
''', (session['user_id'], modulo_id))
|
||||
otros_trabajadores = c.fetchall()
|
||||
|
||||
# Buscar solo el precio vigente actual para esta zona
|
||||
c.execute('''
|
||||
SELECT p.id, p.name, ph.price, ph.commission
|
||||
FROM productos p
|
||||
JOIN precios_historicos ph ON p.id = ph.producto_id
|
||||
WHERE ph.zona_id = ?
|
||||
AND ph.fecha_activacion = (
|
||||
SELECT MAX(fecha_activacion)
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = p.id AND zona_id = ?
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
)
|
||||
ORDER BY p.name
|
||||
''', (zona_id, zona_id)) # Nota: zona_id se pasa dos veces
|
||||
productos = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
has_commission = any(prod[3] > 0 for prod in productos)
|
||||
|
||||
return render_template('worker_dashboard.html',
|
||||
modulo_name=modulo_name,
|
||||
zona_name=zona_name,
|
||||
productos=productos,
|
||||
has_commission=has_commission,
|
||||
otros_trabajadores=otros_trabajadores,
|
||||
today=date.today().strftime('%Y-%m-%d'))
|
||||
731
routes_admin.py
731
routes_admin.py
@@ -1,731 +0,0 @@
|
||||
import sqlite3
|
||||
from flask import app, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
from werkzeug.security import generate_password_hash
|
||||
from datetime import date, datetime
|
||||
from database import get_db_connection
|
||||
from utils import admin_required, validate_rut, format_rut, validate_phone, format_phone, generate_random_password, get_report_params, get_common_report_data
|
||||
import calendar
|
||||
|
||||
def register_admin_routes(app):
|
||||
@app.route('/admin/workers', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def manage_workers():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
form_data = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
raw_rut = request.form['rut']
|
||||
raw_phone = request.form['phone']
|
||||
name = request.form['name'].strip()
|
||||
modulo_id = request.form.get('modulo_id')
|
||||
tipo = request.form.get('tipo', 'Full Time')
|
||||
form_data = request.form
|
||||
|
||||
if not validate_rut(raw_rut):
|
||||
flash("El RUT ingresado no es válido.", "danger")
|
||||
elif not validate_phone(raw_phone):
|
||||
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
|
||||
elif not modulo_id:
|
||||
flash("Debes asignar un módulo al trabajador.", "danger")
|
||||
else:
|
||||
rut = format_rut(raw_rut)
|
||||
phone = format_phone(raw_phone)
|
||||
password = generate_random_password()
|
||||
p_hash = generate_password_hash(password)
|
||||
|
||||
try:
|
||||
c.execute("INSERT INTO workers (rut, name, phone, password_hash, is_admin, modulo_id, tipo) VALUES (?, ?, ?, ?, 0, ?, ?)",
|
||||
(rut, name, phone, p_hash, modulo_id, tipo))
|
||||
conn.commit()
|
||||
flash(f"Trabajador guardado. Contraseña temporal: <strong>{password}</strong>", "success")
|
||||
return redirect(url_for('manage_workers'))
|
||||
except sqlite3.IntegrityError:
|
||||
flash("El RUT ya existe en el sistema.", "danger")
|
||||
|
||||
c.execute('''SELECT w.id, w.rut, w.name, w.phone, m.name, w.modulo_id, w.tipo
|
||||
FROM workers w
|
||||
LEFT JOIN modulos m ON w.modulo_id = m.id
|
||||
WHERE w.is_admin = 0''')
|
||||
workers = c.fetchall()
|
||||
|
||||
c.execute('''SELECT m.id, m.name, z.name
|
||||
FROM modulos m
|
||||
JOIN zonas z ON m.zona_id = z.id
|
||||
ORDER BY z.name, m.name''')
|
||||
modulos = c.fetchall()
|
||||
|
||||
conn.close()
|
||||
return render_template('admin_workers.html', workers=workers, form=form_data, modulos=modulos)
|
||||
|
||||
@app.route('/admin/workers/edit/<int:id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def edit_worker(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'POST':
|
||||
raw_phone = request.form['phone']
|
||||
name = request.form['name'].strip()
|
||||
modulo_id = request.form.get('modulo_id')
|
||||
tipo = request.form.get('tipo', 'Full Time')
|
||||
|
||||
if not validate_phone(raw_phone):
|
||||
flash("El teléfono debe tener 9 dígitos válidos.", "danger")
|
||||
return redirect(url_for('edit_worker', id=id))
|
||||
elif not modulo_id:
|
||||
flash("Debes seleccionar un módulo.", "danger")
|
||||
return redirect(url_for('edit_worker', id=id))
|
||||
|
||||
c.execute("UPDATE workers SET name=?, phone=?, modulo_id=?, tipo=? WHERE id=?",
|
||||
(name, format_phone(raw_phone), modulo_id, tipo, id))
|
||||
conn.commit()
|
||||
flash("Trabajador actualizado exitosamente.", "success")
|
||||
conn.close()
|
||||
return redirect(url_for('manage_workers'))
|
||||
|
||||
c.execute("SELECT id, rut, name, phone, modulo_id FROM workers WHERE id=?", (id,))
|
||||
worker = c.fetchone()
|
||||
|
||||
c.execute('''SELECT m.id, m.name, z.name
|
||||
FROM modulos m
|
||||
JOIN zonas z ON m.zona_id = z.id
|
||||
ORDER BY z.name, m.name''')
|
||||
modulos = c.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
if not worker: return redirect(url_for('manage_workers'))
|
||||
return render_template('edit_worker.html', worker=worker, modulos=modulos)
|
||||
|
||||
@app.route('/admin/workers/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_worker(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM workers WHERE id=?", (id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Trabajador eliminado.", "info")
|
||||
return redirect(url_for('manage_workers'))
|
||||
|
||||
@app.route('/admin/workers/reset_password/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_reset_password(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("SELECT name FROM workers WHERE id=?", (id,))
|
||||
worker = c.fetchone()
|
||||
|
||||
if not worker:
|
||||
conn.close()
|
||||
return redirect(url_for('manage_workers'))
|
||||
|
||||
new_password = generate_random_password()
|
||||
p_hash = generate_password_hash(new_password)
|
||||
|
||||
c.execute("UPDATE workers SET password_hash=? WHERE id=?", (p_hash, id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
flash(f"Contraseña de {worker[0]} restablecida. La nueva contraseña es: <strong>{new_password}</strong>", "warning")
|
||||
|
||||
return redirect(url_for('manage_workers'))
|
||||
|
||||
@app.route('/admin/estructura', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def manage_structure():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'add_zona':
|
||||
name = request.form.get('zona_name').strip()
|
||||
try:
|
||||
c.execute("INSERT INTO zonas (name) VALUES (?)", (name,))
|
||||
conn.commit()
|
||||
flash("Zona guardada exitosamente.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Ese nombre de Zona ya existe.", "danger")
|
||||
|
||||
elif action == 'add_modulo':
|
||||
name = request.form.get('modulo_name').strip()
|
||||
zona_id = request.form.get('zona_id')
|
||||
if not zona_id:
|
||||
flash("Debes seleccionar una Zona válida.", "danger")
|
||||
else:
|
||||
c.execute("INSERT INTO modulos (zona_id, name) VALUES (?, ?)", (zona_id, name))
|
||||
conn.commit()
|
||||
flash("Módulo guardado exitosamente.", "success")
|
||||
|
||||
return redirect(url_for('manage_structure'))
|
||||
|
||||
c.execute("SELECT id, name FROM zonas ORDER BY name")
|
||||
zonas = c.fetchall()
|
||||
|
||||
c.execute('''SELECT m.id, m.name, z.name
|
||||
FROM modulos m
|
||||
JOIN zonas z ON m.zona_id = z.id
|
||||
ORDER BY z.name, m.name''')
|
||||
modulos = c.fetchall()
|
||||
|
||||
conn.close()
|
||||
return render_template('admin_structure.html', zonas=zonas, modulos=modulos)
|
||||
|
||||
@app.route('/admin/estructura/delete/<string:type>/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_structure(type, id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
if type == 'zona':
|
||||
c.execute("SELECT id FROM modulos WHERE zona_id=?", (id,))
|
||||
if c.fetchone():
|
||||
flash("No puedes eliminar una Zona que tiene Módulos asignados.", "danger")
|
||||
else:
|
||||
c.execute("DELETE FROM zonas WHERE id=?", (id,))
|
||||
flash("Zona eliminada.", "info")
|
||||
elif type == 'modulo':
|
||||
c.execute("SELECT id FROM workers WHERE modulo_id=?", (id,))
|
||||
if c.fetchone():
|
||||
flash("No puedes eliminar un Módulo que tiene Trabajadores asignados.", "danger")
|
||||
else:
|
||||
c.execute("DELETE FROM modulos WHERE id=?", (id,))
|
||||
flash("Módulo eliminado.", "info")
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
flash("Error al eliminar el registro.", "danger")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('manage_structure'))
|
||||
|
||||
@app.route('/admin/productos', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def manage_products():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name').strip()
|
||||
try:
|
||||
# 1. Crear producto maestro
|
||||
c.execute("INSERT INTO productos (name) VALUES (?)", (name,))
|
||||
prod_id = c.lastrowid
|
||||
|
||||
# 2. Inicializar precios en 0 para todas las zonas activas
|
||||
c.execute("SELECT id FROM zonas")
|
||||
zonas = c.fetchall()
|
||||
for zona in zonas:
|
||||
c.execute('''INSERT INTO precios_historicos
|
||||
(producto_id, zona_id, price, commission, is_active)
|
||||
VALUES (?, ?, 0, 0, 1)''', (prod_id, zona[0]))
|
||||
|
||||
conn.commit()
|
||||
flash("Producto maestro creado. No olvides configurar sus precios.", "success")
|
||||
except sqlite3.IntegrityError:
|
||||
flash("Ese producto ya existe en el catálogo.", "danger")
|
||||
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
c.execute("SELECT id, name FROM zonas ORDER BY name")
|
||||
zonas = c.fetchall()
|
||||
|
||||
# Obtener productos y el precio VIGENTE por zona usando una subquery
|
||||
c.execute('''
|
||||
SELECT p.id, p.name,
|
||||
z.id as zona_id, z.name as zona_name,
|
||||
ph.price, ph.commission
|
||||
FROM productos p
|
||||
CROSS JOIN zonas z
|
||||
LEFT JOIN precios_historicos ph
|
||||
ON p.id = ph.producto_id AND z.id = ph.zona_id
|
||||
AND ph.fecha_activacion = (
|
||||
SELECT MAX(fecha_activacion)
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = p.id AND zona_id = z.id
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
)
|
||||
ORDER BY p.name, z.name
|
||||
''')
|
||||
|
||||
# Agrupar datos para la vista: {prod_id: {'name': 'Lentes', 'precios': {zona_id: {'price': X, 'comm': Y}}}}
|
||||
raw_data = c.fetchall()
|
||||
productos_dict = {}
|
||||
for row in raw_data:
|
||||
p_id, p_name, z_id, z_name, price, comm = row
|
||||
if p_id not in productos_dict:
|
||||
# Añadimos la lista 'futuros'
|
||||
productos_dict[p_id] = {'id': p_id, 'name': p_name, 'precios': {}, 'futuros': []}
|
||||
productos_dict[p_id]['precios'][z_id] = {
|
||||
'zona_name': z_name,
|
||||
'price': price or 0,
|
||||
'commission': comm or 0
|
||||
}
|
||||
|
||||
# BÚSQUEDA DE PRECIOS PROGRAMADOS (Futuro)
|
||||
c.execute('''
|
||||
SELECT ph.id, ph.producto_id, z.name, ph.price, ph.commission, ph.fecha_activacion
|
||||
FROM precios_historicos ph
|
||||
JOIN zonas z ON ph.zona_id = z.id
|
||||
WHERE ph.fecha_activacion > datetime('now', 'localtime')
|
||||
ORDER BY ph.fecha_activacion ASC
|
||||
''')
|
||||
future_data = c.fetchall()
|
||||
|
||||
for row in future_data:
|
||||
ph_id, p_id, z_name, price, comm, fecha = row
|
||||
if p_id in productos_dict:
|
||||
productos_dict[p_id]['futuros'].append({
|
||||
'id': ph_id,
|
||||
'zona_name': z_name,
|
||||
'price': price,
|
||||
'commission': comm,
|
||||
'fecha': fecha
|
||||
})
|
||||
|
||||
conn.close()
|
||||
return render_template('admin_productos.html', zonas=zonas, productos=productos_dict.values())
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@app.route('/admin/productos/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_product(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
# Borrar historial de precios primero
|
||||
c.execute("DELETE FROM precios_historicos WHERE producto_id=?", (id,))
|
||||
# Borrar producto maestro
|
||||
c.execute("DELETE FROM productos WHERE id=?", (id,))
|
||||
conn.commit()
|
||||
flash("Producto maestro y su historial eliminados.", "info")
|
||||
except sqlite3.IntegrityError:
|
||||
# Si hay ventas asociadas en rendicion_items, SQLite bloqueará el borrado
|
||||
flash("No puedes eliminar este producto porque ya tiene ventas registradas. Cámbiale el precio a 0 en su lugar.", "danger")
|
||||
finally:
|
||||
conn.close()
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
@app.route('/admin/productos/precios/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def update_product_prices(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("SELECT id FROM zonas")
|
||||
zonas = c.fetchall()
|
||||
|
||||
# Obtener fecha programada o usar la actual
|
||||
fecha_input = request.form.get('fecha_activacion')
|
||||
if fecha_input:
|
||||
fecha_activacion = fecha_input.replace('T', ' ') + ':00'
|
||||
else:
|
||||
fecha_activacion = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
for zona in zonas:
|
||||
z_id = str(zona[0])
|
||||
new_price = int(request.form.get(f'price_{z_id}', '0').replace('.', ''))
|
||||
new_comm = int(request.form.get(f'comm_{z_id}', '0').replace('.', ''))
|
||||
|
||||
c.execute('''INSERT INTO precios_historicos
|
||||
(producto_id, zona_id, price, commission, fecha_activacion)
|
||||
VALUES (?, ?, ?, ?, ?)''', (id, z_id, new_price, new_comm, fecha_activacion))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash(f"Precios actualizados. Entrarán en vigencia el {fecha_activacion}.", "success")
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
@app.route('/admin/productos/precios/cancelar/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def cancel_scheduled_price(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM precios_historicos WHERE id = ?", (id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash("Cambio de precio programado cancelado.", "info")
|
||||
return redirect(url_for('manage_products'))
|
||||
|
||||
@app.route('/admin/api/productos/<int:id>/historial')
|
||||
@admin_required
|
||||
def api_product_history(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
SELECT z.name, ph.price, ph.fecha_activacion
|
||||
FROM precios_historicos ph
|
||||
JOIN zonas z ON ph.zona_id = z.id
|
||||
WHERE ph.producto_id = ?
|
||||
ORDER BY ph.fecha_activacion ASC
|
||||
''', (id,)) # <-- Añade la coma para que sea una tupla
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
# Convertimos la data a una lista de diccionarios que JS entienda felizmente
|
||||
history = [{'zona': r[0], 'price': r[1], 'fecha': r[2]} for r in rows]
|
||||
return jsonify(history)
|
||||
|
||||
@app.route('/admin/rendiciones')
|
||||
@admin_required
|
||||
def admin_rendiciones():
|
||||
# Capturamos todos los filtros
|
||||
mes_seleccionado = request.args.get('mes')
|
||||
anio_seleccionado = request.args.get('anio')
|
||||
dia_seleccionado = request.args.get('dia')
|
||||
zona_id_seleccionada = request.args.get('zona_id')
|
||||
modulo_id_seleccionado = request.args.get('modulo_id')
|
||||
|
||||
# Si no viene la variable 'mes' en la URL, significa que es la primera vez que entramos
|
||||
if request.args.get('mes') is None:
|
||||
hoy = date.today()
|
||||
mes_seleccionado = f"{hoy.month:02d}"
|
||||
anio_seleccionado = str(hoy.year)
|
||||
dia_seleccionado = f"{hoy.day:02d}" # <-- Forzamos el día actual
|
||||
|
||||
mes_seleccionado = mes_seleccionado.zfill(2)
|
||||
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
# Construimos la consulta base tipo Lego
|
||||
query = '''
|
||||
SELECT r.id, r.fecha, w.name, m.name,
|
||||
r.venta_debito, r.venta_credito, r.venta_mp, r.venta_efectivo, r.gastos, r.observaciones,
|
||||
c_w.name, r.worker_id, r.companion_id, r.modulo_id,
|
||||
r.worker_comision, r.companion_comision,
|
||||
r.boletas_debito, r.boletas_credito, r.boletas_mp, r.boletas_efectivo
|
||||
FROM rendiciones r
|
||||
JOIN workers w ON r.worker_id = w.id
|
||||
JOIN modulos m ON r.modulo_id = m.id
|
||||
LEFT JOIN workers c_w ON r.companion_id = c_w.id
|
||||
WHERE strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?
|
||||
'''
|
||||
params = [mes_seleccionado, anio_seleccionado]
|
||||
|
||||
# Añadimos las piezas extra si el usuario las seleccionó
|
||||
if dia_seleccionado:
|
||||
query += " AND strftime('%d', r.fecha) = ?"
|
||||
params.append(dia_seleccionado.zfill(2))
|
||||
|
||||
if zona_id_seleccionada:
|
||||
query += " AND m.zona_id = ?"
|
||||
params.append(zona_id_seleccionada)
|
||||
|
||||
if modulo_id_seleccionado:
|
||||
query += " AND r.modulo_id = ?"
|
||||
params.append(modulo_id_seleccionado)
|
||||
|
||||
query += " ORDER BY r.fecha DESC, r.id DESC"
|
||||
|
||||
c.execute(query, tuple(params))
|
||||
rendiciones_basicas = c.fetchall()
|
||||
|
||||
rendiciones_completas = []
|
||||
for r in rendiciones_basicas:
|
||||
c.execute('''
|
||||
SELECT p.name, ri.cantidad, ri.precio_historico, ri.comision_historica,
|
||||
(ri.cantidad * ri.precio_historico) as total_linea,
|
||||
(ri.cantidad * ri.comision_historica) as total_comision,
|
||||
ri.id
|
||||
FROM rendicion_items ri
|
||||
JOIN productos p ON ri.producto_id = p.id
|
||||
WHERE ri.rendicion_id = ?
|
||||
''', (r[0],))
|
||||
items = c.fetchall()
|
||||
|
||||
total_calculado = sum(item[4] for item in items)
|
||||
comision_total = sum(item[5] for item in items)
|
||||
r_completa = r + (items, total_calculado, comision_total)
|
||||
rendiciones_completas.append(r_completa)
|
||||
|
||||
# Cargar catálogos para los selects
|
||||
c.execute("SELECT id, name, tipo, modulo_id FROM workers WHERE is_admin = 0 ORDER BY name")
|
||||
workers = c.fetchall()
|
||||
|
||||
# Ahora traemos el zona_id para poder filtrar los módulos por zona en el frontend
|
||||
c.execute("SELECT id, name, zona_id FROM modulos ORDER BY name")
|
||||
modulos = c.fetchall()
|
||||
|
||||
c.execute("SELECT id, name FROM zonas ORDER BY name")
|
||||
zonas = c.fetchall()
|
||||
|
||||
c.execute("SELECT DISTINCT strftime('%Y', fecha) FROM rendiciones ORDER BY 1 DESC")
|
||||
anios_db = c.fetchall()
|
||||
anios_disponibles = [row[0] for row in anios_db] if anios_db else [str(date.today().year)]
|
||||
if str(date.today().year) not in anios_disponibles:
|
||||
anios_disponibles.insert(0, str(date.today().year))
|
||||
|
||||
conn.close()
|
||||
|
||||
dias_disponibles = [f"{d:02d}" for d in range(1, 32)]
|
||||
|
||||
return render_template('admin_rendiciones.html',
|
||||
rendiciones=rendiciones_completas,
|
||||
workers=workers,
|
||||
modulos=modulos,
|
||||
zonas=zonas,
|
||||
mes_actual=mes_seleccionado,
|
||||
anio_actual=anio_seleccionado,
|
||||
dia_actual=dia_seleccionado,
|
||||
zona_actual=zona_id_seleccionada,
|
||||
modulo_actual=modulo_id_seleccionado,
|
||||
anios_disponibles=anios_disponibles,
|
||||
dias_disponibles=dias_disponibles)
|
||||
|
||||
@app.route('/admin/rendiciones/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_rendicion(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("DELETE FROM rendicion_items WHERE rendicion_id=?", (id,))
|
||||
c.execute("DELETE FROM rendiciones WHERE id=?", (id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
flash("Rendición eliminada.", "info")
|
||||
return redirect(url_for('admin_rendiciones'))
|
||||
|
||||
|
||||
@app.route('/admin/rendiciones/edit/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def edit_rendicion(id):
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
# Obtener datos básicos
|
||||
fecha = request.form.get('fecha')
|
||||
worker_id = request.form.get('worker_id')
|
||||
modulo_id = request.form.get('modulo_id') # Asegúrate de tener el input hidden en el HTML
|
||||
companion_id = request.form.get('companion_id') or None
|
||||
if companion_id and worker_id == companion_id:
|
||||
flash("Error: No puedes asignarte a ti mismo como acompañante.", "danger")
|
||||
return redirect(url_for('admin_rendiciones'))
|
||||
worker_comision = 1 if request.form.get('worker_comision') else 0
|
||||
companion_comision = 1 if request.form.get('companion_comision') else 0
|
||||
|
||||
# Limpiador de dinero para manejar los puntos de miles
|
||||
def clean_money(val):
|
||||
if not val: return 0
|
||||
return int(str(val).replace('.', '').replace('$', ''))
|
||||
|
||||
try:
|
||||
debito = clean_money(request.form.get('venta_debito'))
|
||||
credito = clean_money(request.form.get('venta_credito'))
|
||||
mp = clean_money(request.form.get('venta_mp'))
|
||||
efectivo = clean_money(request.form.get('venta_efectivo'))
|
||||
|
||||
bol_debito = int(request.form.get('boletas_debito') or 0)
|
||||
bol_credito = int(request.form.get('boletas_credito') or 0)
|
||||
bol_mp = int(request.form.get('boletas_mp') or 0)
|
||||
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
|
||||
|
||||
gastos = clean_money(request.form.get('gastos'))
|
||||
observaciones = request.form.get('observaciones', '').strip()
|
||||
|
||||
# 1. Actualizar cantidades de productos
|
||||
# Recorremos el formulario buscando las cantidades editadas
|
||||
for key, value in request.form.items():
|
||||
if key.startswith('qty_'):
|
||||
# En el modal el name es 'qty_{{ item[6] }}' donde item[6] es el ID de rendicion_items
|
||||
ri_id = key.split('_')[1]
|
||||
nueva_qty = int(value or 0)
|
||||
|
||||
# IMPORTANTE: Usamos 'precio_historico' que es el nombre real en tu DB
|
||||
c.execute('''UPDATE rendicion_items
|
||||
SET cantidad = ?
|
||||
WHERE id = ?''', (nueva_qty, ri_id))
|
||||
|
||||
# 2. Actualizar la rendición principal
|
||||
c.execute('''
|
||||
UPDATE rendiciones
|
||||
SET fecha=?, worker_id=?, modulo_id=?, companion_id=?,
|
||||
venta_debito=?, venta_credito=?, venta_mp=?, venta_efectivo=?,
|
||||
boletas_debito=?, boletas_credito=?, boletas_mp=?, boletas_efectivo=?,
|
||||
gastos=?, observaciones=?, worker_comision=?, companion_comision=?
|
||||
WHERE id=?
|
||||
''', (fecha, worker_id, modulo_id, companion_id,
|
||||
debito, credito, mp, efectivo,
|
||||
bol_debito, bol_credito, bol_mp, bol_efectivo,
|
||||
gastos, observaciones, worker_comision, companion_comision, id))
|
||||
|
||||
conn.commit()
|
||||
flash("Rendición y productos actualizados correctamente.", "success")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
flash(f"Error al actualizar: {str(e)}", "danger")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('admin_rendiciones'))
|
||||
|
||||
@app.route('/admin/reportes')
|
||||
@admin_required
|
||||
def admin_reportes_index():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''
|
||||
SELECT m.id, m.name, z.name
|
||||
FROM modulos m
|
||||
JOIN zonas z ON m.zona_id = z.id
|
||||
ORDER BY z.name, m.name
|
||||
''')
|
||||
modulos = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
return render_template('admin_reportes_index.html', modulos=modulos)
|
||||
|
||||
@app.route('/admin/reportes/modulo/<int:modulo_id>')
|
||||
@admin_required
|
||||
def report_modulo_periodo(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
|
||||
|
||||
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
|
||||
params = [modulo_id, mes, anio]
|
||||
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
|
||||
if worker_id: where_clause += " AND r.worker_id = ?"; params.append(worker_id)
|
||||
|
||||
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(r.venta_debito), SUM(r.venta_credito), SUM(r.venta_mp), SUM(r.venta_efectivo), SUM(r.gastos) FROM rendiciones r {where_clause} GROUP BY dia", tuple(params))
|
||||
finanzas = c.fetchall()
|
||||
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(ri.cantidad * ri.comision_historica) FROM rendicion_items ri JOIN rendiciones r ON ri.rendicion_id = r.id {where_clause} AND (r.worker_comision = 1 OR r.companion_comision = 1) GROUP BY dia", tuple(params))
|
||||
comisiones = {row[0]: row[1] for row in c.fetchall()}
|
||||
|
||||
_, num_dias = calendar.monthrange(int(anio), int(mes))
|
||||
dias_en_periodo = [f'{d:02d}' for d in range(1, num_dias + 1)]
|
||||
data_por_dia = {d: {'debito':0,'credito':0,'mp':0,'efectivo':0,'gastos':0,'comision':0,'venta_total':0} for d in dias_en_periodo}
|
||||
for r in finanzas:
|
||||
d = r[0]
|
||||
vt = sum(filter(None, r[1:5]))
|
||||
data_por_dia[d].update({'debito':r[1] or 0,'credito':r[2] or 0,'mp':r[3] or 0,'efectivo':r[4] or 0,'gastos':r[5] or 0,'venta_total':vt,'comision':comisiones.get(d, 0)})
|
||||
|
||||
totales_mes = {k: sum(d[k] for d in data_por_dia.values()) for k in data_por_dia['01'].keys()}
|
||||
dias_activos = sum(1 for d in data_por_dia.values() if d['venta_total'] > 0)
|
||||
conn.close()
|
||||
return render_template('admin_report_modulo.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", dias_en_periodo=dias_en_periodo, data_por_dia=data_por_dia, totales_mes=totales_mes, dias_activos=dias_activos, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
|
||||
|
||||
@app.route('/admin/reportes/modulo/<int:modulo_id>/comisiones')
|
||||
@admin_required
|
||||
def report_modulo_comisiones(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
|
||||
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
|
||||
params = [modulo_id, mes, anio]
|
||||
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
|
||||
if worker_id: where_clause += " AND (r.worker_id = ? OR r.companion_id = ?)"; params.extend([worker_id, worker_id])
|
||||
c.execute(f"SELECT r.id, strftime('%d', r.fecha) as dia, w.id, w.name, w.tipo, r.worker_comision, cw.id, cw.name, cw.tipo, r.companion_comision, (SELECT SUM(cantidad * comision_historica) FROM rendicion_items WHERE rendicion_id = r.id) FROM rendiciones r JOIN workers w ON r.worker_id = w.id LEFT JOIN workers cw ON r.companion_id = cw.id {where_clause}", tuple(params))
|
||||
rendiciones = c.fetchall()
|
||||
workers_data = {}
|
||||
for r in rendiciones:
|
||||
total_com = r[10] or 0
|
||||
# Lógica simplificada: reparte si ambos comisionan
|
||||
for idx, wid, wname, wtipo, wcom in [(0, r[2], r[3], r[4], r[5]), (1, r[6], r[7], r[8], r[9])]:
|
||||
if wid and wcom:
|
||||
if wid not in workers_data: workers_data[wid] = {'name':wname,'tipo':wtipo,'dias':{},'total':0,'enabled':True}
|
||||
val = total_com / 2 if (r[5] and r[9]) else total_com
|
||||
workers_data[wid]['dias'][r[1]] = workers_data[wid]['dias'].get(r[1], 0) + val
|
||||
workers_data[wid]['total'] += val
|
||||
_, num_dias = calendar.monthrange(int(anio), int(mes))
|
||||
dias_en_periodo = [f'{d:02d}' for d in range(1, num_dias + 1)]
|
||||
conn.close()
|
||||
return render_template('admin_report_comisiones.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", workers_data=dict(sorted(workers_data.items(), key=lambda x:x[1]['name'])), dias_en_periodo=dias_en_periodo, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
|
||||
|
||||
@app.route('/admin/reportes/modulo/<int:modulo_id>/horarios')
|
||||
@admin_required
|
||||
def report_modulo_horarios(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
|
||||
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
|
||||
params = [modulo_id, mes, anio]
|
||||
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
|
||||
if worker_id: where_clause += " AND (r.worker_id = ? OR r.companion_id = ?)"; params.extend([worker_id, worker_id])
|
||||
c.execute(f"SELECT r.fecha, w.id, w.name, r.hora_entrada, r.hora_salida, cw.id, cw.name, r.companion_hora_entrada, r.companion_hora_salida FROM rendiciones r JOIN workers w ON r.worker_id = w.id LEFT JOIN workers cw ON r.companion_id = cw.id {where_clause}", tuple(params))
|
||||
rendiciones = c.fetchall()
|
||||
workers_data = {}
|
||||
def calc_h(i, o):
|
||||
if not i or not o: return 0, "0:00"
|
||||
try:
|
||||
t1, t2 = datetime.strptime(i, '%H:%M'), datetime.strptime(o, '%H:%M')
|
||||
d = t2 - t1
|
||||
return d.seconds/3600, f"{d.seconds//3600}:{(d.seconds%3600)//60:02d}"
|
||||
except: return 0, "0:00"
|
||||
for r in rendiciones:
|
||||
d = r[0][-2:]
|
||||
for wid, wname, win, wout in [(r[1], r[2], r[3], r[4]), (r[5], r[6], r[7], r[8])]:
|
||||
if wid:
|
||||
if wid not in workers_data: workers_data[wid] = {'name':wname,'dias':{},'total_horas':0}
|
||||
h_dec, h_str = calc_h(win, wout)
|
||||
workers_data[wid]['dias'][d] = {'in':win,'out':wout,'hrs':h_str}
|
||||
workers_data[wid]['total_horas'] += h_dec
|
||||
for w in workers_data.values():
|
||||
th = w['total_horas']
|
||||
w['total_hrs_str'] = f"{int(th)}:{int((th-int(th))*60):02d}"
|
||||
_, num_dias = calendar.monthrange(int(anio), int(mes))
|
||||
dias_en_periodo = [{'num':f'{d:02d}','name':['L','M','M','J','V','S','D'][date(int(anio),int(mes),d).weekday()]} for d in range(1, num_dias+1)]
|
||||
conn.close()
|
||||
return render_template('admin_report_horarios.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", workers_data=workers_data, dias_en_periodo=dias_en_periodo, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
|
||||
|
||||
@app.route('/admin/reportes/modulo/<int:modulo_id>/centros_comerciales')
|
||||
@admin_required
|
||||
def report_modulo_centros_comerciales(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
|
||||
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
|
||||
params = [modulo_id, mes, anio]
|
||||
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
|
||||
if worker_id: where_clause += " AND r.worker_id = ?"; params.append(worker_id)
|
||||
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(r.boletas_debito + r.boletas_credito + r.boletas_mp), SUM(r.boletas_efectivo), SUM(r.venta_debito + r.venta_credito + r.venta_mp + r.venta_efectivo) FROM rendiciones r {where_clause} GROUP BY dia", tuple(params))
|
||||
resultados = c.fetchall()
|
||||
_, num_dias = calendar.monthrange(int(anio), int(mes))
|
||||
data_por_dia = {f'{d:02d}': {'red_compra':0,'efectivo':0,'total_trans':0,'venta_neta':0} for d in range(1, num_dias+1)}
|
||||
totales = {'red_compra':0,'efectivo':0,'total_trans':0,'venta_neta':0}
|
||||
for r in resultados:
|
||||
dia, rc, ef, vt = r[0], r[1] or 0, r[2] or 0, r[3] or 0
|
||||
vn = round(vt/1.19)
|
||||
data_por_dia[dia] = {'red_compra':rc,'efectivo':ef,'total_trans':rc+ef,'venta_neta':vn}
|
||||
for k in totales: totales[k] += data_por_dia[dia][k]
|
||||
dias_en_periodo = [{'num':f'{d:02d}','name':['L','M','M','J','V','S','D'][date(int(anio),int(mes),d).weekday()]} for d in range(1, num_dias+1)]
|
||||
conn.close()
|
||||
return render_template('admin_report_cc.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", dias_en_periodo=dias_en_periodo, data_por_dia=data_por_dia, totales=totales, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
|
||||
|
||||
@app.route('/admin/reportes/modulo/<int:modulo_id>/calculo_iva')
|
||||
@admin_required
|
||||
def report_modulo_calculo_iva(modulo_id):
|
||||
anio, mes, dia_f, worker_id = get_report_params()
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
mod_name, workers_list, anios_list = get_common_report_data(c, modulo_id)
|
||||
where_clause = "WHERE r.modulo_id = ? AND strftime('%m', r.fecha) = ? AND strftime('%Y', r.fecha) = ?"
|
||||
params = [modulo_id, mes, anio]
|
||||
if dia_f: where_clause += " AND strftime('%d', r.fecha) = ?"; params.append(dia_f.zfill(2))
|
||||
if worker_id: where_clause += " AND r.worker_id = ?"; params.append(worker_id)
|
||||
c.execute(f"SELECT strftime('%d', r.fecha) as dia, SUM(r.venta_efectivo), SUM(r.venta_debito + r.venta_credito + r.venta_mp) FROM rendiciones r {where_clause} GROUP BY dia", tuple(params))
|
||||
resultados = c.fetchall()
|
||||
_, num_dias = calendar.monthrange(int(anio), int(mes))
|
||||
data_por_dia = {f'{d:02d}': {'efectivo':0,'tbk':0,'total':0,'porcentaje':0} for d in range(1, num_dias+1)}
|
||||
totales = {'efectivo':0,'tbk':0,'total':0,'porcentaje':0}
|
||||
for r in resultados:
|
||||
dia, ef, tbk = r[0], r[1] or 0, r[2] or 0
|
||||
tt = ef + tbk
|
||||
data_por_dia[dia] = {'efectivo':ef,'tbk':tbk,'total':tt,'porcentaje':round((ef/tt)*100) if tt>0 else 0}
|
||||
totales['efectivo'] += ef; totales['tbk'] += tbk; totales['total'] += tt
|
||||
if totales['total'] > 0: totales['porcentaje'] = round((totales['efectivo']/totales['total'])*100)
|
||||
dias_en_periodo = [{'num':f'{d:02d}','name':['L','M','M','J','V','S','D'][date(int(anio),int(mes),d).weekday()]} for d in range(1, num_dias+1)]
|
||||
conn.close()
|
||||
return render_template('admin_report_iva.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{mes}/{anio}", dias_en_periodo=dias_en_periodo, data_por_dia=data_por_dia, totales=totales, workers_list=workers_list, worker_actual=worker_id, dia_actual=dia_f, mes_actual=mes, anio_actual=anio, anios_disponibles=anios_list)
|
||||
@@ -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'))
|
||||
188
routes_worker.py
188
routes_worker.py
@@ -1,188 +0,0 @@
|
||||
from flask import render_template, request, redirect, url_for, flash, session
|
||||
from datetime import date
|
||||
from database import get_db_connection
|
||||
from utils import login_required
|
||||
|
||||
def register_worker_routes(app):
|
||||
@app.route('/dashboard')
|
||||
@login_required
|
||||
def worker_dashboard():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
user_id = session['user_id']
|
||||
|
||||
c.execute('''
|
||||
SELECT r.id, r.fecha, w.name, m.name,
|
||||
r.venta_debito, r.venta_credito, r.venta_mp, r.venta_efectivo, r.gastos, r.observaciones,
|
||||
c_w.name, r.worker_id, r.companion_id, r.modulo_id,
|
||||
r.worker_comision, r.companion_comision,
|
||||
r.boletas_debito, r.boletas_credito, r.boletas_mp, r.boletas_efectivo
|
||||
FROM rendiciones r
|
||||
JOIN workers w ON r.worker_id = w.id
|
||||
JOIN modulos m ON r.modulo_id = m.id
|
||||
LEFT JOIN workers c_w ON r.companion_id = c_w.id
|
||||
WHERE r.worker_id = ? OR r.companion_id = ?
|
||||
ORDER BY r.fecha DESC, r.id DESC
|
||||
''', (user_id, user_id))
|
||||
rendiciones_basicas = c.fetchall()
|
||||
|
||||
rendiciones_completas = []
|
||||
for r in rendiciones_basicas:
|
||||
c.execute('''
|
||||
SELECT p.name, ri.cantidad, ri.precio_historico, ri.comision_historica,
|
||||
(ri.cantidad * ri.precio_historico) as total_linea,
|
||||
(ri.cantidad * ri.comision_historica) as total_comision
|
||||
FROM rendicion_items ri
|
||||
JOIN productos p ON ri.producto_id = p.id
|
||||
WHERE ri.rendicion_id = ?
|
||||
''', (r[0],))
|
||||
items = c.fetchall()
|
||||
|
||||
total_calculado = sum(item[4] for item in items)
|
||||
comision_total = sum(item[5] for item in items)
|
||||
|
||||
rol = "Titular" if r[11] == user_id else "Acompañante"
|
||||
|
||||
r_completa = r + (items, total_calculado, comision_total, rol)
|
||||
rendiciones_completas.append(r_completa)
|
||||
|
||||
conn.close()
|
||||
return render_template('worker_history.html', rendiciones=rendiciones_completas)
|
||||
|
||||
@app.route('/rendicion/nueva', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def new_rendicion():
|
||||
conn = get_db_connection()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''SELECT w.modulo_id, m.name, z.id, z.name
|
||||
FROM workers w
|
||||
JOIN modulos m ON w.modulo_id = m.id
|
||||
JOIN zonas z ON m.zona_id = z.id
|
||||
WHERE w.id = ?''', (session['user_id'],))
|
||||
worker_info = c.fetchone()
|
||||
|
||||
if not worker_info:
|
||||
conn.close()
|
||||
return "Error: No tienes un módulo asignado. Contacta al administrador."
|
||||
|
||||
modulo_id, modulo_name, zona_id, zona_name = worker_info
|
||||
|
||||
if request.method == 'POST':
|
||||
fecha = request.form.get('fecha')
|
||||
hora_entrada = request.form.get('hora_entrada')
|
||||
hora_salida = request.form.get('hora_salida')
|
||||
companion_hora_entrada = request.form.get('companion_hora_entrada')
|
||||
companion_hora_salida = request.form.get('companion_hora_salida')
|
||||
|
||||
def clean_and_validate(val):
|
||||
if val is None or val.strip() == "":
|
||||
return 0
|
||||
try:
|
||||
return int(val.replace('.', ''))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
debito = clean_and_validate(request.form.get('venta_debito'))
|
||||
credito = clean_and_validate(request.form.get('venta_credito'))
|
||||
mp = clean_and_validate(request.form.get('venta_mp'))
|
||||
efectivo = clean_and_validate(request.form.get('venta_efectivo'))
|
||||
bol_debito = int(request.form.get('boletas_debito') or 0)
|
||||
bol_credito = int(request.form.get('boletas_credito') or 0)
|
||||
bol_mp = int(request.form.get('boletas_mp') or 0)
|
||||
bol_efectivo = int(request.form.get('boletas_efectivo') or 0)
|
||||
gastos = clean_and_validate(request.form.get('gastos')) or 0
|
||||
obs = request.form.get('observaciones', '').strip()
|
||||
companion_id = request.form.get('companion_id')
|
||||
|
||||
if companion_id == "":
|
||||
companion_id = None
|
||||
companion_hora_entrada = None
|
||||
companion_hora_salida = None
|
||||
|
||||
if debito is None or credito is None or mp is None or efectivo is None or not fecha or not hora_entrada or not hora_salida:
|
||||
flash("Error: Todos los campos obligatorios deben estar rellenos.", "danger")
|
||||
return redirect(url_for('new_rendicion'))
|
||||
|
||||
c.execute("SELECT tipo FROM workers WHERE id = ?", (session['user_id'],))
|
||||
worker_tipo = c.fetchone()[0]
|
||||
worker_comision = 1 if worker_tipo == 'Full Time' else 0
|
||||
|
||||
companion_comision = 0
|
||||
if companion_id:
|
||||
c.execute("SELECT tipo FROM workers WHERE id = ?", (companion_id,))
|
||||
comp_tipo = c.fetchone()
|
||||
if comp_tipo and comp_tipo[0] == 'Full Time':
|
||||
companion_comision = 1
|
||||
|
||||
total_digital = debito + credito + mp
|
||||
total_ventas_general = total_digital + efectivo
|
||||
|
||||
c.execute('''INSERT INTO rendiciones
|
||||
(worker_id, companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
|
||||
venta_debito, venta_credito, venta_mp, venta_efectivo, boletas_debito, boletas_credito, boletas_mp, boletas_efectivo, gastos, observaciones, worker_comision, companion_comision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
(session['user_id'], companion_id, modulo_id, fecha, hora_entrada, hora_salida, companion_hora_entrada, companion_hora_salida,
|
||||
debito, credito, mp, efectivo, bol_debito, bol_credito, bol_mp, bol_efectivo, gastos, obs, worker_comision, companion_comision))
|
||||
|
||||
rendicion_id = c.lastrowid
|
||||
|
||||
for key, value in request.form.items():
|
||||
if key.startswith('qty_') and value and int(value) > 0:
|
||||
prod_id = int(key.split('_')[1])
|
||||
cantidad = int(value)
|
||||
|
||||
# Buscar el precio vigente al momento de la venta
|
||||
c.execute('''
|
||||
SELECT price, commission
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = ? AND zona_id = ?
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
ORDER BY fecha_activacion DESC LIMIT 1
|
||||
''', (prod_id, zona_id))
|
||||
prod_data = c.fetchone()
|
||||
|
||||
if prod_data:
|
||||
c.execute('''INSERT INTO rendicion_items
|
||||
(rendicion_id, producto_id, cantidad, precio_historico, comision_historica)
|
||||
VALUES (?, ?, ?, ?, ?)''',
|
||||
(rendicion_id, prod_id, cantidad, prod_data[0], prod_data[1]))
|
||||
|
||||
conn.commit()
|
||||
flash(f"Rendición enviada exitosamente. Total General Declarado: ${total_ventas_general:,}".replace(',', '.'), "success")
|
||||
return redirect(url_for('worker_dashboard'))
|
||||
|
||||
c.execute('''
|
||||
SELECT id, name FROM workers
|
||||
WHERE id != ? AND modulo_id = ? AND is_admin = 0
|
||||
ORDER BY name
|
||||
''', (session['user_id'], modulo_id))
|
||||
otros_trabajadores = c.fetchall()
|
||||
|
||||
# Buscar solo el precio vigente actual para esta zona
|
||||
c.execute('''
|
||||
SELECT p.id, p.name, ph.price, ph.commission
|
||||
FROM productos p
|
||||
JOIN precios_historicos ph ON p.id = ph.producto_id
|
||||
WHERE ph.zona_id = ?
|
||||
AND ph.fecha_activacion = (
|
||||
SELECT MAX(fecha_activacion)
|
||||
FROM precios_historicos
|
||||
WHERE producto_id = p.id AND zona_id = ?
|
||||
AND fecha_activacion <= datetime('now', 'localtime')
|
||||
)
|
||||
ORDER BY p.name
|
||||
''', (zona_id, zona_id)) # Nota: zona_id se pasa dos veces
|
||||
productos = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
has_commission = any(prod[3] > 0 for prod in productos)
|
||||
|
||||
return render_template('worker_dashboard.html',
|
||||
modulo_name=modulo_name,
|
||||
zona_name=zona_name,
|
||||
productos=productos,
|
||||
has_commission=has_commission,
|
||||
otros_trabajadores=otros_trabajadores,
|
||||
today=date.today().strftime('%Y-%m-%d'))
|
||||
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
90
services/rendiciones_service.py
Normal file
90
services/rendiciones_service.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from datetime import date
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import aliased
|
||||
from models.models import db, Rendicion, RendicionItem, Worker, Modulo, Zona, Producto
|
||||
|
||||
|
||||
def get_filter_catalogs():
|
||||
workers = db.session.query(
|
||||
Worker.id, Worker.name, Worker.tipo, Worker.modulo_id,
|
||||
).filter(Worker.is_admin == False).order_by(Worker.name).all()
|
||||
|
||||
modulos = db.session.query(
|
||||
Modulo.id, Modulo.name, Modulo.zona_id,
|
||||
).order_by(Modulo.name).all()
|
||||
|
||||
zonas = db.session.query(Zona.id, Zona.name).order_by(Zona.name).all()
|
||||
|
||||
anios_rows = db.session.query(
|
||||
func.strftime('%Y', Rendicion.fecha).label('anio'),
|
||||
).distinct().order_by(func.strftime('%Y', Rendicion.fecha).desc()).all()
|
||||
anios_disponibles = [row[0] for row in anios_rows] if anios_rows else [str(date.today().year)]
|
||||
if str(date.today().year) not in anios_disponibles:
|
||||
anios_disponibles.insert(0, str(date.today().year))
|
||||
|
||||
return workers, modulos, zonas, anios_disponibles
|
||||
|
||||
|
||||
def get_filtered_rendiciones(mes, anio, dia, zona_id, modulo_id):
|
||||
Companion = aliased(Worker)
|
||||
|
||||
filters = [
|
||||
func.strftime('%m', Rendicion.fecha) == mes,
|
||||
func.strftime('%Y', Rendicion.fecha) == anio,
|
||||
]
|
||||
if dia:
|
||||
filters.append(func.strftime('%d', Rendicion.fecha) == dia.zfill(2))
|
||||
if zona_id:
|
||||
filters.append(Modulo.zona_id == zona_id)
|
||||
if modulo_id:
|
||||
filters.append(Rendicion.modulo_id == modulo_id)
|
||||
|
||||
rendiciones = db.session.query(
|
||||
Rendicion.id,
|
||||
Rendicion.fecha,
|
||||
Worker.name.label('worker_name'),
|
||||
Modulo.name.label('modulo_name'),
|
||||
Rendicion.venta_debito, Rendicion.venta_credito,
|
||||
Rendicion.venta_mp, Rendicion.venta_efectivo,
|
||||
Rendicion.gastos, Rendicion.observaciones,
|
||||
Companion.name.label('companion_name'),
|
||||
Rendicion.worker_id, Rendicion.companion_id, Rendicion.modulo_id,
|
||||
Rendicion.worker_comision, Rendicion.companion_comision,
|
||||
Rendicion.boletas_debito, Rendicion.boletas_credito,
|
||||
Rendicion.boletas_mp, Rendicion.boletas_efectivo,
|
||||
).join(Worker, Rendicion.worker_id == Worker.id
|
||||
).join(Modulo, Rendicion.modulo_id == Modulo.id
|
||||
).outerjoin(Companion, Rendicion.companion_id == Companion.id
|
||||
).filter(*filters
|
||||
).order_by(Rendicion.fecha.desc(), Rendicion.id.desc()).all()
|
||||
|
||||
if not rendiciones:
|
||||
return []
|
||||
|
||||
rendicion_ids = [r.id for r in rendiciones]
|
||||
items_rows = db.session.query(
|
||||
Producto.name,
|
||||
RendicionItem.cantidad,
|
||||
RendicionItem.precio_historico,
|
||||
RendicionItem.comision_historica,
|
||||
(RendicionItem.cantidad * RendicionItem.precio_historico).label('total_linea'),
|
||||
(RendicionItem.cantidad * RendicionItem.comision_historica).label('total_comision'),
|
||||
RendicionItem.id,
|
||||
RendicionItem.rendicion_id,
|
||||
).join(Producto, RendicionItem.producto_id == Producto.id
|
||||
).filter(RendicionItem.rendicion_id.in_(rendicion_ids)).all()
|
||||
|
||||
items_by_rendicion = {}
|
||||
for it in items_rows:
|
||||
items_by_rendicion.setdefault(it.rendicion_id, []).append(
|
||||
(it[0], it[1], it[2], it[3], it.total_linea, it.total_comision, it.id),
|
||||
)
|
||||
|
||||
rendiciones_completas = []
|
||||
for r in rendiciones:
|
||||
items = items_by_rendicion.get(r.id, [])
|
||||
total_calculado = sum(item[4] for item in items)
|
||||
comision_total = sum(item[5] for item in items)
|
||||
rendiciones_completas.append(tuple(r) + (items, total_calculado, comision_total))
|
||||
|
||||
return rendiciones_completas
|
||||
293
services/report_service.py
Normal file
293
services/report_service.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import calendar
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import aliased
|
||||
from models.models import db, Modulo, Worker, Rendicion, RendicionItem
|
||||
|
||||
Companion = aliased(Worker, name='companion')
|
||||
|
||||
WEEKDAY_SHORT = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
|
||||
|
||||
|
||||
def _fecha_filters(anio, mes, dia_f):
|
||||
filters = [
|
||||
func.strftime('%m', Rendicion.fecha) == mes,
|
||||
func.strftime('%Y', Rendicion.fecha) == anio,
|
||||
]
|
||||
if dia_f:
|
||||
filters.append(func.strftime('%d', Rendicion.fecha) == dia_f.zfill(2))
|
||||
return filters
|
||||
|
||||
|
||||
def _dias_en_periodo(anio, mes):
|
||||
_, num_dias = calendar.monthrange(int(anio), int(mes))
|
||||
return [f'{d:02d}' for d in range(1, num_dias + 1)]
|
||||
|
||||
|
||||
def _dias_con_nombre(anio, mes):
|
||||
_, num_dias = calendar.monthrange(int(anio), int(mes))
|
||||
return [
|
||||
{'num': f'{d:02d}', 'name': WEEKDAY_SHORT[date(int(anio), int(mes), d).weekday()]}
|
||||
for d in range(1, num_dias + 1)
|
||||
]
|
||||
|
||||
|
||||
def get_modulo_workers_and_anios(modulo_id):
|
||||
modulo = db.session.get(Modulo, modulo_id)
|
||||
mod_name = modulo.name if modulo else "Módulo"
|
||||
|
||||
workers = db.session.query(Worker.id, Worker.name).filter(
|
||||
Worker.modulo_id == modulo_id,
|
||||
Worker.is_admin == False,
|
||||
).distinct().order_by(Worker.name).all()
|
||||
workers_list = [(w.id, w.name) for w in workers]
|
||||
|
||||
anios_rows = db.session.query(
|
||||
func.strftime('%Y', Rendicion.fecha).label('anio'),
|
||||
).distinct().order_by(func.strftime('%Y', Rendicion.fecha).desc()).all()
|
||||
anios_list = [row[0] for row in anios_rows]
|
||||
if str(date.today().year) not in anios_list:
|
||||
anios_list.insert(0, str(date.today().year))
|
||||
|
||||
return mod_name, workers_list, anios_list
|
||||
|
||||
|
||||
def _calcular_horas(hora_in, hora_out):
|
||||
if not hora_in or not hora_out:
|
||||
return 0, "0:00"
|
||||
try:
|
||||
t1 = datetime.strptime(hora_in, '%H:%M')
|
||||
t2 = datetime.strptime(hora_out, '%H:%M')
|
||||
d = t2 - t1
|
||||
return d.seconds / 3600, f"{d.seconds // 3600}:{(d.seconds % 3600) // 60:02d}"
|
||||
except (ValueError, TypeError):
|
||||
return 0, "0:00"
|
||||
|
||||
|
||||
def get_modulo_periodo_data(modulo_id, anio, mes, dia_f, worker_id):
|
||||
filters = [
|
||||
Rendicion.modulo_id == modulo_id,
|
||||
*_fecha_filters(anio, mes, dia_f),
|
||||
]
|
||||
if worker_id:
|
||||
filters.append(Rendicion.worker_id == worker_id)
|
||||
|
||||
finanzas = db.session.query(
|
||||
func.strftime('%d', Rendicion.fecha).label('dia'),
|
||||
func.sum(Rendicion.venta_debito),
|
||||
func.sum(Rendicion.venta_credito),
|
||||
func.sum(Rendicion.venta_mp),
|
||||
func.sum(Rendicion.venta_efectivo),
|
||||
func.sum(Rendicion.gastos),
|
||||
).filter(*filters).group_by('dia').all()
|
||||
|
||||
comision_filters = list(filters) + [
|
||||
db.or_(Rendicion.worker_comision == True, Rendicion.companion_comision == True),
|
||||
]
|
||||
comisiones_rows = db.session.query(
|
||||
func.strftime('%d', Rendicion.fecha).label('dia'),
|
||||
func.sum(RendicionItem.cantidad * RendicionItem.comision_historica),
|
||||
).join(Rendicion, RendicionItem.rendicion_id == Rendicion.id).filter(
|
||||
*comision_filters,
|
||||
).group_by('dia').all()
|
||||
comisiones = {row[0]: row[1] for row in comisiones_rows}
|
||||
|
||||
dias_en_periodo = _dias_en_periodo(anio, mes)
|
||||
data_por_dia = {d: {
|
||||
'debito': 0, 'credito': 0, 'mp': 0, 'efectivo': 0,
|
||||
'gastos': 0, 'comision': 0, 'venta_total': 0,
|
||||
} for d in dias_en_periodo}
|
||||
|
||||
for r in finanzas:
|
||||
d = r[0]
|
||||
debito, credito, mp, efectivo, gastos = (
|
||||
r[1] or 0, r[2] or 0, r[3] or 0, r[4] or 0, r[5] or 0,
|
||||
)
|
||||
vt = debito + credito + mp + efectivo
|
||||
data_por_dia[d] = {
|
||||
'debito': debito, 'credito': credito, 'mp': mp, 'efectivo': efectivo,
|
||||
'gastos': gastos, 'venta_total': vt, 'comision': comisiones.get(d, 0),
|
||||
}
|
||||
|
||||
totales_mes = {k: sum(d[k] for d in data_por_dia.values()) for k in data_por_dia['01']}
|
||||
dias_activos = sum(1 for d in data_por_dia.values() if d['venta_total'] > 0)
|
||||
|
||||
return {
|
||||
'dias_en_periodo': dias_en_periodo,
|
||||
'data_por_dia': data_por_dia,
|
||||
'totales_mes': totales_mes,
|
||||
'dias_activos': dias_activos,
|
||||
}
|
||||
|
||||
|
||||
def get_comisiones_data(modulo_id, anio, mes, dia_f, worker_id):
|
||||
filters = [
|
||||
Rendicion.modulo_id == modulo_id,
|
||||
*_fecha_filters(anio, mes, dia_f),
|
||||
]
|
||||
if worker_id:
|
||||
filters.append(db.or_(Rendicion.worker_id == worker_id, Rendicion.companion_id == worker_id))
|
||||
|
||||
items_subq = db.session.query(
|
||||
func.sum(RendicionItem.cantidad * RendicionItem.comision_historica),
|
||||
).filter(RendicionItem.rendicion_id == Rendicion.id).correlate(Rendicion).scalar_subquery()
|
||||
|
||||
rendiciones = db.session.query(
|
||||
Rendicion.id,
|
||||
func.strftime('%d', Rendicion.fecha).label('dia'),
|
||||
Worker.id.label('worker_id'),
|
||||
Worker.name.label('worker_name'),
|
||||
Worker.tipo.label('worker_tipo'),
|
||||
Rendicion.worker_comision,
|
||||
Companion.id.label('companion_id'),
|
||||
Companion.name.label('companion_name'),
|
||||
Companion.tipo.label('companion_tipo'),
|
||||
Rendicion.companion_comision,
|
||||
items_subq.label('total_com'),
|
||||
).join(Worker, Rendicion.worker_id == Worker.id
|
||||
).outerjoin(Companion, Rendicion.companion_id == Companion.id
|
||||
).filter(*filters).all()
|
||||
|
||||
workers_data = {}
|
||||
for r in rendiciones:
|
||||
total_com = r.total_com or 0
|
||||
for wid, wname, wtipo, wcom in [
|
||||
(r.worker_id, r.worker_name, r.worker_tipo, r.worker_comision),
|
||||
(r.companion_id, r.companion_name, r.companion_tipo, r.companion_comision),
|
||||
]:
|
||||
if wid and wcom:
|
||||
if wid not in workers_data:
|
||||
workers_data[wid] = {
|
||||
'name': wname, 'tipo': wtipo, 'dias': {}, 'total': 0, 'enabled': True,
|
||||
}
|
||||
val = total_com / 2 if (r.worker_comision and r.companion_comision) else total_com
|
||||
workers_data[wid]['dias'][r.dia] = workers_data[wid]['dias'].get(r.dia, 0) + val
|
||||
workers_data[wid]['total'] += val
|
||||
|
||||
return {
|
||||
'workers_data': dict(sorted(workers_data.items(), key=lambda x: x[1]['name'])),
|
||||
'dias_en_periodo': _dias_en_periodo(anio, mes),
|
||||
}
|
||||
|
||||
|
||||
def get_horarios_data(modulo_id, anio, mes, dia_f, worker_id):
|
||||
filters = [
|
||||
Rendicion.modulo_id == modulo_id,
|
||||
*_fecha_filters(anio, mes, dia_f),
|
||||
]
|
||||
if worker_id:
|
||||
filters.append(db.or_(Rendicion.worker_id == worker_id, Rendicion.companion_id == worker_id))
|
||||
|
||||
rendiciones = db.session.query(
|
||||
Rendicion.fecha,
|
||||
Worker.id.label('worker_id'),
|
||||
Worker.name.label('worker_name'),
|
||||
Rendicion.hora_entrada,
|
||||
Rendicion.hora_salida,
|
||||
Companion.id.label('companion_id'),
|
||||
Companion.name.label('companion_name'),
|
||||
Rendicion.companion_hora_entrada,
|
||||
Rendicion.companion_hora_salida,
|
||||
).join(Worker, Rendicion.worker_id == Worker.id
|
||||
).outerjoin(Companion, Rendicion.companion_id == Companion.id
|
||||
).filter(*filters).all()
|
||||
|
||||
workers_data = {}
|
||||
for r in rendiciones:
|
||||
d = r.fecha.strftime('%d')
|
||||
for wid, wname, win, wout in [
|
||||
(r.worker_id, r.worker_name, r.hora_entrada, r.hora_salida),
|
||||
(r.companion_id, r.companion_name, r.companion_hora_entrada, r.companion_hora_salida),
|
||||
]:
|
||||
if wid:
|
||||
if wid not in workers_data:
|
||||
workers_data[wid] = {'name': wname, 'dias': {}, 'total_horas': 0}
|
||||
h_dec, h_str = _calcular_horas(win, wout)
|
||||
workers_data[wid]['dias'][d] = {'in': win, 'out': wout, 'hrs': h_str}
|
||||
workers_data[wid]['total_horas'] += h_dec
|
||||
|
||||
for w in workers_data.values():
|
||||
th = w['total_horas']
|
||||
w['total_hrs_str'] = f"{int(th)}:{int((th - int(th)) * 60):02d}"
|
||||
|
||||
return {
|
||||
'workers_data': workers_data,
|
||||
'dias_en_periodo': _dias_con_nombre(anio, mes),
|
||||
}
|
||||
|
||||
|
||||
def get_cc_data(modulo_id, anio, mes, dia_f, worker_id):
|
||||
filters = [
|
||||
Rendicion.modulo_id == modulo_id,
|
||||
*_fecha_filters(anio, mes, dia_f),
|
||||
]
|
||||
if worker_id:
|
||||
filters.append(Rendicion.worker_id == worker_id)
|
||||
|
||||
resultados = db.session.query(
|
||||
func.strftime('%d', Rendicion.fecha).label('dia'),
|
||||
func.sum(Rendicion.boletas_debito + Rendicion.boletas_credito + Rendicion.boletas_mp),
|
||||
func.sum(Rendicion.boletas_efectivo),
|
||||
func.sum(Rendicion.venta_debito + Rendicion.venta_credito + Rendicion.venta_mp + Rendicion.venta_efectivo),
|
||||
).filter(*filters).group_by('dia').all()
|
||||
|
||||
dias_en_periodo = _dias_en_periodo(anio, mes)
|
||||
data_por_dia = {d: {'red_compra': 0, 'efectivo': 0, 'total_trans': 0, 'venta_neta': 0} for d in dias_en_periodo}
|
||||
totales = {'red_compra': 0, 'efectivo': 0, 'total_trans': 0, 'venta_neta': 0}
|
||||
|
||||
for r in resultados:
|
||||
dia = r[0]
|
||||
rc = r[1] or 0
|
||||
ef = r[2] or 0
|
||||
vt = r[3] or 0
|
||||
vn = round(vt / 1.19)
|
||||
data_por_dia[dia] = {'red_compra': rc, 'efectivo': ef, 'total_trans': rc + ef, 'venta_neta': vn}
|
||||
for k in totales:
|
||||
totales[k] += data_por_dia[dia][k]
|
||||
|
||||
return {
|
||||
'dias_en_periodo': _dias_con_nombre(anio, mes),
|
||||
'data_por_dia': data_por_dia,
|
||||
'totales': totales,
|
||||
}
|
||||
|
||||
|
||||
def get_iva_data(modulo_id, anio, mes, dia_f, worker_id):
|
||||
filters = [
|
||||
Rendicion.modulo_id == modulo_id,
|
||||
*_fecha_filters(anio, mes, dia_f),
|
||||
]
|
||||
if worker_id:
|
||||
filters.append(Rendicion.worker_id == worker_id)
|
||||
|
||||
resultados = db.session.query(
|
||||
func.strftime('%d', Rendicion.fecha).label('dia'),
|
||||
func.sum(Rendicion.venta_efectivo),
|
||||
func.sum(Rendicion.venta_debito + Rendicion.venta_credito + Rendicion.venta_mp),
|
||||
).filter(*filters).group_by('dia').all()
|
||||
|
||||
dias_en_periodo = _dias_en_periodo(anio, mes)
|
||||
data_por_dia = {d: {'efectivo': 0, 'tbk': 0, 'total': 0, 'porcentaje': 0} for d in dias_en_periodo}
|
||||
totales = {'efectivo': 0, 'tbk': 0, 'total': 0, 'porcentaje': 0}
|
||||
|
||||
for r in resultados:
|
||||
dia = r[0]
|
||||
ef = r[1] or 0
|
||||
tbk = r[2] or 0
|
||||
tt = ef + tbk
|
||||
data_por_dia[dia] = {
|
||||
'efectivo': ef, 'tbk': tbk, 'total': tt,
|
||||
'porcentaje': round((ef / tt) * 100) if tt > 0 else 0,
|
||||
}
|
||||
totales['efectivo'] += ef
|
||||
totales['tbk'] += tbk
|
||||
totales['total'] += tt
|
||||
|
||||
if totales['total'] > 0:
|
||||
totales['porcentaje'] = round((totales['efectivo'] / totales['total']) * 100)
|
||||
|
||||
return {
|
||||
'dias_en_periodo': _dias_con_nombre(anio, mes),
|
||||
'data_por_dia': data_por_dia,
|
||||
'totales': totales,
|
||||
}
|
||||
52
static/css/components.css
Normal file
52
static/css/components.css
Normal file
@@ -0,0 +1,52 @@
|
||||
/* ============================================================
|
||||
HOVER EFFECTS — theme-aware
|
||||
============================================================ */
|
||||
|
||||
/* .hover-shadow: lift on hover with info-colored border
|
||||
Uses --bs-info so the border matches the theme palette. */
|
||||
.hover-shadow {
|
||||
transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease;
|
||||
}
|
||||
.hover-shadow:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .15) !important;
|
||||
border-color: var(--bs-info) !important;
|
||||
}
|
||||
|
||||
/* .hover-card: tertiary background that blends with the page */
|
||||
.hover-card {
|
||||
transition: transform .2s ease, box-shadow .2s ease;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
.hover-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .12) !important;
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition: all .3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Dark mode gets a deeper shadow */
|
||||
[data-bs-theme="dark"] .hover-shadow:hover {
|
||||
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .3) !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .hover-card:hover {
|
||||
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .25) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BAGUETTE EASTER EGG
|
||||
============================================================ */
|
||||
|
||||
@keyframes baguetteRoll {
|
||||
0% { transform: rotate(0deg) scale(1.2); }
|
||||
50% { transform: rotate(180deg) scale(1.5); }
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.baguette-spin {
|
||||
display: inline-block;
|
||||
animation: baguetteRoll 1s ease-in-out;
|
||||
font-style: normal;
|
||||
}
|
||||
36
static/css/report-tables.css
Normal file
36
static/css/report-tables.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.table-container {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--bs-body-bg);
|
||||
border-right: 2px solid var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--bs-body-bg);
|
||||
box-shadow: inset 0 -1px 0 var(--bs-border-color);
|
||||
}
|
||||
|
||||
thead th.sticky-col {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.numeric-cell {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
/* .total-column: uses theme-aware secondary background
|
||||
so the highlight is visible in both light and dark mode. */
|
||||
.total-column {
|
||||
font-weight: bold;
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
42
static/js/admin_productos.js
Normal file
42
static/js/admin_productos.js
Normal file
@@ -0,0 +1,42 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.formatHelpers.bindMoneyInput('.money-input');
|
||||
|
||||
const editModal = document.getElementById('editProductModal');
|
||||
if (editModal) {
|
||||
editModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
if (!button || !button.hasAttribute('data-id')) return;
|
||||
|
||||
const id = button.getAttribute('data-id');
|
||||
const name = button.getAttribute('data-name');
|
||||
const price = button.getAttribute('data-price');
|
||||
const commission = button.getAttribute('data-commission');
|
||||
const zonaId = button.getAttribute('data-zona');
|
||||
|
||||
const form = editModal.querySelector('#editProductForm');
|
||||
form.action = `/admin/productos/edit/${id}`;
|
||||
|
||||
editModal.querySelector('#edit_name').value = name;
|
||||
editModal.querySelector('#edit_price').value = price;
|
||||
editModal.querySelector('#edit_commission').value = commission;
|
||||
editModal.querySelector('#edit_zona_id').value = zonaId;
|
||||
|
||||
editModal.querySelectorAll('.money-input').forEach(input => {
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('searchProduct');
|
||||
const productRows = document.querySelectorAll('.product-row');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function () {
|
||||
const term = this.value.toLowerCase();
|
||||
productRows.forEach(row => {
|
||||
const name = row.cells[0].textContent.toLowerCase();
|
||||
row.style.display = name.includes(term) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
137
static/js/admin_rendiciones.js
Normal file
137
static/js/admin_rendiciones.js
Normal file
@@ -0,0 +1,137 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.formatHelpers.bindMoneyInput('.money-input');
|
||||
|
||||
window.updateBadge = function (selectElement, badgeId) {
|
||||
const option = selectElement.options[selectElement.selectedIndex];
|
||||
const tipo = option ? option.getAttribute('data-tipo') : null;
|
||||
const badgeDiv = document.getElementById(badgeId);
|
||||
if (!badgeDiv) return;
|
||||
|
||||
if (!tipo) {
|
||||
badgeDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const color = (tipo === 'Full Time') ? 'bg-success' : 'bg-secondary';
|
||||
badgeDiv.innerHTML = `<span class="badge ${color}">${tipo}</span>`;
|
||||
};
|
||||
|
||||
window.toggleCompDiv = function (id, select) {
|
||||
const compDiv = document.getElementById(`comp_com_div_${id}`);
|
||||
if (compDiv) compDiv.style.display = select.value ? 'flex' : 'none';
|
||||
window.updateBadge(select, `badge_comp_${id}`);
|
||||
window.updateComisionToggle(select, `cc_${id}`);
|
||||
};
|
||||
|
||||
window.updateComisionToggle = function (selectElement, toggleId) {
|
||||
const option = selectElement.options[selectElement.selectedIndex];
|
||||
const tipoJornada = option ? option.getAttribute('data-tipo') : null;
|
||||
const toggleSwitch = document.getElementById(toggleId);
|
||||
|
||||
if (toggleSwitch && tipoJornada) {
|
||||
toggleSwitch.checked = (tipoJornada === 'Full Time');
|
||||
} else if (toggleSwitch && !selectElement.value) {
|
||||
toggleSwitch.checked = false;
|
||||
}
|
||||
|
||||
const baseId = toggleId.split('_')[1];
|
||||
const targetBadge = toggleId.startsWith('wc') ? `badge_worker_${baseId}` : `badge_comp_${baseId}`;
|
||||
window.updateBadge(selectElement, targetBadge);
|
||||
};
|
||||
|
||||
window.recalcProductLine = function (input) {
|
||||
const qty = parseInt(input.value) || 0;
|
||||
const price = parseInt(input.getAttribute('data-price')) || 0;
|
||||
const rid = input.getAttribute('data-rid');
|
||||
const row = input.closest('tr');
|
||||
|
||||
const lineTotal = qty * price;
|
||||
const lineCell = row.querySelector('.item-total-line');
|
||||
if (lineCell) lineCell.innerText = '$' + lineTotal.toLocaleString('es-CL');
|
||||
|
||||
const modal = document.getElementById(`editRendicion${rid}`);
|
||||
let newSysTotal = 0;
|
||||
if (modal) {
|
||||
modal.querySelectorAll('.prod-qty-input').forEach(inp => {
|
||||
newSysTotal += (parseInt(inp.value) || 0) * (parseInt(inp.getAttribute('data-price')) || 0);
|
||||
});
|
||||
}
|
||||
const sysTotalEl = document.getElementById(`sys_total_${rid}`);
|
||||
if (sysTotalEl) sysTotalEl.innerText = '$' + newSysTotal.toLocaleString('es-CL');
|
||||
};
|
||||
|
||||
window.calcTotalEdit = function (id) {
|
||||
const getVal = (inputId) => window.formatHelpers.getMoneyInputValue(inputId);
|
||||
const total = getVal(`edit_debito_${id}`) + getVal(`edit_credito_${id}`)
|
||||
+ getVal(`edit_mp_${id}`) + getVal(`edit_efectivo_${id}`);
|
||||
const el = document.getElementById(`display_nuevo_total_${id}`);
|
||||
if (el) el.innerText = '$' + total.toLocaleString('es-CL');
|
||||
};
|
||||
|
||||
const editModals = document.querySelectorAll('[id^="editRendicion"]');
|
||||
const editForms = document.querySelectorAll('form[action*="/admin/rendiciones/edit/"]');
|
||||
const errorModalEl = document.getElementById('errorPersonaModal');
|
||||
const errorModal = errorModalEl ? new bootstrap.Modal(errorModalEl) : null;
|
||||
const errorBody = document.getElementById('errorPersonaModalBody');
|
||||
|
||||
editForms.forEach(form => {
|
||||
form.addEventListener('submit', function (e) {
|
||||
const workerId = this.querySelector('select[name="worker_id"]').value;
|
||||
const companionId = this.querySelector('select[name="companion_id"]').value;
|
||||
|
||||
if (companionId && workerId === companionId) {
|
||||
e.preventDefault();
|
||||
if (errorModal && errorBody) {
|
||||
errorBody.innerHTML = "<strong>Error:</strong> El trabajador titular y el acompañante no pueden ser la misma persona. Por favor, selecciona a alguien más.";
|
||||
errorModal.show();
|
||||
} else {
|
||||
alert("Un trabajador no puede ser su propio acompañante. Por favor, corrige la selección.");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
editModals.forEach(modal => {
|
||||
modal.addEventListener('show.bs.modal', function () {
|
||||
const rid = this.id.replace('editRendicion', '');
|
||||
const workerSelect = this.querySelector('select[name="worker_id"]');
|
||||
if (workerSelect) window.updateBadge(workerSelect, `badge_worker_${rid}`);
|
||||
|
||||
const compSelect = this.querySelector('select[name="companion_id"]');
|
||||
if (compSelect && compSelect.value) window.updateBadge(compSelect, `badge_comp_${rid}`);
|
||||
});
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
const form = this.querySelector('form');
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
const rid = this.id.replace('editRendicion', '');
|
||||
window.calcTotalEdit(rid);
|
||||
this.querySelectorAll('.prod-qty-input').forEach(inp => window.recalcProductLine(inp));
|
||||
});
|
||||
});
|
||||
|
||||
const zonaSelect = document.getElementById('zonaSelect');
|
||||
const moduloSelect = document.getElementById('moduloSelect');
|
||||
|
||||
if (zonaSelect && moduloSelect) {
|
||||
const moduloOptions = Array.from(moduloSelect.options);
|
||||
|
||||
function filterModulos() {
|
||||
const selectedZona = zonaSelect.value;
|
||||
moduloOptions.forEach(option => {
|
||||
if (option.value === "") {
|
||||
option.style.display = '';
|
||||
} else if (!selectedZona || option.dataset.zona === selectedZona) {
|
||||
option.style.display = '';
|
||||
} else {
|
||||
option.style.display = 'none';
|
||||
if (option.selected) moduloSelect.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
zonaSelect.addEventListener('change', filterModulos);
|
||||
filterModulos();
|
||||
}
|
||||
});
|
||||
80
static/js/admin_workers.js
Normal file
80
static/js/admin_workers.js
Normal file
@@ -0,0 +1,80 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const editWorkerModal = document.getElementById('editWorkerModal');
|
||||
const confirmResetModal = document.getElementById('confirmResetPass');
|
||||
|
||||
if (editWorkerModal) {
|
||||
editWorkerModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
if (!button || !button.hasAttribute('data-id')) return;
|
||||
|
||||
const id = button.getAttribute('data-id');
|
||||
const name = button.getAttribute('data-name');
|
||||
|
||||
const editForm = editWorkerModal.querySelector('#editWorkerForm');
|
||||
const resetForm = confirmResetModal ? confirmResetModal.querySelector('form') : null;
|
||||
|
||||
editForm.action = "/admin/workers/edit/" + id;
|
||||
if (resetForm) resetForm.action = "/admin/workers/reset_password/" + id;
|
||||
|
||||
if (confirmResetModal) {
|
||||
confirmResetModal.querySelector('.modal-body').textContent =
|
||||
`¿Estás seguro de generar una nueva contraseña para ${name}? La anterior dejará de funcionar.`;
|
||||
}
|
||||
|
||||
editWorkerModal.querySelector('#edit_worker_rut').value = button.getAttribute('data-rut');
|
||||
editWorkerModal.querySelector('#edit_worker_name').value = name;
|
||||
editWorkerModal.querySelector('#edit_worker_phone').value = button.getAttribute('data-phone');
|
||||
editWorkerModal.querySelector('#edit_worker_modulo').value = button.getAttribute('data-modulo');
|
||||
editWorkerModal.querySelector('#edit_worker_tipo').value = button.getAttribute('data-tipo');
|
||||
});
|
||||
}
|
||||
|
||||
if (editWorkerModal && confirmResetModal) {
|
||||
const btnCancelar = confirmResetModal.querySelector('.btn-secondary');
|
||||
const btnCerrarX = confirmResetModal.querySelector('.btn-close');
|
||||
|
||||
const reabrirEdicion = () => {
|
||||
const modalEdicion = new bootstrap.Modal(editWorkerModal);
|
||||
modalEdicion.show();
|
||||
};
|
||||
|
||||
if (btnCancelar) btnCancelar.addEventListener('click', reabrirEdicion);
|
||||
if (btnCerrarX) btnCerrarX.addEventListener('click', reabrirEdicion);
|
||||
}
|
||||
|
||||
const rutInput = document.getElementById('rutInput');
|
||||
if (rutInput) {
|
||||
window.formatHelpers.bindRutInput('#rutInput');
|
||||
}
|
||||
|
||||
window.formatHelpers.bindPhoneInput('.phone-input, #phoneInput');
|
||||
|
||||
const searchInputWorker = document.getElementById('searchWorker');
|
||||
const moduleSelectFilter = document.getElementById('filterModule');
|
||||
const typeSelectFilter = document.getElementById('filterType');
|
||||
const workerRows = document.querySelectorAll('.worker-row');
|
||||
|
||||
function filterWorkers() {
|
||||
if (!searchInputWorker) return;
|
||||
const searchTerm = searchInputWorker.value.toLowerCase();
|
||||
const selectedModule = moduleSelectFilter ? moduleSelectFilter.value : 'all';
|
||||
const selectedType = typeSelectFilter ? typeSelectFilter.value : 'all';
|
||||
|
||||
workerRows.forEach(row => {
|
||||
const rut = row.cells[0].textContent.toLowerCase();
|
||||
const name = row.cells[1].textContent.toLowerCase();
|
||||
const rowModule = row.getAttribute('data-modulo');
|
||||
const rowType = row.getAttribute('data-tipo');
|
||||
|
||||
const matchesSearch = rut.includes(searchTerm) || name.includes(searchTerm);
|
||||
const matchesModule = selectedModule === 'all' || rowModule === selectedModule;
|
||||
const matchesType = selectedType === 'all' || rowType === selectedType;
|
||||
|
||||
row.style.display = (matchesSearch && matchesModule && matchesType) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInputWorker) searchInputWorker.addEventListener('input', filterWorkers);
|
||||
if (moduleSelectFilter) moduleSelectFilter.addEventListener('change', filterWorkers);
|
||||
if (typeSelectFilter) typeSelectFilter.addEventListener('change', filterWorkers);
|
||||
});
|
||||
71
static/js/format-helpers.js
Normal file
71
static/js/format-helpers.js
Normal file
@@ -0,0 +1,71 @@
|
||||
window.formatHelpers = (function () {
|
||||
function formatRutInput(input) {
|
||||
let value = input.value.replace(/[^0-9kK]/g, '').toUpperCase();
|
||||
if (value.length > 9) value = value.slice(0, 9);
|
||||
if (value.length > 1) {
|
||||
let body = value.slice(0, -1);
|
||||
let dv = value.slice(-1);
|
||||
body = body.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||
input.value = `${body}-${dv}`;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function bindRutInput(selector) {
|
||||
document.querySelectorAll(selector).forEach(function (inp) {
|
||||
inp.addEventListener('input', function () { formatRutInput(inp); });
|
||||
});
|
||||
}
|
||||
|
||||
function formatPhone(input) {
|
||||
let value = input.value.replace(/\D/g, '');
|
||||
if (value.startsWith('56')) value = value.substring(2);
|
||||
value = value.substring(0, 9);
|
||||
if (value.length > 5) input.value = value.replace(/(\d{1})(\d{4})(\d+)/, '$1 $2 $3');
|
||||
else if (value.length > 1) input.value = value.replace(/(\d{1})(\d+)/, '$1 $2');
|
||||
else input.value = value;
|
||||
}
|
||||
|
||||
function bindPhoneInput(selector) {
|
||||
document.querySelectorAll(selector).forEach(function (inp) {
|
||||
inp.addEventListener('input', function () { formatPhone(inp); });
|
||||
});
|
||||
}
|
||||
|
||||
function formatMoneyInput(input) {
|
||||
if (input.dataset.formatted === '1') return;
|
||||
let value = input.value.replace(/\D/g, '');
|
||||
input.value = value === '' ? '' : parseInt(value, 10).toLocaleString('es-CL');
|
||||
input.dataset.formatted = '1';
|
||||
}
|
||||
|
||||
function bindMoneyInput(selector) {
|
||||
document.querySelectorAll(selector).forEach(function (input) {
|
||||
formatMoneyInput(input);
|
||||
input.addEventListener('input', function () {
|
||||
let value = input.value.replace(/\D/g, '');
|
||||
input.value = value === '' ? '' : parseInt(value, 10).toLocaleString('es-CL');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseMoney(value) {
|
||||
return parseInt(String(value).replace(/\D/g, ''), 10) || 0;
|
||||
}
|
||||
|
||||
function getMoneyInputValue(id) {
|
||||
return parseMoney(document.getElementById(id).value);
|
||||
}
|
||||
|
||||
return {
|
||||
formatRutInput: formatRutInput,
|
||||
bindRutInput: bindRutInput,
|
||||
formatPhone: formatPhone,
|
||||
bindPhoneInput: bindPhoneInput,
|
||||
formatMoneyInput: formatMoneyInput,
|
||||
bindMoneyInput: bindMoneyInput,
|
||||
parseMoney: parseMoney,
|
||||
getMoneyInputValue: getMoneyInputValue
|
||||
};
|
||||
})();
|
||||
3
static/js/login.js
Normal file
3
static/js/login.js
Normal file
@@ -0,0 +1,3 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.formatHelpers.bindRutInput('#rutInput');
|
||||
});
|
||||
30
static/js/navbar.js
Normal file
30
static/js/navbar.js
Normal file
@@ -0,0 +1,30 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const brandIcon = document.getElementById("brandIcon");
|
||||
if (!brandIcon) return;
|
||||
|
||||
let clickCount = 0;
|
||||
let clickResetTimer;
|
||||
|
||||
brandIcon.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
clickCount++;
|
||||
clearTimeout(clickResetTimer);
|
||||
clickResetTimer = setTimeout(() => { clickCount = 0; }, 800);
|
||||
|
||||
if (clickCount >= 5) {
|
||||
clickCount = 0;
|
||||
clearTimeout(clickResetTimer);
|
||||
|
||||
const originalClass = this.className;
|
||||
this.className = "fs-3 me-2 baguette-spin";
|
||||
this.innerHTML = "🥖";
|
||||
|
||||
setTimeout(() => {
|
||||
this.className = originalClass;
|
||||
this.innerHTML = "";
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
55
static/js/product-history-chart.js
Normal file
55
static/js/product-history-chart.js
Normal file
@@ -0,0 +1,55 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (typeof Chart === "undefined") return;
|
||||
|
||||
const COLORS = ['#0d6efd', '#198754', '#dc3545', '#ffc107', '#0dcaf0'];
|
||||
let priceChartInstance = null;
|
||||
|
||||
window.showHistory = async function (prodId, prodName) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
|
||||
document.getElementById('chartModalTitle').innerText = 'Fluctuación de Precio: ' + prodName;
|
||||
modal.show();
|
||||
|
||||
const res = await fetch(`/admin/api/productos/${prodId}/historial`);
|
||||
const data = await res.json();
|
||||
|
||||
const zonas = [...new Set(data.map(d => d.zona))];
|
||||
const fechas = [...new Set(data.map(d => d.fecha.split(' ')[0]))].sort();
|
||||
|
||||
const datasets = zonas.map((zona, index) => {
|
||||
let lastPrice = 0;
|
||||
const dataPoints = fechas.map(f => {
|
||||
const hits = data.filter(d => d.zona === zona && d.fecha.startsWith(f));
|
||||
if (hits.length > 0) {
|
||||
lastPrice = hits[hits.length - 1].price;
|
||||
}
|
||||
return lastPrice;
|
||||
});
|
||||
return {
|
||||
label: zona,
|
||||
data: dataPoints,
|
||||
borderColor: COLORS[index % COLORS.length],
|
||||
backgroundColor: COLORS[index % COLORS.length],
|
||||
stepped: true,
|
||||
borderWidth: 2
|
||||
};
|
||||
});
|
||||
|
||||
const ctx = document.getElementById('priceChart').getContext('2d');
|
||||
if (priceChartInstance) priceChartInstance.destroy();
|
||||
|
||||
priceChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: fechas, datasets: datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { callback: v => '$' + v.toLocaleString('es-CL') }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
206
static/js/worker_dashboard.js
Normal file
206
static/js/worker_dashboard.js
Normal file
@@ -0,0 +1,206 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.formatHelpers.bindMoneyInput('.money-input');
|
||||
|
||||
const companionSelect = document.getElementById('companion_select');
|
||||
if (companionSelect) {
|
||||
companionSelect.addEventListener('change', function () {
|
||||
const timeDiv = document.getElementById('companion_times_div');
|
||||
const compIn = document.getElementById('comp_in');
|
||||
const compOut = document.getElementById('comp_out');
|
||||
if (this.value) {
|
||||
timeDiv.style.display = 'block';
|
||||
compIn.required = true;
|
||||
compOut.required = true;
|
||||
} else {
|
||||
timeDiv.style.display = 'none';
|
||||
compIn.required = false;
|
||||
compOut.required = false;
|
||||
compIn.value = '';
|
||||
compOut.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getVal(id) {
|
||||
return window.formatHelpers.getMoneyInputValue(id);
|
||||
}
|
||||
|
||||
function checkWarnings() {
|
||||
const totalProductos = getVal('total_productos_calc');
|
||||
const totalDeclarado = getVal('total_general');
|
||||
const gastos = getVal('gastos');
|
||||
const efectivo = getVal('venta_efectivo');
|
||||
|
||||
let warnings = [];
|
||||
if ((totalProductos > 0 || totalDeclarado > 0) && totalProductos !== totalDeclarado) {
|
||||
warnings.push("El <strong>Total Venta por Productos</strong> no coincide con el <strong>Total Ventas Declaradas</strong>.");
|
||||
}
|
||||
if (gastos > efectivo) {
|
||||
warnings.push("El <strong>Monto de Gastos</strong> es mayor que el <strong>Efectivo</strong> declarado.");
|
||||
}
|
||||
|
||||
const warningContainer = document.getElementById('discrepancy_warning');
|
||||
const warningText = document.getElementById('discrepancy_text');
|
||||
if (warnings.length > 0) {
|
||||
warningText.innerHTML = warnings.join("<br>");
|
||||
warningContainer.style.display = 'block';
|
||||
} else {
|
||||
warningContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const inputsCantidad = document.querySelectorAll('input[name^="qty_"]');
|
||||
const displayTotalProductos = document.getElementById('total_productos_calc');
|
||||
|
||||
function calcularVentaProductos() {
|
||||
if (!displayTotalProductos) return;
|
||||
let granTotal = 0;
|
||||
const filas = document.querySelectorAll('tbody tr');
|
||||
|
||||
filas.forEach(fila => {
|
||||
const inputQty = fila.querySelector('input[name^="qty_"]');
|
||||
if (inputQty) {
|
||||
if (parseInt(inputQty.value) < 0) inputQty.value = 0;
|
||||
const cantidad = parseInt(inputQty.value) || 0;
|
||||
const precioTexto = fila.cells[1].innerText.replace(/\D/g, '');
|
||||
const precio = parseInt(precioTexto) || 0;
|
||||
granTotal += (cantidad * precio);
|
||||
}
|
||||
});
|
||||
displayTotalProductos.value = granTotal.toLocaleString('es-CL');
|
||||
checkWarnings();
|
||||
}
|
||||
|
||||
inputsCantidad.forEach(input => {
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
|
||||
if (e.key < '0' || e.key > '9') e.preventDefault();
|
||||
});
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
calcularVentaProductos();
|
||||
});
|
||||
});
|
||||
|
||||
const inputsVenta = document.querySelectorAll('.sale-input');
|
||||
const displayDigital = document.getElementById('total_digital');
|
||||
const displayGeneral = document.getElementById('total_general');
|
||||
|
||||
function calcularTotales() {
|
||||
const debito = getVal('venta_debito');
|
||||
const credito = getVal('venta_credito');
|
||||
const mp = getVal('venta_mp');
|
||||
const efectivo = getVal('venta_efectivo');
|
||||
|
||||
const totalDigital = debito + credito + mp;
|
||||
const totalGeneral = totalDigital + efectivo;
|
||||
|
||||
if (displayDigital) displayDigital.value = totalDigital.toLocaleString('es-CL');
|
||||
if (displayGeneral) displayGeneral.value = totalGeneral.toLocaleString('es-CL');
|
||||
|
||||
checkWarnings();
|
||||
}
|
||||
|
||||
inputsVenta.forEach(input => {
|
||||
input.addEventListener('input', calcularTotales);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.money-input').forEach(function (input) {
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
|
||||
if (e.key < '0' || e.key > '9') e.preventDefault();
|
||||
});
|
||||
|
||||
input.addEventListener('focus', function () {
|
||||
if (this.value === '0') this.value = '';
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function () {
|
||||
if (this.value.trim() === '' || this.value.trim() === '0') this.value = '0';
|
||||
calcularTotales();
|
||||
});
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
let value = this.value.replace(/\D/g, '');
|
||||
if (value !== '') this.value = parseInt(value, 10).toLocaleString('es-CL');
|
||||
calcularTotales();
|
||||
});
|
||||
});
|
||||
|
||||
const submitModal = document.getElementById('confirmSubmitModal');
|
||||
const mainForm = document.querySelector('form');
|
||||
const alertModalEl = document.getElementById('globalAlertModal');
|
||||
const alertModal = alertModalEl ? new bootstrap.Modal(alertModalEl) : null;
|
||||
|
||||
function mostrarError(mensaje) {
|
||||
const body = document.getElementById('globalAlertModalBody');
|
||||
if (body) body.textContent = mensaje;
|
||||
if (alertModal) alertModal.show();
|
||||
}
|
||||
|
||||
function validarFormulario() {
|
||||
if (!mainForm) return false;
|
||||
const requiredInputs = mainForm.querySelectorAll('[required]');
|
||||
let valid = true;
|
||||
|
||||
requiredInputs.forEach(input => {
|
||||
const isMoney = input.classList.contains('money-input');
|
||||
if (!input.value.trim() || (isMoney && input.value === '')) {
|
||||
input.classList.add('is-invalid');
|
||||
valid = false;
|
||||
} else {
|
||||
input.classList.remove('is-invalid');
|
||||
}
|
||||
});
|
||||
return valid;
|
||||
}
|
||||
|
||||
if (submitModal) {
|
||||
const confirmBtn = submitModal.querySelector('button[type="submit"]');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (validarFormulario()) {
|
||||
mainForm.submit();
|
||||
} else {
|
||||
const submitInstance = bootstrap.Modal.getInstance(submitModal);
|
||||
if (submitInstance) submitInstance.hide();
|
||||
mostrarError("Por favor, rellena los campos obligatorios (Fecha y Hora) antes de enviar.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mainForm) {
|
||||
mainForm.addEventListener('submit', function (e) {
|
||||
const requiredInputs = this.querySelectorAll('[required]');
|
||||
let valid = true;
|
||||
|
||||
requiredInputs.forEach(input => {
|
||||
const isMoney = input.classList.contains('money-input');
|
||||
if (!input.value.trim() || (isMoney && input.value === '')) {
|
||||
input.classList.add('is-invalid');
|
||||
valid = false;
|
||||
} else {
|
||||
input.classList.remove('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
e.preventDefault();
|
||||
if (alertModalEl) {
|
||||
const inst = bootstrap.Modal.getOrCreateInstance(alertModalEl);
|
||||
mostrarError("Por favor, rellena todos los campos obligatorios antes de enviar.");
|
||||
inst.show();
|
||||
} else {
|
||||
alert("Por favor, rellena todos los campos obligatorios.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.money-input').forEach(input => {
|
||||
if (!input.value.trim()) input.value = '0';
|
||||
});
|
||||
});
|
||||
148
static/style.css
148
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);
|
||||
}
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from 'macros/modals.html' import confirm_modal, edit_product_modal %}
|
||||
{% from "macros/ui.html" import flashed_messages %}
|
||||
|
||||
{% block title %}Catálogo de Productos{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!-- HEAD -->
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">Catálogo de Productos por Zona</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ flashed_messages() }}
|
||||
|
||||
{{ edit_product_modal(zonas) }}
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-primary text-white">Agregar Producto Maestro</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('manage_products') }}">
|
||||
<form method="POST" action="{{ url_for('admin.manage_products') }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-10">
|
||||
<input type="text" class="form-control" name="name" placeholder="Nombre del Producto" required>
|
||||
@@ -69,7 +60,7 @@
|
||||
id='deleteProd' ~ prod.id,
|
||||
title='Eliminar Producto',
|
||||
message='¿Eliminar "' ~ prod.name ~ '"? Esto fallará si el producto ya tiene ventas registradas.',
|
||||
action_url=url_for('delete_product', id=prod.id),
|
||||
action_url=url_for('admin.delete_product', id=prod.id),
|
||||
btn_class='btn-danger',
|
||||
btn_text='Eliminar'
|
||||
) }}
|
||||
@@ -85,7 +76,7 @@
|
||||
<div class="modal fade" id="pricesModal{{ prod.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('update_product_prices', id=prod.id) }}">
|
||||
<form method="POST" action="{{ url_for('admin.update_product_prices', id=prod.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Precios: {{ prod.name }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
@@ -156,7 +147,7 @@
|
||||
<td class="align-middle">${{ "{:,.0f}".format(futuro.price).replace(',', '.') }}</td>
|
||||
<td class="align-middle">${{ "{:,.0f}".format(futuro.commission).replace(',', '.') }}</td>
|
||||
<td class="align-middle">
|
||||
<form action="{{ url_for('cancel_scheduled_price', id=futuro.id) }}" method="POST" class="d-inline">
|
||||
<form action="{{ url_for('admin.cancel_scheduled_price', id=futuro.id) }}" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm py-0 px-2" title="Cancelar este cambio">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
@@ -196,132 +187,6 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<script>
|
||||
let priceChartInstance = null;
|
||||
|
||||
async function showHistory(prodId, prodName) {
|
||||
// Abrir el modal inmediatamente para que no parezca que la app murió
|
||||
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
|
||||
document.getElementById('chartModalTitle').innerText = 'Fluctuación de Precio: ' + prodName;
|
||||
modal.show();
|
||||
|
||||
// Traer la data desde nuestra nueva API
|
||||
const res = await fetch(`/admin/api/productos/${prodId}/historial`);
|
||||
const data = await res.json();
|
||||
|
||||
// Extraer zonas únicas y fechas únicas (limpiando la hora para el eje X)
|
||||
const zonas = [...new Set(data.map(d => d.zona))];
|
||||
const fechas = [...new Set(data.map(d => d.fecha.split(' ')[0]))].sort();
|
||||
|
||||
const colors = ['#0d6efd', '#198754', '#dc3545', '#ffc107', '#0dcaf0'];
|
||||
|
||||
const datasets = zonas.map((zona, index) => {
|
||||
let lastPrice = 0;
|
||||
|
||||
// Rellenar huecos: Si no hubo cambio un día, se mantiene el precio del día anterior
|
||||
const dataPoints = fechas.map(f => {
|
||||
const hits = data.filter(d => d.zona === zona && d.fecha.startsWith(f));
|
||||
if (hits.length > 0) {
|
||||
lastPrice = hits[hits.length - 1].price; // Tomar el último cambio de ese día
|
||||
}
|
||||
return lastPrice;
|
||||
});
|
||||
|
||||
return {
|
||||
label: zona,
|
||||
data: dataPoints,
|
||||
borderColor: colors[index % colors.length],
|
||||
backgroundColor: colors[index % colors.length],
|
||||
stepped: true, // Hace que se vea como escaleras en vez de curvas raras
|
||||
borderWidth: 2
|
||||
};
|
||||
});
|
||||
|
||||
// Dibujar (o redibujar) el gráfico
|
||||
const ctx = document.getElementById('priceChart').getContext('2d');
|
||||
if (priceChartInstance) {
|
||||
priceChartInstance.destroy();
|
||||
}
|
||||
|
||||
priceChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: fechas,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) { return '$' + value.toLocaleString('es-CL'); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const editModal = document.getElementById('editProductModal');
|
||||
if (editModal) {
|
||||
editModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
|
||||
// Atributos extraídos del botón
|
||||
const id = button.getAttribute('data-id');
|
||||
const name = button.getAttribute('data-name');
|
||||
const price = button.getAttribute('data-price');
|
||||
const commission = button.getAttribute('data-commission');
|
||||
const zonaId = button.getAttribute('data-zona');
|
||||
|
||||
// Actualizamos el destino del formulario
|
||||
const form = editModal.querySelector('#editProductForm');
|
||||
form.action = `/admin/productos/edit/${id}`;
|
||||
|
||||
// Llenamos los inputs
|
||||
editModal.querySelector('#edit_name').value = name;
|
||||
editModal.querySelector('#edit_price').value = price;
|
||||
editModal.querySelector('#edit_commission').value = commission;
|
||||
editModal.querySelector('#edit_zona_id').value = zonaId;
|
||||
|
||||
// Forzamos el formato de miles (puntos) inmediatamente
|
||||
editModal.querySelectorAll('.money-input').forEach(input => {
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.money-input').forEach(function(input) {
|
||||
input.addEventListener('input', function(e) {
|
||||
let value = this.value.replace(/\D/g, '');
|
||||
if (value !== '') {
|
||||
this.value = parseInt(value, 10).toLocaleString('es-CL');
|
||||
} else {
|
||||
this.value = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('searchProduct');
|
||||
const productRows = document.querySelectorAll('.product-row');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const term = this.value.toLowerCase();
|
||||
productRows.forEach(row => {
|
||||
// Asume que el nombre está en la primera celda
|
||||
const name = row.cells[0].textContent.toLowerCase();
|
||||
row.style.display = name.includes(term) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/product-history-chart.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/admin_productos.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,9 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from 'macros/modals.html' import alert_modal, rendicion_detail_modal, confirm_modal, edit_rendicion_modal %}
|
||||
{% from "macros/ui.html" import flashed_messages %}
|
||||
|
||||
{% block title %}Historial de Rendiciones{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="mb-0">Historial de Rendiciones</h2>
|
||||
@@ -13,7 +11,7 @@
|
||||
|
||||
<div class="card shadow-sm mb-4 border-0">
|
||||
<div class="card-body bg-body-tertiary rounded p-3">
|
||||
<form method="GET" action="{{ url_for('admin_rendiciones') }}" id="filterForm">
|
||||
<form method="GET" action="{{ url_for('admin.admin_rendiciones') }}" id="filterForm">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted mb-1">Año</label>
|
||||
@@ -77,13 +75,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ flashed_messages() }}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
@@ -123,7 +115,7 @@
|
||||
id='deleteRendicion' ~ r[0],
|
||||
title='Eliminar Rendición',
|
||||
message='¿Estás seguro de que deseas eliminar la rendición #' ~ r[0] ~ '? Esta acción no se puede deshacer.',
|
||||
action_url=url_for('delete_rendicion', id=r[0]),
|
||||
action_url=url_for('admin.delete_rendicion', id=r[0]),
|
||||
btn_class='btn-danger',
|
||||
btn_text='Eliminar'
|
||||
) }}
|
||||
@@ -142,171 +134,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Función para manejar las etiquetas de jornada (Full/Part Time)
|
||||
function updateBadge(selectElement, badgeId) {
|
||||
const option = selectElement.options[selectElement.selectedIndex];
|
||||
const tipo = option.getAttribute('data-tipo');
|
||||
const badgeDiv = document.getElementById(badgeId);
|
||||
|
||||
if (!badgeDiv) return;
|
||||
|
||||
if (!tipo) {
|
||||
badgeDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const color = (tipo === 'Full Time') ? 'bg-success' : 'bg-secondary';
|
||||
badgeDiv.innerHTML = `<span class="badge ${color}">${tipo}</span>`;
|
||||
}
|
||||
|
||||
function toggleCompDiv(id, select) {
|
||||
const compDiv = document.getElementById(`comp_com_div_${id}`);
|
||||
compDiv.style.display = select.value ? 'flex' : 'none';
|
||||
updateBadge(select, `badge_comp_${id}`);
|
||||
updateComisionToggle(select, `cc_${id}`);
|
||||
}
|
||||
|
||||
function updateComisionToggle(selectElement, toggleId) {
|
||||
const option = selectElement.options[selectElement.selectedIndex];
|
||||
const tipoJornada = option.getAttribute('data-tipo');
|
||||
const toggleSwitch = document.getElementById(toggleId);
|
||||
|
||||
if (toggleSwitch && tipoJornada) {
|
||||
// Explicitly set to true if Full Time, false otherwise
|
||||
toggleSwitch.checked = (tipoJornada === 'Full Time');
|
||||
} else if (toggleSwitch && !selectElement.value) {
|
||||
// If "Sin acompañante" is selected, turn it off
|
||||
toggleSwitch.checked = false;
|
||||
}
|
||||
|
||||
// Actualizar el badge también
|
||||
const baseId = toggleId.split('_')[1];
|
||||
const targetBadge = toggleId.startsWith('wc') ? `badge_worker_${baseId}` : `badge_comp_${baseId}`;
|
||||
updateBadge(selectElement, targetBadge);
|
||||
}
|
||||
|
||||
// Recalcular total de la línea de producto y el total del sistema
|
||||
function recalcProductLine(input) {
|
||||
const qty = parseInt(input.value) || 0;
|
||||
const price = parseInt(input.getAttribute('data-price')) || 0;
|
||||
const rid = input.getAttribute('data-rid');
|
||||
const row = input.closest('tr');
|
||||
|
||||
// Actualizar línea individual
|
||||
const lineTotal = qty * price;
|
||||
row.querySelector('.item-total-line').innerText = '$' + lineTotal.toLocaleString('es-CL');
|
||||
|
||||
// Recalcular total general del sistema en el modal
|
||||
const modal = document.getElementById(`editRendicion${rid}`);
|
||||
let newSysTotal = 0;
|
||||
modal.querySelectorAll('.prod-qty-input').forEach(inp => {
|
||||
newSysTotal += (parseInt(inp.value) || 0) * (parseInt(inp.getAttribute('data-price')) || 0);
|
||||
});
|
||||
document.getElementById(`sys_total_${rid}`).innerText = '$' + newSysTotal.toLocaleString('es-CL');
|
||||
}
|
||||
|
||||
function calcTotalEdit(id) {
|
||||
const getVal = (inputId) => parseInt(document.getElementById(inputId).value.replace(/\D/g, '')) || 0;
|
||||
const total = getVal(`edit_debito_${id}`) + getVal(`edit_credito_${id}`) + getVal(`edit_mp_${id}`) + getVal(`edit_efectivo_${id}`);
|
||||
document.getElementById(`display_nuevo_total_${id}`).innerText = '$' + total.toLocaleString('es-CL');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const editModals = document.querySelectorAll('[id^="editRendicion"]');
|
||||
const editForms = document.querySelectorAll('form[action*="/admin/rendiciones/edit/"]');
|
||||
const errorModalEl = document.getElementById('errorPersonaModal');
|
||||
const errorModal = new bootstrap.Modal(errorModalEl);
|
||||
const errorBody = document.getElementById('errorPersonaModalBody');
|
||||
|
||||
editForms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const workerId = this.querySelector('select[name="worker_id"]').value;
|
||||
const companionId = this.querySelector('select[name="companion_id"]').value;
|
||||
|
||||
if (companionId && workerId === companionId) {
|
||||
e.preventDefault();
|
||||
errorBody.innerHTML = "<strong>Error:</strong> El trabajador titular y el acompañante no pueden ser la misma persona. Por favor, selecciona a alguien más.";
|
||||
errorModal.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
editModals.forEach(modal => {
|
||||
// Inicializar badges al abrir
|
||||
modal.addEventListener('show.bs.modal', function() {
|
||||
const rid = this.id.replace('editRendicion', '');
|
||||
updateBadge(this.querySelector('select[name="worker_id"]'), `badge_worker_${rid}`);
|
||||
const compSelect = this.querySelector('select[name="companion_id"]');
|
||||
if (compSelect.value) updateBadge(compSelect, `badge_comp_${rid}`);
|
||||
});
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
const form = this.querySelector('form');
|
||||
if (form) {
|
||||
form.reset();
|
||||
const rid = this.id.replace('editRendicion', '');
|
||||
calcTotalEdit(rid);
|
||||
// Resetear los subtotales visuales de productos
|
||||
this.querySelectorAll('.prod-qty-input').forEach(inp => recalcProductLine(inp));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function validarNombresDiferentes(rendicionId) {
|
||||
const workerSelect = document.querySelector(`select[name="worker_id"]`);
|
||||
const companionSelect = document.querySelector(`select[name="companion_id"]`);
|
||||
|
||||
if (companionSelect.value && workerSelect.value === companionSelect.value) {
|
||||
alert("Error: El trabajador titular y el acompañante no pueden ser la misma persona.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vincula esta validación al evento submit del formulario de edición
|
||||
document.querySelectorAll('form[action*="edit_rendicion"]').forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const workerSelect = this.querySelector('select[name="worker_id"]');
|
||||
const companionSelect = this.querySelector('select[name="companion_id"]');
|
||||
|
||||
if (companionSelect.value && workerSelect.value === companionSelect.value) {
|
||||
e.preventDefault();
|
||||
alert("Un trabajador no puede ser su propio acompañante. Por favor, corrige la selección.");
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const zonaSelect = document.getElementById('zonaSelect');
|
||||
const moduloSelect = document.getElementById('moduloSelect');
|
||||
const moduloOptions = Array.from(moduloSelect.options);
|
||||
|
||||
function filterModulos() {
|
||||
const selectedZona = zonaSelect.value;
|
||||
|
||||
moduloOptions.forEach(option => {
|
||||
if (option.value === "") {
|
||||
// Siempre mostramos "Todos los Módulos"
|
||||
option.style.display = '';
|
||||
} else if (!selectedZona || option.dataset.zona === selectedZona) {
|
||||
option.style.display = '';
|
||||
} else {
|
||||
option.style.display = 'none';
|
||||
// Si el módulo seleccionado acaba de ocultarse, reseteamos el select
|
||||
if (option.selected) {
|
||||
moduloSelect.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
zonaSelect.addEventListener('change', filterModulos);
|
||||
// Ejecutar al cargar la página por si ya viene con una zona filtrada
|
||||
filterModulos();
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/admin_rendiciones.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,48 +1,13 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from "macros/modals.html" import report_filters %}
|
||||
{% from "macros/ui.html" import back_link %}
|
||||
|
||||
{% block title %}Reporte: Centros Comerciales - {{ modulo_name }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.table-container {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--bs-body-bg);
|
||||
border-right: 2px solid var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--bs-body-bg);
|
||||
box-shadow: inset 0 -1px 0 var(--bs-border-color);
|
||||
}
|
||||
|
||||
thead th.sticky-col {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.numeric-cell {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
||||
</a>
|
||||
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||
<h2>Registro Centros Comerciales</h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
@@ -51,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ report_filters(
|
||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
||||
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||
workers_list,
|
||||
worker_actual,
|
||||
dia_actual,
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from "macros/modals.html" import report_filters %}
|
||||
{% from "macros/ui.html" import back_link %}
|
||||
|
||||
{% block title %}Reporte: Comisiones - {{ modulo_name }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.numeric-cell {
|
||||
text-align: right;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background-color: #f8f9fa !important;
|
||||
border-right: 2px solid #dee2e6;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
||||
</a>
|
||||
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||
<h2>Reporte de Comisiones</h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
@@ -33,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ report_filters(
|
||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
||||
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||
workers_list,
|
||||
worker_actual,
|
||||
dia_actual,
|
||||
|
||||
@@ -1,44 +1,13 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from "macros/modals.html" import report_filters %}
|
||||
{% from "macros/ui.html" import back_link %}
|
||||
|
||||
{% block title %}Reporte: Horarios - {{ modulo_name }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.table-container {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--bs-body-bg);
|
||||
border-right: 2px solid var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--bs-body-bg);
|
||||
box-shadow: inset 0 -1px 0 var(--bs-border-color);
|
||||
}
|
||||
|
||||
thead th.sticky-col {
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
||||
</a>
|
||||
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||
<h2>Control de Horarios</h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
@@ -47,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ report_filters(
|
||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
||||
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||
workers_list,
|
||||
worker_actual,
|
||||
dia_actual,
|
||||
|
||||
@@ -1,48 +1,13 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from "macros/modals.html" import report_filters %}
|
||||
{% from "macros/ui.html" import back_link %}
|
||||
|
||||
{% block title %}Reporte: Cálculo de IVA - {{ modulo_name }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.table-container {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--bs-body-bg);
|
||||
border-right: 2px solid var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--bs-body-bg);
|
||||
box-shadow: inset 0 -1px 0 var(--bs-border-color);
|
||||
}
|
||||
|
||||
thead th.sticky-col {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.numeric-cell {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
||||
</a>
|
||||
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||
<h2>Cálculo de IVA</h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
@@ -51,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ report_filters(
|
||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
||||
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||
workers_list,
|
||||
worker_actual,
|
||||
dia_actual,
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from "macros/modals.html" import report_filters %}
|
||||
{% from "macros/ui.html" import back_link %}
|
||||
|
||||
{% block title %}Reporte: Finanzas - {{ modulo_name }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.numeric-cell {
|
||||
text-align: right;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
.total-column {
|
||||
font-weight: bold;
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background-color: #f8f9fa !important;
|
||||
border-right: 2px solid #dee2e6;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
||||
</a>
|
||||
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||
<h2>Resumen Financiero y Medios de Pago</h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
@@ -74,7 +52,7 @@
|
||||
</div>
|
||||
|
||||
{{ report_filters(
|
||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
||||
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||
workers_list,
|
||||
worker_actual,
|
||||
dia_actual,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<h4 class="text-info border-bottom border-secondary pb-2 mb-4">
|
||||
<i class="bi bi-geo-alt-fill me-2"></i>Zona: {{ zona_name }}
|
||||
</h4>
|
||||
|
||||
|
||||
<div class="row g-4">
|
||||
{% for mod in lista_modulos %}
|
||||
<div class="col-md-4 col-sm-6">
|
||||
@@ -45,23 +45,4 @@
|
||||
{{ reportes_menu_modal(mod[0], mod[1]) }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
<style>
|
||||
.hover-shadow:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.3)!important;
|
||||
border-color: #0dcaf0 !important;
|
||||
}
|
||||
.hover-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
background-color: #1e2125;
|
||||
}
|
||||
.hover-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.25)!important;
|
||||
}
|
||||
.transition-all {
|
||||
transition: all .3s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,18 +1,13 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% import "macros/modals.html" as modals %}
|
||||
{% from "macros/ui.html" import flashed_messages %}
|
||||
|
||||
{% block title %}Estructura Operativa{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">Estructura Operativa</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ flashed_messages() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
@@ -21,7 +16,7 @@
|
||||
<h5 class="mb-0">Gestión de Zonas</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('manage_structure') }}" class="mb-4">
|
||||
<form method="POST" action="{{ url_for('admin.manage_structure') }}" class="mb-4">
|
||||
<input type="hidden" name="action" value="add_zona">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="zona_name" placeholder="Nombre de la Zona (ej: Norte)" required>
|
||||
@@ -53,7 +48,7 @@
|
||||
'deleteZona' ~ zona[0],
|
||||
'Eliminar Zona',
|
||||
'¿Estás seguro de eliminar la zona "' ~ zona[1] ~ '"? Esto podría afectar a los módulos asociados.',
|
||||
url_for('delete_structure', type='zona', id=zona[0]),
|
||||
url_for('admin.delete_structure', type='zona', id=zona[0]),
|
||||
'btn-danger',
|
||||
'Eliminar'
|
||||
) }}
|
||||
@@ -76,7 +71,7 @@
|
||||
<h5 class="mb-0">Gestión de Módulos</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('manage_structure') }}" class="mb-4">
|
||||
<form method="POST" action="{{ url_for('admin.manage_structure') }}" class="mb-4">
|
||||
<input type="hidden" name="action" value="add_modulo">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5">
|
||||
@@ -122,7 +117,7 @@
|
||||
'deleteModulo' ~ modulo[0],
|
||||
'Eliminar Módulo',
|
||||
'¿Deseas eliminar el módulo "' ~ modulo[1] ~ '"?',
|
||||
url_for('delete_structure', type='modulo', id=modulo[0]),
|
||||
url_for('admin.delete_structure', type='modulo', id=modulo[0]),
|
||||
'btn-danger',
|
||||
'Eliminar'
|
||||
) }}
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from 'macros/modals.html' import confirm_modal, edit_worker_modal %}
|
||||
{% from "macros/ui.html" import flashed_messages %}
|
||||
|
||||
{% block title %}Gestión de Trabajadores{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!-- HEAD -->
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">Gestión de Trabajadores</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ flashed_messages() }}
|
||||
|
||||
{{ edit_worker_modal(modulos) }}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">Agregar Nuevo Trabajador</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('manage_workers') }}">
|
||||
<form method="POST" action="{{ url_for('admin.manage_workers') }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">RUT</label>
|
||||
@@ -134,7 +125,7 @@
|
||||
id='delWorker' ~ worker[0],
|
||||
title='Eliminar Trabajador',
|
||||
message='¿Eliminar a ' ~ worker[2] ~ '?',
|
||||
action_url=url_for('delete_worker', id=worker[0]),
|
||||
action_url=url_for('admin.delete_worker', id=worker[0]),
|
||||
btn_class='btn-danger'
|
||||
) }}
|
||||
</td>
|
||||
@@ -159,116 +150,5 @@
|
||||
) }}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
|
||||
<script>
|
||||
const editWorkerModal = document.getElementById('editWorkerModal');
|
||||
const confirmResetModal = document.getElementById('confirmResetPass');
|
||||
|
||||
if (editWorkerModal) {
|
||||
editWorkerModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
|
||||
// Si el modal se abre desde el botón "Editar" de la tabla
|
||||
if (button && button.hasAttribute('data-id')) {
|
||||
const id = button.getAttribute('data-id');
|
||||
const name = button.getAttribute('data-name');
|
||||
|
||||
const editForm = editWorkerModal.querySelector('#editWorkerForm');
|
||||
const resetForm = confirmResetModal.querySelector('form');
|
||||
|
||||
editForm.action = "/admin/workers/edit/" + id;
|
||||
resetForm.action = "/admin/workers/reset_password/" + id;
|
||||
|
||||
confirmResetModal.querySelector('.modal-body').textContent = `¿Estás seguro de generar una nueva contraseña para ${name}? La anterior dejará de funcionar.`;
|
||||
|
||||
editWorkerModal.querySelector('#edit_worker_rut').value = button.getAttribute('data-rut');
|
||||
editWorkerModal.querySelector('#edit_worker_name').value = name;
|
||||
editWorkerModal.querySelector('#edit_worker_phone').value = button.getAttribute('data-phone');
|
||||
editWorkerModal.querySelector('#edit_worker_modulo').value = button.getAttribute('data-modulo');
|
||||
editWorkerModal.querySelector('#edit_worker_tipo').value = button.getAttribute('data-tipo');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lógica para reabrir el modal de edición al cancelar el de confirmación
|
||||
if (confirmResetModal) {
|
||||
// Buscamos el botón de cancelar dentro del modal de confirmación
|
||||
const btnCancelar = confirmResetModal.querySelector('.btn-secondary');
|
||||
const btnCerrarX = confirmResetModal.querySelector('.btn-close');
|
||||
|
||||
const reabrirEdicion = () => {
|
||||
const modalEdicion = new bootstrap.Modal(editWorkerModal);
|
||||
modalEdicion.show();
|
||||
};
|
||||
|
||||
btnCancelar.addEventListener('click', reabrirEdicion);
|
||||
btnCerrarX.addEventListener('click', reabrirEdicion);
|
||||
}
|
||||
|
||||
document.getElementById('rutInput').addEventListener('input', function(e) {
|
||||
let value = this.value.replace(/[^0-9kK]/g, '').toUpperCase();
|
||||
|
||||
// Bloquear el largo interno a 9 caracteres (8 cuerpo + 1 dv)
|
||||
if (value.length > 9) {
|
||||
value = value.slice(0, 9);
|
||||
}
|
||||
|
||||
if (value.length > 1) {
|
||||
let body = value.slice(0, -1);
|
||||
let dv = value.slice(-1);
|
||||
body = body.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
this.value = `${body}-${dv}`;
|
||||
} else {
|
||||
this.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
function formatPhone(input) {
|
||||
let value = input.value.replace(/\D/g, '');
|
||||
if (value.startsWith('56')) value = value.substring(2);
|
||||
value = value.substring(0, 9);
|
||||
if (value.length > 5) input.value = value.replace(/(\d{1})(\d{4})(\d+)/, '$1 $2 $3');
|
||||
else if (value.length > 1) input.value = value.replace(/(\d{1})(\d+)/, '$1 $2');
|
||||
else input.value = value;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.phone-input, #phoneInput').forEach(inp => {
|
||||
inp.addEventListener('input', () => formatPhone(inp));
|
||||
});
|
||||
|
||||
|
||||
// --- LÓGICA DE FILTRADO DE TRABAJADORES ---
|
||||
const searchInputWorker = document.getElementById('searchWorker');
|
||||
const moduleSelectFilter = document.getElementById('filterModule');
|
||||
const typeSelectFilter = document.getElementById('filterType');
|
||||
const workerRows = document.querySelectorAll('.worker-row');
|
||||
|
||||
function filterWorkers() {
|
||||
const searchTerm = searchInputWorker.value.toLowerCase();
|
||||
const selectedModule = moduleSelectFilter.value;
|
||||
const selectedType = typeSelectFilter.value;
|
||||
|
||||
workerRows.forEach(row => {
|
||||
// Asumiendo que celda 0 es RUT y celda 1 es Nombre
|
||||
const rut = row.cells[0].textContent.toLowerCase();
|
||||
const name = row.cells[1].textContent.toLowerCase();
|
||||
const rowModule = row.getAttribute('data-modulo');
|
||||
const rowType = row.getAttribute('data-tipo');
|
||||
|
||||
const matchesSearch = rut.includes(searchTerm) || name.includes(searchTerm);
|
||||
const matchesModule = selectedModule === 'all' || rowModule === selectedModule;
|
||||
const matchesType = selectedType === 'all' || rowType === selectedType;
|
||||
|
||||
if (matchesSearch && matchesModule && matchesType) {
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
searchInputWorker.addEventListener('input', filterWorkers);
|
||||
moduleSelectFilter.addEventListener('change', filterWorkers);
|
||||
typeSelectFilter.addEventListener('change', filterWorkers);
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/admin_workers.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from "macros/ui.html" import flashed_messages %}
|
||||
|
||||
{% block title %}Inicio de sesión{% endblock %}
|
||||
|
||||
@@ -10,14 +11,8 @@
|
||||
<h4 class="mb-0">Iniciar Sesión</h4>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{{ flashed_messages() }}
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">RUT</label>
|
||||
@@ -43,22 +38,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('rutInput').addEventListener('input', function(e) {
|
||||
let value = this.value.replace(/[^0-9kK]/g, '').toUpperCase();
|
||||
|
||||
if (value.length > 9) {
|
||||
value = value.slice(0, 9);
|
||||
}
|
||||
|
||||
if (value.length > 1) {
|
||||
let body = value.slice(0, -1);
|
||||
let dv = value.slice(-1);
|
||||
body = body.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
this.value = `${body}-${dv}`;
|
||||
} else {
|
||||
this.value = value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>Sistema de Rendiciones - {% block title %}{% endblock %}</title>
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/x-icon">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/report-tables.css') }}">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
</head>
|
||||
@@ -21,7 +23,9 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='cookieStuff.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/format-helpers.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/navbar.js') }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted d-block mb-1">Observaciones:</span>
|
||||
<p class="mb-0 bg-dark p-2 rounded border border-secondary text-wrap text-break" style="font-size: 0.9em;">
|
||||
<p class="mb-0 bg-body-tertiary p-2 rounded border border-secondary text-wrap text-break" style="font-size: 0.9em;">
|
||||
{{ rendicion[9] if rendicion[9] else "Sin observaciones." }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -301,7 +301,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start">
|
||||
<form method="POST" action="{{ url_for('edit_rendicion', id=rendicion[0]) }}" id="editRendicionForm_{{ rendicion[0] }}">
|
||||
<form method="POST" action="{{ url_for('admin.edit_rendicion', id=rendicion[0]) }}" id="editRendicionForm_{{ rendicion[0] }}">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
@@ -400,32 +400,32 @@
|
||||
<label class="small text-muted mb-0">Débito</label>
|
||||
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_debito_{{ rendicion[0] }}" name="venta_debito" value="{{ '{:,.0f}'.format(rendicion[4] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_debito" value="{{ rendicion[16] or 0 }}">
|
||||
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_debito" value="{{ rendicion[16] or 0 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="small text-muted mb-0">Crédito</label>
|
||||
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_credito_{{ rendicion[0] }}" name="venta_credito" value="{{ '{:,.0f}'.format(rendicion[5] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_credito" value="{{ rendicion[17] or 0 }}">
|
||||
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_credito" value="{{ rendicion[17] or 0 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="small text-muted mb-0">Mercado Pago</label>
|
||||
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_mp_{{ rendicion[0] }}" name="venta_mp" value="{{ '{:,.0f}'.format(rendicion[6] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_mp" value="{{ rendicion[18] or 0 }}">
|
||||
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_mp" value="{{ rendicion[18] or 0 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="small text-muted mb-0">Efectivo</label>
|
||||
<input type="text" class="form-control form-control-sm text-end money-input mb-1" id="edit_efectivo_{{ rendicion[0] }}" name="venta_efectivo" value="{{ '{:,.0f}'.format(rendicion[7] or 0).replace(',', '.') }}" oninput="calcTotalEdit({{ rendicion[0] }})">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-dark border-secondary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-dark text-white border-secondary" name="boletas_efectivo" value="{{ rendicion[19] or 0 }}">
|
||||
<span class="input-group-text bg-body-tertiary text-muted" style="font-size: 0.7em;">Boletas</span>
|
||||
<input type="number" class="form-control text-center bg-body text-body border-secondary" name="boletas_efectivo" value="{{ rendicion[19] or 0 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -443,13 +443,13 @@
|
||||
<div class="mb-2">
|
||||
<label class="small text-danger fw-bold">Monto Gastos</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-dark border-danger text-danger">-$</span>
|
||||
<span class="input-group-text bg-danger-subtle border-danger text-danger">-$</span>
|
||||
<input type="text" class="form-control money-input border-danger text-end" name="gastos" value="{{ '{:,.0f}'.format(rendicion[8] or 0).replace(',', '.') }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="small text-muted">Observaciones</label>
|
||||
<textarea class="form-control form-control-sm bg-dark text-white" name="observaciones" rows="2">{{ rendicion[9] }}</textarea>
|
||||
<textarea class="form-control form-control-sm bg-body text-body" name="observaciones" rows="2">{{ rendicion[9] }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,7 +486,7 @@
|
||||
<h5 class="card-title mb-0">Detalle de Ventas</h5>
|
||||
</div>
|
||||
<p class="text-muted small flex-grow-1">Análisis detallado de ventas diarias, productos vendidos y consolidado mensual.</p>
|
||||
<a href="{{ url_for('report_modulo_periodo', modulo_id=modulo_id) }}" class="btn btn-primary w-100 mt-3">Generar Reporte</a>
|
||||
<a href="{{ url_for('admin.report_modulo_periodo', modulo_id=modulo_id) }}" class="btn btn-primary w-100 mt-3">Generar Reporte</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -501,7 +501,7 @@
|
||||
<h5 class="card-title mb-0">Comisiones</h5>
|
||||
</div>
|
||||
<p class="text-muted small flex-grow-1">Cálculo de comisiones generadas por los trabajadores en este módulo.</p>
|
||||
<a href="{{ url_for('report_modulo_comisiones', modulo_id=modulo_id) }}" class="btn btn-success w-100 mt-3">Generar Reporte</a>
|
||||
<a href="{{ url_for('admin.report_modulo_comisiones', modulo_id=modulo_id) }}" class="btn btn-success w-100 mt-3">Generar Reporte</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -516,7 +516,7 @@
|
||||
<h5 class="card-title mb-0">Control de Horarios</h5>
|
||||
</div>
|
||||
<p class="text-muted small flex-grow-1">Registro de horas de entrada y salida de los trabajadores y acompañantes.</p>
|
||||
<a href="{{ url_for('report_modulo_horarios', modulo_id=modulo_id) }}" class="btn btn-warning w-100 mt-3">Generar Reporte</a>
|
||||
<a href="{{ url_for('admin.report_modulo_horarios', modulo_id=modulo_id) }}" class="btn btn-warning w-100 mt-3">Generar Reporte</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -531,7 +531,7 @@
|
||||
<h5 class="card-title mb-0">Centros Comerciales</h5>
|
||||
</div>
|
||||
<p class="text-muted small flex-grow-1">Reporte de datos exigidos por la administración del centro comercial.</p>
|
||||
<a href="{{ url_for('report_modulo_centros_comerciales', modulo_id=modulo_id) }}" class="btn btn-info w-100 mt-3">Generar Reporte</a>
|
||||
<a href="{{ url_for('admin.report_modulo_centros_comerciales', modulo_id=modulo_id) }}" class="btn btn-info w-100 mt-3">Generar Reporte</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -546,7 +546,7 @@
|
||||
<h5 class="card-title mb-0">Cálculo de IVA</h5>
|
||||
</div>
|
||||
<p class="text-muted small flex-grow-1">Proyección de impuestos basados en las ventas declaradas.</p>
|
||||
<a href="{{ url_for('report_modulo_calculo_iva', modulo_id=modulo_id) }}" class="btn btn-secondary w-100 mt-3">Generar Reporte</a>
|
||||
<a href="{{ url_for('admin.report_modulo_calculo_iva', modulo_id=modulo_id) }}" class="btn btn-secondary w-100 mt-3">Generar Reporte</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary shadow-sm border-bottom mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex flex-column align-items-start text-primary-emphasis" href="{{ url_for('index') }}" style="gap: 0;">
|
||||
<a class="navbar-brand d-flex flex-column align-items-start text-primary-emphasis" href="{{ url_for('auth.index') }}" style="gap: 0;">
|
||||
|
||||
<div class="d-flex align-items-center">
|
||||
<i id="brandIcon" class="bi bi-receipt-cutoff fs-3 text-info me-2"></i>
|
||||
@@ -21,33 +21,33 @@
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if session.get('is_admin') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if 'rendiciones' in request.path }}" href="{{ url_for('admin_rendiciones') }}">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if 'rendiciones' in request.path }}" href="{{ url_for('admin.admin_rendiciones') }}">
|
||||
<i class="bi bi-journal-text me-1"></i> Rendiciones
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'manage_workers' }}" href="{{ url_for('manage_workers') }}">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'admin.manage_workers' }}" href="{{ url_for('admin.manage_workers') }}">
|
||||
<i class="bi bi-people me-1"></i> Trabajadores
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'manage_structure' }}" href="{{ url_for('manage_structure') }}">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'admin.manage_structure' }}" href="{{ url_for('admin.manage_structure') }}">
|
||||
<i class="bi bi-diagram-3 me-1"></i> Estructura
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'manage_products' }}" href="{{ url_for('manage_products') }}">
|
||||
<a class="nav-link d-flex align-items-center {{ 'active fw-bold' if request.endpoint == 'admin.manage_products' }}" href="{{ url_for('admin.manage_products') }}">
|
||||
<i class="bi bi-box-seam me-1"></i> Productos
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'reporte' in request.endpoint %}active fw-bold text-white{% endif %}" href="{{ url_for('admin_reportes_index') }}">
|
||||
<a class="nav-link {% if request.endpoint and 'reporte' in request.endpoint %}active fw-bold text-white{% endif %}" href="{{ url_for('admin.admin_reportes_index') }}">
|
||||
<i class="bi bi-graph-up me-1"></i> Reportes
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active fw-bold' if request.endpoint == 'worker_dashboard' }}" href="{{ url_for('worker_dashboard') }}">
|
||||
<a class="nav-link {{ 'active fw-bold' if request.endpoint == 'worker.worker_dashboard' }}" href="{{ url_for('worker.worker_dashboard') }}">
|
||||
<i class="bi bi-speedometer2 me-1"></i> Mis Rendiciones
|
||||
</a>
|
||||
</li>
|
||||
@@ -63,7 +63,7 @@
|
||||
<i class="bi bi-person-circle me-1 text-info"></i> {{ session.get('rut') }}
|
||||
</span>
|
||||
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger btn-sm rounded-pill px-3">
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger btn-sm rounded-pill px-3">
|
||||
Salir
|
||||
</a>
|
||||
</div>
|
||||
@@ -72,52 +72,3 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
@keyframes baguetteRoll {
|
||||
0% { transform: rotate(0deg) scale(1.2); }
|
||||
50% { transform: rotate(180deg) scale(1.5); }
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
.baguette-spin {
|
||||
display: inline-block;
|
||||
animation: baguetteRoll 1s ease-in-out;
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const brandIcon = document.getElementById("brandIcon");
|
||||
if (brandIcon) {
|
||||
let clickCount = 0;
|
||||
let clickResetTimer;
|
||||
|
||||
brandIcon.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
clickCount++;
|
||||
|
||||
clearTimeout(clickResetTimer);
|
||||
clickResetTimer = setTimeout(() => {
|
||||
clickCount = 0;
|
||||
}, 800);
|
||||
|
||||
if (clickCount >= 5) {
|
||||
clickCount = 0;
|
||||
clearTimeout(clickResetTimer);
|
||||
|
||||
const originalClass = this.className;
|
||||
|
||||
this.className = "fs-3 me-2 baguette-spin";
|
||||
this.innerHTML = "🥖";
|
||||
|
||||
setTimeout(() => {
|
||||
this.className = originalClass;
|
||||
this.innerHTML = "";
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
22
templates/macros/ui.html
Normal file
22
templates/macros/ui.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% macro flashed_messages() %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro back_link(url, text='Volver', icon='bi-arrow-left') %}
|
||||
<a href="{{ url }}" class="btn btn-outline-secondary btn-sm mb-2">
|
||||
<i class="bi {{ icon }}"></i> {{ text }}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro page_header(title, icon='', actions='') %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>{% if icon %}<i class="bi {{ icon }} me-2"></i>{% endif %}{{ title }}</h2>
|
||||
{% if actions %}{{ actions|safe }}{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,19 +1,14 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from 'macros/modals.html' import confirm_modal, alert_modal %}
|
||||
{% from "macros/ui.html" import flashed_messages, back_link %}
|
||||
|
||||
{% block title %}Rendición de Caja{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!-- HEAD -->
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ url_for('worker_dashboard') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
||||
<i class="bi bi-arrow-left"></i> Volver al Historial
|
||||
</a>
|
||||
{{ back_link(url_for('worker.worker_dashboard'), 'Volver al Historial') }}
|
||||
<h2>Nueva Rendición de Caja</h2>
|
||||
</div>
|
||||
<div class="text-end text-muted">
|
||||
@@ -22,13 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ flashed_messages() }}
|
||||
|
||||
<form method="POST">
|
||||
<div class="card mb-4 shadow-sm">
|
||||
@@ -241,228 +230,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('companion_select').addEventListener('change', function() {
|
||||
const timeDiv = document.getElementById('companion_times_div');
|
||||
const compIn = document.getElementById('comp_in');
|
||||
const compOut = document.getElementById('comp_out');
|
||||
if (this.value) {
|
||||
timeDiv.style.display = 'block';
|
||||
compIn.required = true;
|
||||
compOut.required = true;
|
||||
} else {
|
||||
timeDiv.style.display = 'none';
|
||||
compIn.required = false;
|
||||
compOut.required = false;
|
||||
compIn.value = '';
|
||||
compOut.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const inputsCantidad = document.querySelectorAll('input[name^="qty_"]');
|
||||
const displayTotalProductos = document.getElementById('total_productos_calc');
|
||||
|
||||
function calcularVentaProductos() {
|
||||
let granTotal = 0;
|
||||
const filas = document.querySelectorAll('tbody tr');
|
||||
|
||||
filas.forEach(fila => {
|
||||
const inputQty = fila.querySelector('input[name^="qty_"]');
|
||||
if (inputQty) {
|
||||
if (parseInt(inputQty.value) < 0) {
|
||||
inputQty.value = 0;
|
||||
}
|
||||
const cantidad = parseInt(inputQty.value) || 0;
|
||||
const precioTexto = fila.cells[1].innerText.replace(/\D/g, '');
|
||||
const precio = parseInt(precioTexto) || 0;
|
||||
granTotal += (cantidad * precio);
|
||||
}
|
||||
});
|
||||
displayTotalProductos.value = granTotal.toLocaleString('es-CL');
|
||||
|
||||
checkWarnings();
|
||||
}
|
||||
|
||||
inputsCantidad.forEach(input => {
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
|
||||
if (e.key < '0' || e.key > '9') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
calcularVentaProductos();
|
||||
});
|
||||
});
|
||||
|
||||
function checkWarnings() {
|
||||
const getVal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || !el.value.trim() || el.value === '0') return 0;
|
||||
return parseInt(el.value.replace(/\D/g, '')) || 0;
|
||||
};
|
||||
|
||||
const totalProductos = getVal('total_productos_calc');
|
||||
const totalDeclarado = getVal('total_general');
|
||||
const gastos = getVal('gastos');
|
||||
const efectivo = getVal('venta_efectivo');
|
||||
|
||||
let warnings = [];
|
||||
|
||||
// Comprobar diferencias en totales (solo si se ha ingresado algo para no mostrar el error de entrada)
|
||||
if ((totalProductos > 0 || totalDeclarado > 0) && totalProductos !== totalDeclarado) {
|
||||
warnings.push("El <strong>Total Venta por Productos</strong> no coincide con el <strong>Total Ventas Declaradas</strong>.");
|
||||
}
|
||||
|
||||
// Comprobar si los gastos superan el efectivo
|
||||
if (gastos > efectivo) {
|
||||
warnings.push("El <strong>Monto de Gastos</strong> es mayor que el <strong>Efectivo</strong> declarado.");
|
||||
}
|
||||
|
||||
const warningContainer = document.getElementById('discrepancy_warning');
|
||||
const warningText = document.getElementById('discrepancy_text');
|
||||
|
||||
if (warnings.length > 0) {
|
||||
warningText.innerHTML = warnings.join("<br>");
|
||||
warningContainer.style.display = 'block';
|
||||
} else {
|
||||
warningContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const submitModal = document.getElementById('confirmSubmitModal');
|
||||
const mainForm = document.querySelector('form');
|
||||
const alertModalEl = document.getElementById('globalAlertModal');
|
||||
const alertModal = new bootstrap.Modal(alertModalEl);
|
||||
const confirmBtn = submitModal.querySelector('button[type="submit"]');
|
||||
|
||||
function mostrarError(mensaje) {
|
||||
document.getElementById('globalAlertModalBody').textContent = mensaje;
|
||||
alertModal.show();
|
||||
}
|
||||
|
||||
function validarFormulario() {
|
||||
const requiredInputs = mainForm.querySelectorAll('[required]');
|
||||
let valid = true;
|
||||
|
||||
requiredInputs.forEach(input => {
|
||||
const isMoney = input.classList.contains('money-input');
|
||||
if (!input.value.trim() || (isMoney && input.value === '')) {
|
||||
input.classList.add('is-invalid');
|
||||
valid = false;
|
||||
} else {
|
||||
input.classList.remove('is-invalid');
|
||||
}
|
||||
});
|
||||
return valid;
|
||||
}
|
||||
|
||||
confirmBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (validarFormulario()) {
|
||||
mainForm.submit();
|
||||
} else {
|
||||
bootstrap.Modal.getInstance(submitModal).hide();
|
||||
mostrarError("Por favor, rellena los campos obligatorios (Fecha y Hora) antes de enviar.");
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.money-input').forEach(input => {
|
||||
if (!input.value.trim()) input.value = '0';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const inputsVenta = document.querySelectorAll('.sale-input');
|
||||
const displayDigital = document.getElementById('total_digital');
|
||||
const displayGeneral = document.getElementById('total_general');
|
||||
|
||||
function calcularTotales() {
|
||||
const getVal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || !el.value.trim() || el.value === '0') return 0;
|
||||
return parseInt(el.value.replace(/\D/g, '')) || 0;
|
||||
};
|
||||
|
||||
const debito = getVal('venta_debito');
|
||||
const credito = getVal('venta_credito');
|
||||
const mp = getVal('venta_mp');
|
||||
const efectivo = getVal('venta_efectivo');
|
||||
|
||||
const totalDigital = debito + credito + mp;
|
||||
const totalGeneral = totalDigital + efectivo; // Ya no restamos los gastos aquí
|
||||
|
||||
document.getElementById('total_digital').value = totalDigital.toLocaleString('es-CL');
|
||||
document.getElementById('total_general').value = totalGeneral.toLocaleString('es-CL');
|
||||
|
||||
checkWarnings();
|
||||
}
|
||||
|
||||
inputsVenta.forEach(input => {
|
||||
input.addEventListener('input', calcularTotales);
|
||||
});
|
||||
|
||||
document.querySelector('form').addEventListener('submit', function(e) {
|
||||
const requiredInputs = this.querySelectorAll('[required]');
|
||||
let valid = true;
|
||||
|
||||
requiredInputs.forEach(input => {
|
||||
const isMoney = input.classList.contains('money-input');
|
||||
if (!input.value.trim() || (isMoney && input.value === '')) {
|
||||
input.classList.add('is-invalid');
|
||||
valid = false;
|
||||
} else {
|
||||
input.classList.remove('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
e.preventDefault();
|
||||
const alertModalEl = document.getElementById('globalAlertModal');
|
||||
if (alertModalEl) {
|
||||
const alertModal = bootstrap.Modal.getOrCreateInstance(alertModalEl);
|
||||
document.getElementById('globalAlertModalBody').textContent = "Por favor, rellena todos los campos obligatorios antes de enviar.";
|
||||
alertModal.show();
|
||||
} else {
|
||||
alert("Por favor, rellena todos los campos obligatorios.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.money-input').forEach(function(input) {
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) return;
|
||||
if (e.key < '0' || e.key > '9') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('focus', function() {
|
||||
if (this.value === '0') this.value = '';
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function() {
|
||||
if (this.value.trim() === '' || this.value.trim() === '0') {
|
||||
this.value = '0';
|
||||
}
|
||||
calcularTotales();
|
||||
});
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
let value = this.value.replace(/\D/g, '');
|
||||
if (value !== '') {
|
||||
this.value = parseInt(value, 10).toLocaleString('es-CL');
|
||||
}
|
||||
calcularTotales();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/worker_dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,23 +1,18 @@
|
||||
{% extends "macros/base.html" %}
|
||||
{% from 'macros/modals.html' import rendicion_detail_modal %}
|
||||
{% from "macros/ui.html" import flashed_messages %}
|
||||
|
||||
{% block title %}Mis Rendiciones{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 gap-3">
|
||||
<h2 class="mb-0">Mis Rendiciones</h2>
|
||||
<a href="{{ url_for('new_rendicion') }}" class="btn btn-success shadow-sm align-self-start align-self-md-auto">
|
||||
<a href="{{ url_for('worker.new_rendicion') }}" class="btn btn-success shadow-sm align-self-start align-self-md-auto">
|
||||
<i class="bi bi-plus-circle me-2"></i>Nueva Rendición
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ flashed_messages() }}
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-0">
|
||||
|
||||
26
utils.py
26
utils.py
@@ -43,7 +43,7 @@ def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('index'))
|
||||
return redirect(url_for('auth.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@@ -52,7 +52,7 @@ def admin_required(f):
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session or not session.get('is_admin'):
|
||||
flash("Acceso denegado. Se requieren permisos de administrador.", "danger")
|
||||
return redirect(url_for('index'))
|
||||
return redirect(url_for('auth.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@@ -63,24 +63,4 @@ def get_report_params():
|
||||
mes = request.args.get('mes', f"{hoy.month:02d}")
|
||||
dia = request.args.get('dia')
|
||||
worker_id = request.args.get('worker_id')
|
||||
return anio, mes, dia, worker_id
|
||||
|
||||
def get_common_report_data(c, modulo_id):
|
||||
"""Obtiene nombre del módulo y lista de trabajadores para los filtros."""
|
||||
c.execute("SELECT name FROM modulos WHERE id = ?", (modulo_id,))
|
||||
modulo_info = c.fetchone()
|
||||
|
||||
c.execute('''
|
||||
SELECT DISTINCT w.id, w.name
|
||||
FROM workers w
|
||||
WHERE w.modulo_id = ? AND w.is_admin = 0
|
||||
ORDER BY w.name
|
||||
''', (modulo_id,))
|
||||
workers = c.fetchall()
|
||||
|
||||
c.execute("SELECT DISTINCT strftime('%Y', fecha) as anio FROM rendiciones ORDER BY anio DESC")
|
||||
anios = [row[0] for row in c.fetchall()]
|
||||
if str(date.today().year) not in anios:
|
||||
anios.insert(0, str(date.today().year))
|
||||
|
||||
return modulo_info[0] if modulo_info else "Módulo", workers, anios
|
||||
return anio, mes, dia, worker_id
|
||||
Reference in New Issue
Block a user