diff --git a/src/__init__.py b/src/__init__.py index 5102783..1792f82 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -108,14 +108,15 @@ def configure_logging(app): ) app.register_blueprint(google_bp, url_prefix="/login") -# Límite global -# 5 por segundo para proteger contra ataques de fuerza bruta -# 30 por minuto debería ser suficiente para la mayoría de los usuarios -# 3000 por día como límite máximo global, suficiente incluso si se deja la app abierta la 24 horas del día +# Límite global (doblado: la app es de uso interno y los valores anteriores +# saltaban demasiado) +# 10/s para proteger contra ataques de fuerza bruta +# 60/min holgado para uso normal +# 10000/día como límite máximo global limiter = Limiter( key_func=get_remote_address, app=app, - default_limits=["5 per second", "30 per minute", "5000 per day"], + default_limits=["10 per second", "60 per minute", "10000 per day"], storage_uri="memory://" ) @@ -233,7 +234,9 @@ def init_db(): app.register_blueprint(ausencias_bp) app.register_blueprint(admin_bp) -from src.cli import cerrar_anio_command, import_users_command, init_admin_command +from src.cli import cerrar_anio_command, import_users_command, init_admin_command, recalcular_command, cambiar_saldo_command app.cli.add_command(cerrar_anio_command) app.cli.add_command(import_users_command) -app.cli.add_command(init_admin_command) \ No newline at end of file +app.cli.add_command(init_admin_command) +app.cli.add_command(recalcular_command) +app.cli.add_command(cambiar_saldo_command) \ No newline at end of file diff --git a/src/cli.py b/src/cli.py index 86a3f3b..a56a0b7 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,7 +1,9 @@ import click from flask.cli import with_appcontext from src import db -from src.models import Usuario, SaldoVacaciones +from src.models import Usuario, SaldoVacaciones, SolicitudVacaciones, CambioSaldo + +MSG_OPERACION_CANCELADA = "❌ Operación cancelada" @click.command('cerrar-anio') @click.argument('anio_origen', type=int) @@ -45,7 +47,7 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an print(f"\n⚠️ ADVERTENCIA: Ya existen {saldos_nuevos} saldos para {anio_nuevo}") print(" Si quieres rehacer el cierre, usa --force") if not click.confirm('\n¿Continuar de todas formas?', default=False): - print("❌ Operación cancelada") + print(MSG_OPERACION_CANCELADA) return # ======================================== @@ -81,7 +83,7 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an if not force: print("\n" + "=" * 70) if not click.confirm('¿Proceder con el cierre de año?', default=False): - print("❌ Operación cancelada") + print(MSG_OPERACION_CANCELADA) return print("\n" + "=" * 70) @@ -163,6 +165,8 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an anio=anio_nuevo ).first() + saldo_aplicado = False + if not saldo_nuevo: saldo_nuevo = SaldoVacaciones( usuario_id=u.id, @@ -173,20 +177,36 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an ) db.session.add(saldo_nuevo) count_creados += 1 + saldo_aplicado = True print(f" {simbolo} {u.nombre:30} | Base: {dias_base_nuevo_anio:2} + Carryover: {dias_a_traspasar:3} = {total_nuevo:3} días") - + elif force: # Si force, actualizar saldo existente saldo_nuevo.dias_totales = total_nuevo saldo_nuevo.dias_carryover = dias_a_traspasar saldo_nuevo.dias_disfrutados = 0 # Reset count_actualizados += 1 + saldo_aplicado = True print(f" 🔄 {u.nombre:30} | ACTUALIZADO (force) = {total_nuevo} días") - + else: count_saltados += 1 print(f" ⏭️ {u.nombre:30} | Ya existe saldo {anio_nuevo} (saltado)") + # 5. Auditoría: registrar el ajuste de carryover si lo hubo + if saldo_aplicado and dias_a_traspasar != 0: + db.session.add(CambioSaldo( + usuario_id=u.id, + actor_id=None, + actor_label='system:cli', + anio=anio_nuevo, + dias_anteriores=dias_base_nuevo_anio, + dias_nuevos=total_nuevo, + delta=dias_a_traspasar, + motivo=f"Ajuste cierre {anio_origen}", + origen='cli', + )) + except Exception as e: errores.append(f"{u.nombre}: {str(e)}") print(f" ❌ {u.nombre}: ERROR - {str(e)}") @@ -303,6 +323,185 @@ def import_users_command(csv_file): print(f"\nResumen: {count_new} creados, {count_skip} saltados.") +@click.command('recalcular') +@click.option('--usuario', '-u', required=True, help='Email del usuario') +@click.option('--anio', type=int, default=None, help='Año a recalcular (default: año actual)') +@click.option('--dry-run', is_flag=True, help='Solo mostrar el resultado sin aplicar cambios') +@with_appcontext +def recalcular_command(usuario, anio, dry_run): + """ + Recalcula 'dias_disfrutados' y 'dias_restantes' de un usuario sumando + sus solicitudes de vacaciones aprobadas (es_actual=True, tipo_accion!=cancelacion) + cuya fecha_solicitud cae dentro del año indicado. + + Con --dry-run no se modifica nada, solo se muestra cómo quedaría el saldo. + + Ejemplos: + flask recalcular -u john.doe@adhara.io --dry-run + flask recalcular -u john.doe@adhara.io --anio 2025 + """ + from datetime import datetime + from sqlalchemy import func + + if anio is None: + anio = datetime.now().year + + user = Usuario.query.filter_by(email=usuario).first() + if not user: + print(f"❌ Usuario no encontrado: {usuario}") + return + + saldo = SaldoVacaciones.query.filter_by(usuario_id=user.id, anio=anio).first() + if not saldo: + print(f"❌ No hay saldo registrado para {user.nombre} ({user.email}) en {anio}") + return + + inicio_anio = datetime(anio, 1, 1) + inicio_anio_siguiente = datetime(anio + 1, 1, 1) + + base_query = SolicitudVacaciones.query.filter( + SolicitudVacaciones.usuario_id == user.id, + SolicitudVacaciones.estado == 'aprobada', + SolicitudVacaciones.es_actual == True, + SolicitudVacaciones.tipo_accion != 'cancelacion', + SolicitudVacaciones.fecha_solicitud >= inicio_anio, + SolicitudVacaciones.fecha_solicitud < inicio_anio_siguiente, + ) + + total = base_query.with_entities( + func.coalesce(func.sum(SolicitudVacaciones.dias_solicitados), 0) + ).scalar() + dias_disfrutados_calc = int(total or 0) + dias_restantes_calc = saldo.dias_totales - dias_disfrutados_calc + + dias_disfrutados_actual = saldo.dias_disfrutados + dias_restantes_actual = saldo.dias_totales - saldo.dias_disfrutados + + print("=" * 70) + print(" RECÁLCULO DE SALDO DE VACACIONES") + print("=" * 70) + print(f"\n👤 Usuario: {user.nombre} ({user.email})") + print(f"📅 Año: {anio}") + + print("\n📊 Saldo actual:") + print(f" • Días totales: {saldo.dias_totales}") + print(f" • Días disfrutados: {dias_disfrutados_actual}") + print(f" • Días restantes: {dias_restantes_actual}") + + print("\n🔢 Saldo recalculado:") + print(f" • Días totales: {saldo.dias_totales} (sin cambios)") + print(f" • Días disfrutados: {dias_disfrutados_calc}") + print(f" • Días restantes: {dias_restantes_calc}") + + diff = dias_disfrutados_calc - dias_disfrutados_actual + + solicitudes = base_query.order_by(SolicitudVacaciones.fecha_inicio).all() + if solicitudes: + print(f"\n📋 Solicitudes consideradas ({len(solicitudes)}):") + for s in solicitudes: + print(f" • {s.fecha_inicio} → {s.fecha_fin}: {s.dias_solicitados} días " + f"(solicitada {s.fecha_solicitud.strftime('%Y-%m-%d')}, {s.tipo_accion})") + else: + print(f"\n📋 No hay solicitudes aprobadas activas con fecha_solicitud en {anio}.") + + if diff == 0: + print("\n✅ No hay diferencias. El saldo ya está correcto.") + return + + simbolo = '+' if diff > 0 else '' + print(f"\n⚠️ Diferencia en disfrutados: {simbolo}{diff} días") + + if dry_run: + print("\n💡 Modo --dry-run: no se han aplicado cambios.") + return + + if not click.confirm('\n¿Aplicar el recálculo y actualizar el saldo?', default=False): + print(MSG_OPERACION_CANCELADA) + return + + saldo.dias_disfrutados = dias_disfrutados_calc + db.session.commit() + print("\n✅ Saldo actualizado.") + + +@click.command('cambiar-saldo') +@click.option('--usuario', '-u', required=True, help='Email del usuario') +@click.option('--delta', type=int, required=True, + help='Días a sumar (positivo) o restar (negativo). Ej: -2, +5') +@click.option('--motivo', '-m', required=True, help='Justificación obligatoria') +@click.option('--anio', type=int, default=None, help='Año del saldo (default: año actual)') +@click.option('--force', is_flag=True, help='Aplicar sin pedir confirmación') +@with_appcontext +def cambiar_saldo_command(usuario, delta, motivo, anio, force): + """ + Suma o resta días al SaldoVacaciones (dias_totales) de un usuario para + un año concreto, dejando un registro en la tabla de auditoría + 'cambios_saldo'. Actor = system:cli. + + No toca la base contractual (Usuario.dias_vacaciones); esa se ajusta + en el cierre anual. + + Ejemplos: + flask cambiar-saldo -u john.doe@adhara.io --delta 2 --motivo "Bonus proyecto X" + flask cambiar-saldo -u john.doe@adhara.io --delta -1 --motivo "Ajuste error de cómputo" --anio 2025 + """ + from datetime import datetime + from src.utils import aplicar_cambio_saldo + + if anio is None: + anio = datetime.now().year + + if delta == 0: + print("❌ --delta no puede ser 0.") + return + if not motivo.strip(): + print("❌ --motivo no puede estar vacío.") + return + + user = Usuario.query.filter_by(email=usuario).first() + if not user: + print(f"❌ Usuario no encontrado: {usuario}") + return + + saldo = SaldoVacaciones.query.filter_by(usuario_id=user.id, anio=anio).first() + dias_anteriores = saldo.dias_totales if saldo else user.dias_vacaciones + dias_proyectados = dias_anteriores + delta + + print("=" * 70) + print(" CAMBIO DE SALDO DE VACACIONES") + print("=" * 70) + print(f"\n👤 Usuario: {user.nombre} ({user.email})") + print(f"📅 Año: {anio}") + print(f"📝 Motivo: {motivo.strip()}") + print(f"\n📊 dias_totales:") + print(f" • Antes: {dias_anteriores}{' (saldo nuevo, base contractual)' if not saldo else ''}") + print(f" • Delta: {delta:+d}") + print(f" • Después: {dias_proyectados}") + + if dias_proyectados < 0: + print(f"\n❌ El nuevo total quedaría negativo ({dias_proyectados}). Operación abortada.") + return + + if not force and not click.confirm('\n¿Aplicar el cambio?', default=False): + print(MSG_OPERACION_CANCELADA) + return + + try: + cambio = aplicar_cambio_saldo( + usuario=user, + delta=delta, + motivo=motivo, + anio=anio, + actor=None, + origen='cli', + ) + except ValueError as e: + print(f"\n❌ {e}") + return + + print(f"\n✅ Saldo actualizado. Auditoría id={cambio.id}, actor={cambio.actor_label}.") + + @click.command('init-admin') @with_appcontext def init_admin_command(): diff --git a/src/models.py b/src/models.py index 95c5be9..ceab8a4 100644 --- a/src/models.py +++ b/src/models.py @@ -111,6 +111,33 @@ def __repr__(self): return f'' +class CambioSaldo(db.Model): + __tablename__ = 'cambios_saldo' + + __table_args__ = ( + db.Index('idx_cambio_saldo_usuario', 'usuario_id'), + db.Index('idx_cambio_saldo_fecha', 'fecha'), + ) + + id = db.Column(db.Integer, primary_key=True) + usuario_id = db.Column(db.Integer, db.ForeignKey('usuarios.id'), nullable=False) + actor_id = db.Column(db.Integer, db.ForeignKey('usuarios.id'), nullable=True) + actor_label = db.Column(db.String(100), nullable=False) + anio = db.Column(db.Integer, nullable=False) + dias_anteriores = db.Column(db.Integer, nullable=False) + dias_nuevos = db.Column(db.Integer, nullable=False) + delta = db.Column(db.Integer, nullable=False) + motivo = db.Column(db.Text, nullable=False) + origen = db.Column(db.String(20), nullable=False) + fecha = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + usuario = db.relationship('Usuario', foreign_keys=[usuario_id]) + actor = db.relationship('Usuario', foreign_keys=[actor_id]) + + def __repr__(self): + return f'' + + class Fichaje(db.Model): __tablename__ = 'fichajes' diff --git a/src/routes/admin.py b/src/routes/admin.py index 9b51a1f..b8cee05 100644 --- a/src/routes/admin.py +++ b/src/routes/admin.py @@ -6,8 +6,8 @@ from calendar import monthrange from src import db, admin_required -from src.models import Usuario, Aprobador, Fichaje, SolicitudVacaciones, Festivo, TipoAusencia, SolicitudBaja -from src.utils import invalidar_cache_festivos +from src.models import Usuario, Aprobador, Fichaje, SolicitudVacaciones, Festivo, TipoAusencia, SolicitudBaja, CambioSaldo, SaldoVacaciones +from src.utils import invalidar_cache_festivos, aplicar_cambio_saldo from . import admin_bp @admin_bp.route('/admin/usuarios') @@ -115,8 +115,9 @@ def admin_editar_usuario(id): usuario.nombre = request.form.get('nombre') usuario.email = request.form.get('email') usuario.rol = request.form.get('rol') - usuario.dias_vacaciones = int(request.form.get('dias_vacaciones', 25)) - + # Nota: dias_vacaciones (base contractual) ya no se modifica desde aquí. + # Para ajustes de saldo usar 'flask cambiar-saldo' (queda en auditoría). + password = request.form.get('password') password_changed = False @@ -147,8 +148,84 @@ def admin_editar_usuario(id): flash('Usuario actualizado correctamente', 'success') return redirect(url_for('admin.admin_usuarios')) - - return render_template('admin/editar_usuario.html', usuario=usuario) + + anio_actual = datetime.now().year + saldo_actual = SaldoVacaciones.query.filter_by(usuario_id=usuario.id, anio=anio_actual).first() + saldos = (SaldoVacaciones.query + .filter_by(usuario_id=usuario.id) + .order_by(SaldoVacaciones.anio.desc()) + .all()) + historial_saldo = (CambioSaldo.query + .filter_by(usuario_id=usuario.id) + .order_by(CambioSaldo.fecha.desc()) + .limit(5) + .all()) + + return render_template('admin/editar_usuario.html', + usuario=usuario, + anio_actual=anio_actual, + saldo_actual=saldo_actual, + saldos=saldos, + historial_saldo=historial_saldo) + + +@admin_bp.route('/admin/usuarios//cambiar-saldo', methods=['POST']) +@admin_required +def admin_cambiar_saldo(id): + usuario = Usuario.query.get_or_404(id) + motivo = (request.form.get('motivo') or '').strip() + + try: + delta = int(request.form.get('delta', '0')) + except (TypeError, ValueError): + flash('El valor de delta debe ser un número entero.', 'danger') + return redirect(url_for('admin.admin_editar_usuario', id=id)) + + anio_raw = request.form.get('anio') + try: + anio = int(anio_raw) if anio_raw else None + except (TypeError, ValueError): + flash('El año debe ser un número entero.', 'danger') + return redirect(url_for('admin.admin_editar_usuario', id=id)) + + try: + cambio = aplicar_cambio_saldo( + usuario=usuario, + delta=delta, + motivo=motivo, + anio=anio, + actor=current_user, + origen='gui', + ) + except ValueError as e: + flash(f'No se ha podido aplicar el cambio: {e}', 'danger') + return redirect(url_for('admin.admin_editar_usuario', id=id)) + + current_app.logger.info( + f"Cambio de saldo aplicado: {usuario.email} {cambio.delta:+d} días en {cambio.anio}", + extra={ + "event.action": "saldo-change", + "event.category": ["iam", "configuration"], + "event.module": "admin", + "user.target.id": usuario.id, + "user.target.email": usuario.email, + "saldo.anio": cambio.anio, + "saldo.delta": cambio.delta, + "saldo.dias_anteriores": cambio.dias_anteriores, + "saldo.dias_nuevos": cambio.dias_nuevos, + "saldo.motivo": cambio.motivo, + "actor.email": current_user.email, + "actor.id": current_user.id, + "source.ip": request.remote_addr, + } + ) + + flash( + f'Saldo de {usuario.nombre} actualizado: {cambio.dias_anteriores} → {cambio.dias_nuevos} ' + f'días en {cambio.anio} ({cambio.delta:+d}).', + 'success' + ) + return redirect(url_for('admin.admin_editar_usuario', id=id)) @admin_bp.route('/admin/usuarios/eliminar/', methods=['POST']) @admin_required @@ -906,9 +983,41 @@ def admin_auditoria(): 'motivo': b.motivo or '-' }) + # 4. AUDITORÍA DE CAMBIOS DE SALDO (manuales, vía CLI o futura GUI) + query_saldo = CambioSaldo.query.join(Usuario, CambioSaldo.usuario_id == Usuario.id) + + if usuario_nombre: + query_saldo = query_saldo.filter(Usuario.nombre.ilike(f'%{usuario_nombre}%')) + if fecha_inicio: + query_saldo = query_saldo.filter(CambioSaldo.fecha >= datetime.strptime(fecha_inicio, '%Y-%m-%d')) + if fecha_fin: + fin_saldo = datetime.strptime(fecha_fin, '%Y-%m-%d') + timedelta(days=1) + query_saldo = query_saldo.filter(CambioSaldo.fecha < fin_saldo) + + for c in query_saldo.all(): + if c.actor: + editor_label = c.actor.nombre + elif c.actor_label.startswith('system'): + editor_label = 'Sistema (CLI)' + else: + editor_label = c.actor_label + + logs_unificados.append({ + 'fecha_accion': c.fecha, + 'tipo_etiqueta': 'CAMBIO SALDO', + 'empleado': c.usuario.nombre, + 'editor': editor_label, + 'objeto': 'Saldo Vacaciones', + 'detalle': ( + f"Año {c.anio}: {c.dias_anteriores} → {c.dias_nuevos} días " + f"({c.delta:+d}) [{c.origen}]" + ), + 'motivo': c.motivo or '-', + }) + # Ordenar cronológicamente (más reciente arriba) logs_unificados.sort(key=lambda x: x['fecha_accion'], reverse=True) - + # Renderizamos la plantilla nueva que sabe mostrar esta lista unificada return render_template('admin/auditoria.html', logs=logs_unificados) diff --git a/src/utils.py b/src/utils.py index 8baecc0..c744010 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,6 +1,6 @@ from functools import lru_cache from datetime import timedelta, date, datetime -from src.models import Festivo, SolicitudVacaciones, SolicitudBaja, SaldoVacaciones, Fichaje +from src.models import Festivo, SolicitudVacaciones, SolicitudBaja, SaldoVacaciones, Fichaje, CambioSaldo from sqlalchemy import or_, and_ @@ -306,5 +306,84 @@ def verificar_solapamiento_fichaje(usuario_id, fecha, hora_entrada, hora_salida, if conflicto: return True, f"Solapamiento con fichaje existente ({conflicto.hora_entrada.strftime('%H:%M')} - {conflicto.hora_salida.strftime('%H:%M')})" - - return False, None \ No newline at end of file + + return False, None + + +def aplicar_cambio_saldo(usuario, delta, motivo, anio=None, actor=None, origen='gui'): + """ + Aplica un ajuste de saldo (positivo o negativo) sobre 'dias_totales' + del SaldoVacaciones del año indicado y registra el evento en la tabla + de auditoría 'cambios_saldo'. + + No modifica la base contractual (Usuario.dias_vacaciones); esa se ajusta + a través del cierre anual. + + Args: + usuario (Usuario): usuario destino del ajuste. + delta (int): días a sumar (>0) o restar (<0). No puede ser 0. + motivo (str): justificación obligatoria. + anio (int|None): año del saldo a ajustar. Por defecto, año actual. + actor (Usuario|None): quien realiza el cambio. None = sistema (CLI). + origen (str): 'cli' o 'gui'. + + Returns: + CambioSaldo: registro de auditoría creado. + + Raises: + ValueError: si motivo vacío, delta == 0 o el resultado quedaría < 0. + """ + from src import db + + if not motivo or not motivo.strip(): + raise ValueError("Se requiere una justificación (motivo).") + if delta == 0: + raise ValueError("El delta debe ser distinto de 0.") + if anio is None: + anio = datetime.now().year + + saldo = SaldoVacaciones.query.filter_by(usuario_id=usuario.id, anio=anio).first() + if saldo is None: + saldo = SaldoVacaciones( + usuario_id=usuario.id, + anio=anio, + dias_totales=usuario.dias_vacaciones, + dias_disfrutados=0, + dias_carryover=0, + ) + db.session.add(saldo) + db.session.flush() + + dias_anteriores = saldo.dias_totales + dias_nuevos = dias_anteriores + delta + + if dias_nuevos < 0: + raise ValueError( + f"El nuevo total ({dias_nuevos}) no puede ser negativo " + f"(actual: {dias_anteriores}, delta: {delta:+d})." + ) + + saldo.dias_totales = dias_nuevos + + if actor is not None: + actor_id = actor.id + actor_label = f'admin:{actor.email}' + else: + actor_id = None + actor_label = 'system:cli' + + cambio = CambioSaldo( + usuario_id=usuario.id, + actor_id=actor_id, + actor_label=actor_label, + anio=anio, + dias_anteriores=dias_anteriores, + dias_nuevos=dias_nuevos, + delta=delta, + motivo=motivo.strip(), + origen=origen, + ) + db.session.add(cambio) + db.session.commit() + + return cambio \ No newline at end of file diff --git a/templates/admin/auditoria.html b/templates/admin/auditoria.html index 9fec687..af1a687 100644 --- a/templates/admin/auditoria.html +++ b/templates/admin/auditoria.html @@ -56,6 +56,8 @@

