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
|
||||||
27
Dockerfile
27
Dockerfile
@@ -1,23 +1,34 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy source code
|
COPY app.py database.py utils.py ./
|
||||||
COPY *.py .
|
COPY routes/ ./routes/
|
||||||
|
COPY models/ ./models/
|
||||||
|
COPY services/ ./services/
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
#COPY .env .
|
COPY db/ ./db/
|
||||||
|
|
||||||
# Create the folder structure for the volume mounts
|
RUN mkdir -p /app/static/cache /app/db
|
||||||
RUN mkdir -p /app/static/cache
|
|
||||||
|
RUN useradd --create-home --shell /bin/bash appuser \
|
||||||
|
&& chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Run with unbuffered output so you can actually see the logs in Portainer
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
ENV PYTHONUNBUFFERED=1
|
CMD curl -fsS http://localhost:5000/login >/dev/null || exit 1
|
||||||
|
|
||||||
CMD ["python", "app.py"]
|
CMD ["python", "app.py"]
|
||||||
20
app.py
20
app.py
@@ -1,12 +1,20 @@
|
|||||||
|
import os
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from database import init_db
|
from database import init_db
|
||||||
from routes_auth import register_auth_routes
|
from models.models import db
|
||||||
from routes_worker import register_worker_routes
|
from routes.auth_bp import auth_bp
|
||||||
from routes_admin import register_admin_routes
|
from routes.worker_bp import worker_bp
|
||||||
|
from routes.admin_bp import admin_bp
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = "super_secret_dev_key"
|
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
|
@app.after_request
|
||||||
def add_header(response):
|
def add_header(response):
|
||||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
@@ -14,9 +22,9 @@ def add_header(response):
|
|||||||
response.headers["Expires"] = "0"
|
response.headers["Expires"] = "0"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
register_auth_routes(app)
|
app.register_blueprint(auth_bp)
|
||||||
register_worker_routes(app)
|
app.register_blueprint(worker_bp)
|
||||||
register_admin_routes(app)
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_db()
|
init_db()
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
name: rendiciones
|
name: rendiciones
|
||||||
|
|
||||||
services:
|
services:
|
||||||
rendiciones:
|
rendiciones:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: rendiciones:latest
|
||||||
|
container_name: rendiciones-server
|
||||||
ports:
|
ports:
|
||||||
- 5500:5000
|
- "5500:5000"
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
volumes:
|
volumes:
|
||||||
- /home/shironeko/Documents/dockerVols/rendiciones/db:/app/db
|
- /home/shironeko/Documents/dockerVols/rendiciones/db:/app/db
|
||||||
- /home/shironeko/Documents/dockerVols/rendiciones/static/cache:/app/static/cache
|
- /home/shironeko/Documents/dockerVols/rendiciones/static/cache:/app/static/cache
|
||||||
container_name: rendiciones-server
|
|
||||||
image: rendiciones:latest
|
|
||||||
restart: unless-stopped
|
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==3.1.3
|
||||||
Flask-Login==0.6.3
|
Flask-SQLAlchemy==3.1.1
|
||||||
Flask-SocketIO==5.6.1
|
Werkzeug==3.1.6
|
||||||
requests==2.32.5
|
SQLAlchemy==2.0.45
|
||||||
eventlet==0.36.1
|
|
||||||
|
|||||||
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';
|
||||||
|
});
|
||||||
|
});
|
||||||
138
static/style.css
138
static/style.css
@@ -1,4 +1,7 @@
|
|||||||
/* navbar */
|
/* ============================================================
|
||||||
|
GLOBAL THEME — applies to both light and dark modes
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
@@ -13,19 +16,34 @@
|
|||||||
transition: color 0.2s ease-in-out;
|
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 {
|
#theme-switcher.nav-link {
|
||||||
color: inherit !important;
|
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] {
|
[data-bs-theme="dark"] .form-control[readonly] {
|
||||||
background-color: #1a1d21;
|
background-color: #1a1d21;
|
||||||
border-color: #373b3e;
|
border-color: #373b3e;
|
||||||
@@ -35,22 +53,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] .text-muted-rut {
|
[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 {
|
[data-bs-theme="dark"] .custom-total-row {
|
||||||
background-color: rgba(30, 41, 59, 0.7);
|
background-color: rgba(30, 41, 59, 0.7);
|
||||||
@@ -65,7 +69,85 @@
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-info {
|
[data-bs-theme="dark"] #theme-icon.bi-sun {
|
||||||
color: #38bdf8 !important; /* Azul cielo moderno */
|
color: #ffc107;
|
||||||
text-shadow: 0 0 10px rgba(56, 189, 248, 0.2);
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
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" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from 'macros/modals.html' import confirm_modal, edit_product_modal %}
|
{% 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 title %}Catálogo de Productos{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<!-- HEAD -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="mb-4">Catálogo de Productos por Zona</h2>
|
<h2 class="mb-4">Catálogo de Productos por Zona</h2>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{{ flashed_messages() }}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{{ edit_product_modal(zonas) }}
|
{{ edit_product_modal(zonas) }}
|
||||||
|
|
||||||
<div class="card mb-4 shadow-sm">
|
<div class="card mb-4 shadow-sm">
|
||||||
<div class="card-header bg-primary text-white">Agregar Producto Maestro</div>
|
<div class="card-header bg-primary text-white">Agregar Producto Maestro</div>
|
||||||
<div class="card-body">
|
<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="row g-3">
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
<input type="text" class="form-control" name="name" placeholder="Nombre del Producto" required>
|
<input type="text" class="form-control" name="name" placeholder="Nombre del Producto" required>
|
||||||
@@ -69,7 +60,7 @@
|
|||||||
id='deleteProd' ~ prod.id,
|
id='deleteProd' ~ prod.id,
|
||||||
title='Eliminar Producto',
|
title='Eliminar Producto',
|
||||||
message='¿Eliminar "' ~ prod.name ~ '"? Esto fallará si el producto ya tiene ventas registradas.',
|
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_class='btn-danger',
|
||||||
btn_text='Eliminar'
|
btn_text='Eliminar'
|
||||||
) }}
|
) }}
|
||||||
@@ -85,7 +76,7 @@
|
|||||||
<div class="modal fade" id="pricesModal{{ prod.id }}" tabindex="-1">
|
<div class="modal fade" id="pricesModal{{ prod.id }}" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<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">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">Precios: {{ prod.name }}</h5>
|
<h5 class="modal-title">Precios: {{ prod.name }}</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<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.price).replace(',', '.') }}</td>
|
||||||
<td class="align-middle">${{ "{:,.0f}".format(futuro.commission).replace(',', '.') }}</td>
|
<td class="align-middle">${{ "{:,.0f}".format(futuro.commission).replace(',', '.') }}</td>
|
||||||
<td class="align-middle">
|
<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">
|
<button type="submit" class="btn btn-danger btn-sm py-0 px-2" title="Cancelar este cambio">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -196,132 +187,6 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/product-history-chart.js') }}"></script>
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/admin_productos.js') }}"></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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from 'macros/modals.html' import alert_modal, rendicion_detail_modal, confirm_modal, edit_rendicion_modal %}
|
{% 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 title %}Historial de Rendiciones{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h2 class="mb-0">Historial de Rendiciones</h2>
|
<h2 class="mb-0">Historial de Rendiciones</h2>
|
||||||
@@ -13,7 +11,7 @@
|
|||||||
|
|
||||||
<div class="card shadow-sm mb-4 border-0">
|
<div class="card shadow-sm mb-4 border-0">
|
||||||
<div class="card-body bg-body-tertiary rounded p-3">
|
<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="row g-2 align-items-end">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small text-muted mb-1">Año</label>
|
<label class="form-label small text-muted mb-1">Año</label>
|
||||||
@@ -77,13 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{{ flashed_messages() }}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@@ -123,7 +115,7 @@
|
|||||||
id='deleteRendicion' ~ r[0],
|
id='deleteRendicion' ~ r[0],
|
||||||
title='Eliminar Rendición',
|
title='Eliminar Rendición',
|
||||||
message='¿Estás seguro de que deseas eliminar la rendición #' ~ r[0] ~ '? Esta acción no se puede deshacer.',
|
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_class='btn-danger',
|
||||||
btn_text='Eliminar'
|
btn_text='Eliminar'
|
||||||
) }}
|
) }}
|
||||||
@@ -142,171 +134,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/admin_rendiciones.js') }}"></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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,48 +1,13 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from "macros/modals.html" import report_filters %}
|
{% from "macros/modals.html" import report_filters %}
|
||||||
|
{% from "macros/ui.html" import back_link %}
|
||||||
|
|
||||||
{% block title %}Reporte: Centros Comerciales - {{ modulo_name }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
|
||||||
</a>
|
|
||||||
<h2>Registro Centros Comerciales</h2>
|
<h2>Registro Centros Comerciales</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
@@ -51,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ report_filters(
|
{{ report_filters(
|
||||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||||
workers_list,
|
workers_list,
|
||||||
worker_actual,
|
worker_actual,
|
||||||
dia_actual,
|
dia_actual,
|
||||||
|
|||||||
@@ -1,30 +1,13 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from "macros/modals.html" import report_filters %}
|
{% from "macros/modals.html" import report_filters %}
|
||||||
|
{% from "macros/ui.html" import back_link %}
|
||||||
|
|
||||||
{% block title %}Reporte: Comisiones - {{ modulo_name }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
|
||||||
</a>
|
|
||||||
<h2>Reporte de Comisiones</h2>
|
<h2>Reporte de Comisiones</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
@@ -33,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ report_filters(
|
{{ report_filters(
|
||||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||||
workers_list,
|
workers_list,
|
||||||
worker_actual,
|
worker_actual,
|
||||||
dia_actual,
|
dia_actual,
|
||||||
|
|||||||
@@ -1,44 +1,13 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from "macros/modals.html" import report_filters %}
|
{% from "macros/modals.html" import report_filters %}
|
||||||
|
{% from "macros/ui.html" import back_link %}
|
||||||
|
|
||||||
{% block title %}Reporte: Horarios - {{ modulo_name }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
|
||||||
</a>
|
|
||||||
<h2>Control de Horarios</h2>
|
<h2>Control de Horarios</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
@@ -47,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ report_filters(
|
{{ report_filters(
|
||||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||||
workers_list,
|
workers_list,
|
||||||
worker_actual,
|
worker_actual,
|
||||||
dia_actual,
|
dia_actual,
|
||||||
|
|||||||
@@ -1,48 +1,13 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from "macros/modals.html" import report_filters %}
|
{% 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 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 %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
|
||||||
</a>
|
|
||||||
<h2>Cálculo de IVA</h2>
|
<h2>Cálculo de IVA</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
@@ -51,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ report_filters(
|
{{ report_filters(
|
||||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||||
workers_list,
|
workers_list,
|
||||||
worker_actual,
|
worker_actual,
|
||||||
dia_actual,
|
dia_actual,
|
||||||
|
|||||||
@@ -1,35 +1,13 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from "macros/modals.html" import report_filters %}
|
{% from "macros/modals.html" import report_filters %}
|
||||||
|
{% from "macros/ui.html" import back_link %}
|
||||||
|
|
||||||
{% block title %}Reporte: Finanzas - {{ modulo_name }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('admin_reportes_index') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
{{ back_link(url_for('admin.admin_reportes_index'), 'Volver al Menú') }}
|
||||||
<i class="bi bi-arrow-left"></i> Volver al Menú
|
|
||||||
</a>
|
|
||||||
<h2>Resumen Financiero y Medios de Pago</h2>
|
<h2>Resumen Financiero y Medios de Pago</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
@@ -74,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ report_filters(
|
{{ report_filters(
|
||||||
url_for('report_modulo_periodo', modulo_id=modulo_id),
|
url_for('admin.report_modulo_periodo', modulo_id=modulo_id),
|
||||||
workers_list,
|
workers_list,
|
||||||
worker_actual,
|
worker_actual,
|
||||||
dia_actual,
|
dia_actual,
|
||||||
|
|||||||
@@ -45,23 +45,4 @@
|
|||||||
{{ reportes_menu_modal(mod[0], mod[1]) }}
|
{{ reportes_menu_modal(mod[0], mod[1]) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% 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 %}
|
{% endblock %}
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% import "macros/modals.html" as modals %}
|
{% import "macros/modals.html" as modals %}
|
||||||
|
{% from "macros/ui.html" import flashed_messages %}
|
||||||
|
|
||||||
{% block title %}Estructura Operativa{% endblock %}
|
{% block title %}Estructura Operativa{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="mb-4">Estructura Operativa</h2>
|
<h2 class="mb-4">Estructura Operativa</h2>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{{ flashed_messages() }}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-4">
|
<div class="col-md-6 mb-4">
|
||||||
@@ -21,7 +16,7 @@
|
|||||||
<h5 class="mb-0">Gestión de Zonas</h5>
|
<h5 class="mb-0">Gestión de Zonas</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
<input type="hidden" name="action" value="add_zona">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" name="zona_name" placeholder="Nombre de la Zona (ej: Norte)" required>
|
<input type="text" class="form-control" name="zona_name" placeholder="Nombre de la Zona (ej: Norte)" required>
|
||||||
@@ -53,7 +48,7 @@
|
|||||||
'deleteZona' ~ zona[0],
|
'deleteZona' ~ zona[0],
|
||||||
'Eliminar Zona',
|
'Eliminar Zona',
|
||||||
'¿Estás seguro de eliminar la zona "' ~ zona[1] ~ '"? Esto podría afectar a los módulos asociados.',
|
'¿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',
|
'btn-danger',
|
||||||
'Eliminar'
|
'Eliminar'
|
||||||
) }}
|
) }}
|
||||||
@@ -76,7 +71,7 @@
|
|||||||
<h5 class="mb-0">Gestión de Módulos</h5>
|
<h5 class="mb-0">Gestión de Módulos</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
<input type="hidden" name="action" value="add_modulo">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
@@ -122,7 +117,7 @@
|
|||||||
'deleteModulo' ~ modulo[0],
|
'deleteModulo' ~ modulo[0],
|
||||||
'Eliminar Módulo',
|
'Eliminar Módulo',
|
||||||
'¿Deseas eliminar el módulo "' ~ modulo[1] ~ '"?',
|
'¿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',
|
'btn-danger',
|
||||||
'Eliminar'
|
'Eliminar'
|
||||||
) }}
|
) }}
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from 'macros/modals.html' import confirm_modal, edit_worker_modal %}
|
{% 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 title %}Gestión de Trabajadores{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<!-- HEAD -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="mb-4">Gestión de Trabajadores</h2>
|
<h2 class="mb-4">Gestión de Trabajadores</h2>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{{ flashed_messages() }}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{{ edit_worker_modal(modulos) }}
|
{{ edit_worker_modal(modulos) }}
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-primary text-white">Agregar Nuevo Trabajador</div>
|
<div class="card-header bg-primary text-white">Agregar Nuevo Trabajador</div>
|
||||||
<div class="card-body">
|
<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="row g-3">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">RUT</label>
|
<label class="form-label">RUT</label>
|
||||||
@@ -134,7 +125,7 @@
|
|||||||
id='delWorker' ~ worker[0],
|
id='delWorker' ~ worker[0],
|
||||||
title='Eliminar Trabajador',
|
title='Eliminar Trabajador',
|
||||||
message='¿Eliminar a ' ~ worker[2] ~ '?',
|
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'
|
btn_class='btn-danger'
|
||||||
) }}
|
) }}
|
||||||
</td>
|
</td>
|
||||||
@@ -159,116 +150,5 @@
|
|||||||
) }}
|
) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/admin_workers.js') }}"></script>
|
||||||
<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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
|
{% from "macros/ui.html" import flashed_messages %}
|
||||||
|
|
||||||
{% block title %}Inicio de sesión{% endblock %}
|
{% block title %}Inicio de sesión{% endblock %}
|
||||||
|
|
||||||
@@ -10,13 +11,7 @@
|
|||||||
<h4 class="mb-0">Iniciar Sesión</h4>
|
<h4 class="mb-0">Iniciar Sesión</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{{ flashed_messages() }}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -43,22 +38,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/login.js') }}"></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 %}
|
{% endblock %}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
<title>Sistema de Rendiciones - {% block title %}{% endblock %}</title>
|
<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="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='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@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">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
@@ -21,6 +23,8 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<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='cookieStuff.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='themeStuff.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 %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -275,7 +275,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted d-block mb-1">Observaciones:</span>
|
<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." }}
|
{{ rendicion[9] if rendicion[9] else "Sin observaciones." }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body text-start">
|
<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="row">
|
||||||
<div class="col-md-8 mb-3">
|
<div class="col-md-8 mb-3">
|
||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm h-100">
|
||||||
@@ -400,32 +400,32 @@
|
|||||||
<label class="small text-muted mb-0">Débito</label>
|
<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] }})">
|
<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">
|
<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>
|
<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-dark text-white border-secondary" name="boletas_debito" value="{{ rendicion[16] or 0 }}">
|
<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>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="small text-muted mb-0">Crédito</label>
|
<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] }})">
|
<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">
|
<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>
|
<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-dark text-white border-secondary" name="boletas_credito" value="{{ rendicion[17] or 0 }}">
|
<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>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="small text-muted mb-0">Mercado Pago</label>
|
<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] }})">
|
<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">
|
<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>
|
<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-dark text-white border-secondary" name="boletas_mp" value="{{ rendicion[18] or 0 }}">
|
<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>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="small text-muted mb-0">Efectivo</label>
|
<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] }})">
|
<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">
|
<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>
|
<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-dark text-white border-secondary" name="boletas_efectivo" value="{{ rendicion[19] or 0 }}">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,13 +443,13 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="small text-danger fw-bold">Monto Gastos</label>
|
<label class="small text-danger fw-bold">Monto Gastos</label>
|
||||||
<div class="input-group input-group-sm">
|
<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>
|
<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>
|
</div>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<label class="small text-muted">Observaciones</label>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -486,7 +486,7 @@
|
|||||||
<h5 class="card-title mb-0">Detalle de Ventas</h5>
|
<h5 class="card-title mb-0">Detalle de Ventas</h5>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small flex-grow-1">Análisis detallado de ventas diarias, productos vendidos y consolidado mensual.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,7 +501,7 @@
|
|||||||
<h5 class="card-title mb-0">Comisiones</h5>
|
<h5 class="card-title mb-0">Comisiones</h5>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small flex-grow-1">Cálculo de comisiones generadas por los trabajadores en este módulo.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,7 +516,7 @@
|
|||||||
<h5 class="card-title mb-0">Control de Horarios</h5>
|
<h5 class="card-title mb-0">Control de Horarios</h5>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small flex-grow-1">Registro de horas de entrada y salida de los trabajadores y acompañantes.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -531,7 +531,7 @@
|
|||||||
<h5 class="card-title mb-0">Centros Comerciales</h5>
|
<h5 class="card-title mb-0">Centros Comerciales</h5>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small flex-grow-1">Reporte de datos exigidos por la administración del centro comercial.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,7 +546,7 @@
|
|||||||
<h5 class="card-title mb-0">Cálculo de IVA</h5>
|
<h5 class="card-title mb-0">Cálculo de IVA</h5>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small flex-grow-1">Proyección de impuestos basados en las ventas declaradas.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary shadow-sm border-bottom mb-4">
|
<nav class="navbar navbar-expand-lg bg-body-tertiary shadow-sm border-bottom mb-4">
|
||||||
<div class="container">
|
<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">
|
<div class="d-flex align-items-center">
|
||||||
<i id="brandIcon" class="bi bi-receipt-cutoff fs-3 text-info me-2"></i>
|
<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">
|
<ul class="navbar-nav me-auto">
|
||||||
{% if session.get('is_admin') %}
|
{% if session.get('is_admin') %}
|
||||||
<li class="nav-item">
|
<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
|
<i class="bi bi-journal-text me-1"></i> Rendiciones
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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
|
<i class="bi bi-people me-1"></i> Trabajadores
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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
|
<i class="bi bi-diagram-3 me-1"></i> Estructura
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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
|
<i class="bi bi-box-seam me-1"></i> Productos
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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
|
<i class="bi bi-graph-up me-1"></i> Reportes
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item">
|
<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
|
<i class="bi bi-speedometer2 me-1"></i> Mis Rendiciones
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
<i class="bi bi-person-circle me-1 text-info"></i> {{ session.get('rut') }}
|
<i class="bi bi-person-circle me-1 text-info"></i> {{ session.get('rut') }}
|
||||||
</span>
|
</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
|
Salir
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,52 +72,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</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" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from 'macros/modals.html' import confirm_modal, alert_modal %}
|
{% 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 title %}Rendición de Caja{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<!-- HEAD -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('worker_dashboard') }}" class="btn btn-outline-secondary btn-sm mb-2">
|
{{ back_link(url_for('worker.worker_dashboard'), 'Volver al Historial') }}
|
||||||
<i class="bi bi-arrow-left"></i> Volver al Historial
|
|
||||||
</a>
|
|
||||||
<h2>Nueva Rendición de Caja</h2>
|
<h2>Nueva Rendición de Caja</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end text-muted">
|
<div class="text-end text-muted">
|
||||||
@@ -22,13 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{{ flashed_messages() }}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="card mb-4 shadow-sm">
|
<div class="card mb-4 shadow-sm">
|
||||||
@@ -241,228 +230,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script src="{{ url_for('static', filename='js/worker_dashboard.js') }}"></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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from 'macros/modals.html' import rendicion_detail_modal %}
|
{% from 'macros/modals.html' import rendicion_detail_modal %}
|
||||||
|
{% from "macros/ui.html" import flashed_messages %}
|
||||||
|
|
||||||
{% block title %}Mis Rendiciones{% endblock %}
|
{% block title %}Mis Rendiciones{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 gap-3">
|
<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>
|
<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
|
<i class="bi bi-plus-circle me-2"></i>Nueva Rendición
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{{ flashed_messages() }}
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
|||||||
24
utils.py
24
utils.py
@@ -43,7 +43,7 @@ def login_required(f):
|
|||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if 'user_id' not in session:
|
if 'user_id' not in session:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('auth.index'))
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ def admin_required(f):
|
|||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if 'user_id' not in session or not session.get('is_admin'):
|
if 'user_id' not in session or not session.get('is_admin'):
|
||||||
flash("Acceso denegado. Se requieren permisos de administrador.", "danger")
|
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 f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
@@ -64,23 +64,3 @@ def get_report_params():
|
|||||||
dia = request.args.get('dia')
|
dia = request.args.get('dia')
|
||||||
worker_id = request.args.get('worker_id')
|
worker_id = request.args.get('worker_id')
|
||||||
return anio, mes, dia, 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
|
|
||||||
Reference in New Issue
Block a user