Auditoría Global

{{ log.tipo_etiqueta }} {% elif 'CREACIÓN' in log.tipo_etiqueta %} {{ log.tipo_etiqueta }} + {% elif 'CAMBIO SALDO' in log.tipo_etiqueta %} + {{ log.tipo_etiqueta }} {% else %} {{ log.tipo_etiqueta }} {% endif %} diff --git a/templates/admin/editar_usuario.html b/templates/admin/editar_usuario.html index 978d124..a4159fb 100644 --- a/templates/admin/editar_usuario.html +++ b/templates/admin/editar_usuario.html @@ -34,9 +34,13 @@

Editar Usuario

- - + + +
+ + Este valor se ajusta en el cierre anual. Para añadir/restar días al saldo del año en curso + usa el botón "Ajustar saldo" (queda registrado en auditoría con justificación). +
@@ -48,4 +52,150 @@

Editar Usuario

+ +
+
+ Saldo de Vacaciones + +
+
+ {% if saldo_actual %} +

+ Año {{ anio_actual }}: + {{ saldo_actual.dias_totales }} totales · + {{ saldo_actual.dias_disfrutados }} disfrutados · + {{ saldo_actual.dias_totales - saldo_actual.dias_disfrutados }} restantes + {% if saldo_actual.dias_carryover %} + (carryover: {{ saldo_actual.dias_carryover }}) + {% endif %} +

+ {% else %} +

+ Sin saldo registrado para {{ anio_actual }}. Si aplicas un cambio se creará usando + {{ usuario.dias_vacaciones }} como base. +

+ {% endif %} + + {% if historial_saldo %} +
+
Últimos cambios manuales
+
+ + + + + + + + + + + + + + {% for h in historial_saldo %} + + + + + + + + + + {% endfor %} + +
FechaAñoAntes → DespuésΔOrigenPorMotivo
{{ h.fecha.strftime('%d/%m/%Y %H:%M') }}{{ h.anio }}{{ h.dias_anteriores }} → {{ h.dias_nuevos }} + + {{ '%+d' | format(h.delta) }} + + {{ h.origen }} + + {% if h.actor %}{{ h.actor.nombre }} + {% elif h.actor_label.startswith('system') %}Sistema (CLI) + {% else %}{{ h.actor_label }} + {% endif %} + + + + {{ h.motivo }} + +
+
+ {% endif %} +
+
+ + {% endblock %} \ No newline at end of file diff --git a/templates/admin/gestion_ausencias.html b/templates/admin/gestion_ausencias.html index 7d63899..bcb889d 100644 --- a/templates/admin/gestion_ausencias.html +++ b/templates/admin/gestion_ausencias.html @@ -79,6 +79,7 @@

Gestión de Ausencias

Hasta Días Estado + Creada Motivo @@ -111,6 +112,16 @@

Gestión de Ausencias

item.version }} {% endif %} + + {% if item.fecha_solicitud %} + + {{ item.fecha_solicitud.strftime('%d/%m/%Y') }}
+ {{ item.fecha_solicitud.strftime('%H:%M') }} +
+ {% else %} + - + {% endif %} + @@ -120,7 +131,7 @@

Gestión de Ausencias

{% else %} - + No se encontraron ausencias con estos filtros. diff --git a/templates/base.html b/templates/base.html index 27aa45d..cda776a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,6 +16,15 @@ background-color: #f8f9fa; } + body.sidebar-open { + overflow: hidden; + } + + .navbar-brand { + min-width: 0; + white-space: nowrap; + } + .sidebar { position: fixed; top: 56px; @@ -45,6 +54,7 @@ .main-content { margin-left: 250px; padding: 20px; + min-width: 0; } .card { @@ -52,16 +62,216 @@ box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); } - @media (max-width: 768px) { - .sidebar { + /* Overlay para cerrar el sidebar en móvil */ + .sidebar-overlay { + display: none; + position: fixed; + top: 56px; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1040; + opacity: 0; + transition: opacity 0.3s ease; + } + + .sidebar-overlay.show { + display: block; + opacity: 1; + } + + .table-responsive { + -webkit-overflow-scrolling: touch; + } + + .table { + vertical-align: middle; + } + + @media (max-width: 767.98px) { + body { + padding-top: 56px; + } + + h1 { + font-size: 1.55rem; + line-height: 1.25; + } + + h2 { + font-size: 1.35rem; + } + + h3 { + font-size: 1.2rem; + } + + .navbar .container-fluid { + flex-wrap: nowrap; + gap: 0.25rem; + } + + .navbar-brand { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + } + + .navbar-toggler { + flex: 0 0 auto; + padding: 0.25rem 0.5rem; + } + + .navbar-collapse { + position: fixed; + top: 56px; + left: 0; + right: 0; + padding: 0.75rem 1rem 1rem; + background-color: #212529; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2); + } + + .navbar-nav .dropdown-menu { + position: static; width: 100%; - height: auto; - position: relative; - top: 0; + margin-top: 0.5rem; + } + + .sidebar { + width: 280px; + max-width: calc(100vw - 3rem); + position: fixed; + top: 56px; + left: -280px; + bottom: 0; + z-index: 1045; + padding: 12px 0 max(20px, env(safe-area-inset-bottom)); + transition: left 0.3s ease; + } + + .sidebar.show { + left: 0; } .main-content { margin-left: 0; + padding: 16px 12px max(24px, env(safe-area-inset-bottom)); + width: 100%; + overflow-x: hidden; + } + + .container.mt-5 { + margin-top: 1rem !important; + padding-left: 12px; + padding-right: 12px; + } + + .card { + border-radius: 0.5rem; + margin-bottom: 16px; + } + + .card-body { + padding: 1rem; + } + + .d-flex.justify-content-between.align-items-center.mb-4 { + align-items: stretch !important; + flex-direction: column; + gap: 0.75rem; + } + + .d-flex.justify-content-between.align-items-center.mb-4 > .btn, + .d-flex.justify-content-between.align-items-center.mb-4 > .btn-group { + width: 100%; + } + + .btn-group { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .btn-group > .btn, + .btn-group > a, + .btn-group > form { + flex: 1 1 100%; + border-radius: 0.375rem !important; + } + + .alert.d-flex.justify-content-between.align-items-center { + align-items: flex-start !important; + flex-direction: column; + gap: 0.5rem; + } + + .row.g-3 > [class*="col-auto"] { + width: 100%; + } + + .row.g-3 > .ms-auto { + margin-left: 0 !important; + } + + .table-responsive { + margin-left: -1rem; + margin-right: -1rem; + padding-left: 1rem; + padding-right: 1rem; + } + + .table { + min-width: 640px; + font-size: 0.9rem; + } + + .table .btn, + .table .badge { + white-space: nowrap; + } + + .pagination { + flex-wrap: wrap; + gap: 0.25rem; + } + + .pagination .page-link { + border-radius: 0.375rem; + } + + .modal-dialog { + margin: 0.75rem; + } + + .fc .fc-toolbar { + align-items: stretch; + flex-direction: column; + gap: 0.5rem; + } + + .fc .fc-toolbar-chunk { + display: flex; + justify-content: center; + } + + .fc .fc-toolbar-title { + font-size: 1.1rem; + text-align: center; + } + + .fc .fc-button { + padding: 0.25rem 0.45rem; + font-size: 0.85rem; + } + } + + @media (prefers-reduced-motion: reduce) { + .sidebar, + .sidebar-overlay, + .sidebar a { + transition: none; } } @@ -71,6 +281,12 @@ {% if current_user.is_authenticated %} -