feat: abm comisiones, hitos, links virtuales, eliminacion usuarios y roles bedelia y alumno

This commit is contained in:
Carlos Tello
2026-09-03 04:34:54 -03:00
parent 8809ff3410
commit f3a4b5875e
10 changed files with 1297 additions and 361 deletions
+5 -1
View File
@@ -47,6 +47,7 @@ class AcademicMilestone(db.Model):
title = db.Column(db.String(150), nullable=True)
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
@@ -56,8 +57,9 @@ class AcademicMilestone(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Asignatura
# Relación con Asignatura y Comisión
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
@property
def tipo(self):
@@ -72,6 +74,8 @@ class AcademicMilestone(db.Model):
'id': self.id,
'title': self.title or self.tipo,
'materiaCodigo': self.materia_codigo,
'subject_id': self.subject_id,
'commission_id': self.commission_id,
'tipo': self.tipo,
'milestone_type_id': self.milestone_type_id,
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
+3
View File
@@ -50,6 +50,7 @@ class Commission(db.Model):
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -87,6 +88,8 @@ class Commission(db.Model):
'max_students': self.max_students,
'current_students': self.current_students,
'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
+51
View File
@@ -179,6 +179,36 @@ def user_toggle(id):
return redirect(url_for('admin.users_list'))
@admin_bp.route('/users/delete/<int:id>', methods=['POST'])
@admin_required
def user_delete(id):
"""Delete a user account"""
if id == current_user.id:
flash(_('No puedes eliminar tu propia cuenta mientras estás en sesión activa.'), 'error')
return redirect(url_for('admin.users_list'))
user = User.query.get_or_404(id)
# Protect sole system administrator
if user.is_admin():
admin_count = User.query.filter(User.role.ilike('%ADMIN%'), User.is_active == True).count()
if admin_count <= 1:
flash(_('No es posible eliminar al único administrador activo del sistema.'), 'error')
return redirect(url_for('admin.users_list'))
# Unassign user from commissions as teacher
from app.models.subject import Commission
Commission.query.filter_by(teacher_id=id).update({'teacher_id': None})
name = user.name
email = user.email
db.session.delete(user)
db.session.commit()
flash(_('El usuario "%(name)s" (%(email)s) ha sido eliminado del sistema.', name=name, email=email), 'success')
return redirect(url_for('admin.users_list'))
# ---------------------------------------------------------
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
# ---------------------------------------------------------
@@ -467,6 +497,27 @@ def milestone_type_toggle(id):
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/milestone-types/delete/<int:id>', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_delete(id):
"""Delete a milestone type if unused, or deactivate with notice"""
item = MilestoneType.query.get_or_404(id)
from app.models.milestone import AcademicMilestone
milestone_count = AcademicMilestone.query.filter_by(milestone_type_id=id).count()
if milestone_count > 0:
item.is_active = False
db.session.commit()
flash(_('La tipificación "%(name)s" tiene %(count)d hitos registrados. Se ha desactivado para conservar el historial académico.', name=item.name, count=milestone_count), 'warning')
else:
name = item.name
db.session.delete(item)
db.session.commit()
flash(_('Tipificación "%(name)s" eliminada permanentemente.', name=name), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/api/milestone-types')
@login_required
def api_milestone_types():
+229 -6
View File
@@ -157,19 +157,242 @@ def calendar_view():
available_floors=available_floors
)
@schedule_bp.route('/commissions')
def commissions_view():
subjects = Subject.query.all()
def user_can_manage_commission_milestones(user, commission=None):
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
if user.is_admin():
return True
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
return True
if role_name in ['docente', 'profesor']:
return True
return user.has_permission('schedule', 'read_write')
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
@schedule_bp.route('/commissions')
@login_required
def commissions_view():
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
from app.models.subject import Commission, Subject
from app.models.classroom import Classroom
from app.models.user import User
from app.models.milestone import MilestoneType
subjects = Subject.query.order_by(Subject.code.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
# Totales para indicadores superiores
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
# Calculamos el total de comisiones
total_commissions = sum(len(s.commissions) for s in subjects)
can_manage_milestones = user_can_manage_commission_milestones(current_user)
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
return render_template('schedule/commissions.html',
subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity,
total_commissions=total_commissions)
total_commissions=total_commissions,
can_manage_milestones=can_manage_milestones,
can_edit_commissions=can_edit_commissions)
@schedule_bp.route('/commissions/add', methods=['POST'])
@login_required
def commission_add():
"""Crear una nueva comisión de cursada"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para crear comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
subject_id = request.form.get('subject_id', type=int)
code = request.form.get('code', '').strip()
semester = request.form.get('semester', '1C').strip()
year = request.form.get('year', datetime.now().year, type=int)
teacher_id = request.form.get('teacher_id', type=int) or None
max_students = request.form.get('max_students', 40, type=int)
schedule = request.form.get('schedule', '').strip()
shift = request.form.get('shift', 'Tarde').strip()
virtual_link = request.form.get('virtual_link', '').strip()
if not subject_id or not code:
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
return redirect(url_for('schedule.commissions_view'))
# Verificar existencia duplicada
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
if existing:
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
return redirect(url_for('schedule.commissions_view'))
new_comm = Commission(
subject_id=subject_id,
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link or None,
active=True
)
db.session.add(new_comm)
db.session.commit()
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
@login_required
def commission_edit(id):
"""Editar datos de una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para modificar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
commission.code = request.form.get('code', commission.code).strip()
commission.semester = request.form.get('semester', commission.semester).strip()
commission.year = request.form.get('year', commission.year, type=int)
commission.teacher_id = request.form.get('teacher_id', type=int) or None
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
commission.schedule = request.form.get('schedule', commission.schedule).strip()
commission.shift = request.form.get('shift', commission.shift).strip()
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
db.session.commit()
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
@login_required
def commission_delete(id):
"""Eliminar una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
full_code = commission.get_full_code()
db.session.delete(commission)
db.session.commit()
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
@login_required
def commission_virtual_link(id):
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
link = request.form.get('virtual_link', '').strip()
commission.virtual_link = link or None
db.session.commit()
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
@login_required
def commission_milestone_add(id):
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone, MilestoneType
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone_type_id = request.form.get('milestone_type_id', type=int)
title = request.form.get('title', '').strip()
date_str = request.form.get('date', '').strip()
time_str = request.form.get('time', '18:00').strip()
classroom = request.form.get('classroom', '').strip()
link = request.form.get('link', '').strip() or commission.virtual_link or ''
notes = request.form.get('notes', '').strip()
if not milestone_type_id or not date_str:
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
return redirect(url_for('schedule.commissions_view'))
m_type = MilestoneType.query.get(milestone_type_id)
if not m_type:
flash(_('Tipo de hito inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
flash(_('Formato de fecha inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone(
title=title or m_type.name,
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
subject_id=commission.subject_id,
commission_id=commission.id,
milestone_type_id=m_type.id,
date=dt,
classroom=classroom or 'Campus Virtual',
link=link or None,
notes=notes or None,
is_active=True
)
db.session.add(milestone)
db.session.commit()
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
@login_required
def commission_milestone_delete(commission_id, milestone_id):
"""Eliminar un hito académico de una comisión"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone
commission = Commission.query.get_or_404(commission_id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
title = milestone.title
db.session.delete(milestone)
db.session.commit()
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required
+128 -46
View File
@@ -3,7 +3,7 @@
{% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
@@ -16,7 +16,7 @@
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-tags text-warning me-2"></i>{% trans %}Tipificaciones de Hitos Académicos{% endtrans %}
</h1>
<p class="text-muted mb-0">
<p class="text-muted mb-0 small">
{% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %}
</p>
</div>
@@ -43,9 +43,9 @@
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4" style="width: 70px;">{% trans %}Color{% endtrans %}</th>
<th>{% trans %}Nombre Visible{% endtrans %}</th>
<th class="ps-4">{% trans %}Tipificación e Insignia{% endtrans %}</th>
<th>{% trans %}Código / Slug{% endtrans %}</th>
<th>{% trans %}Color Hex{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
<th class="text-center">{% trans %}Orden{% endtrans %}</th>
<th class="text-center">{% trans %}Estado{% endtrans %}</th>
@@ -56,19 +56,24 @@
{% for item in types %}
<tr id="row-{{ item.id }}">
<td class="ps-4">
<span class="d-inline-block rounded-circle shadow-sm border border-light"
style="width: 26px; height: 26px; background-color: {{ item.color or '#0d6efd' }}; vertical-align: middle;"
title="{{ item.color }}"></span>
</td>
<td>
<div class="fw-bold fs-6">{{ item.name }}</div>
<span class="badge rounded-pill text-white shadow-xs" style="background-color: {{ item.color or '#0d6efd' }}; font-size: 0.72rem;">
{{ item.name }}
</span>
<div class="d-flex align-items-center">
<span class="rounded-circle shadow-xs me-3 flex-shrink-0 border"
style="width: 22px; height: 22px; background-color: {{ item.color or '#0d6efd' }};"
title="{{ item.color }}"></span>
<div>
<span class="fw-bold text-dark-emphasis">{{ item.name }}</span>
<span class="badge rounded-pill text-white ms-2 shadow-xs" style="background-color: {{ item.color or '#0d6efd' }}; font-size: 0.70rem;">
{{ item.name }}
</span>
</div>
</div>
</td>
<td>
<code class="text-primary fw-semibold">{{ item.code }}</code>
</td>
<td>
<span class="font-monospace small text-muted">{{ item.color or '#0d6efd' }}</span>
</td>
<td>
<span class="text-muted small">{{ item.description or '—' }}</span>
</td>
@@ -94,20 +99,30 @@
{% endif %}
</td>
<td class="text-end pe-4">
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-primary py-1 px-2 btn-edit"
data-id="{{ item.id }}"
data-name="{{ item.name }}"
data-code="{{ item.code }}"
data-color="{{ item.color or '#0d6efd' }}"
data-description="{{ item.description or '' }}"
data-sort-order="{{ item.sort_order }}"
data-is-active="{{ 'true' if item.is_active else 'false' }}">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Editar{% endtrans %}
</button>
{% else %}
<span class="text-muted small"><i class="bi bi-lock me-1"></i>{% trans %}Solo lectura{% endtrans %}</span>
{% endif %}
<div class="btn-group btn-group-sm">
{% if can_edit %}
<button type="button" class="btn btn-outline-primary btn-edit"
data-id="{{ item.id }}"
data-name="{{ item.name }}"
data-code="{{ item.code }}"
data-color="{{ item.color or '#0d6efd' }}"
data-description="{{ item.description or '' }}"
data-sort-order="{{ item.sort_order }}"
data-is-active="{{ 'true' if item.is_active else 'false' }}"
title="{% trans %}Editar Tipificación{% endtrans %}">
<i class="bi bi-pencil-square"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-delete-type"
data-id="{{ item.id }}"
data-name="{{ item.name }}"
data-action-url="{{ url_for('admin.milestone_type_delete', id=item.id) }}"
title="{% trans %}Eliminar Tipificación{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
{% else %}
<span class="text-muted small"><i class="bi bi-lock me-1"></i>{% trans %}Solo lectura{% endtrans %}</span>
{% endif %}
</div>
</td>
</tr>
{% else %}
@@ -151,41 +166,39 @@
</div>
<div class="col-md-5">
<label for="add_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
<div class="input-group">
<input type="color" class="form-control form-control-color w-100" id="add_color" name="color" value="#0d6efd" title="Elegir color">
</div>
<input type="color" class="form-control form-control-color w-100" id="add_color" name="color" value="#B43E8E" title="Elegir color">
</div>
</div>
<!-- Paleta rápida de colores UniCABA -->
<div class="mb-3">
<label class="form-label small text-muted d-block mb-1">{% trans %}Colores sugeridos{% endtrans %}:</label>
<div class="d-flex gap-2 flex-wrap" id="presetColorsAdd">
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #ffc107;" data-color="#ffc107"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #0dcaf0;" data-color="#0dcaf0"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d"></button>
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
<div class="d-flex flex-wrap gap-2" id="presetColorsAdd">
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
</div>
</div>
<div class="mb-3">
<label for="add_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea class="form-control" id="add_description" name="description" rows="2" placeholder="Opcional: detalles sobre el tipo de evaluación..."></textarea>
<label for="add_description" class="form-label fw-semibold">{% trans %}Descripción / Notas{% endtrans %}</label>
<textarea class="form-control" id="add_description" name="description" rows="2" placeholder="Detalle o alcance de este tipo de evaluación..."></textarea>
</div>
<div class="row g-3 mb-2">
<div class="col-6">
<label for="add_sort_order" class="form-label fw-semibold">{% trans %}Orden de Aparición{% endtrans %}</label>
<label for="add_sort_order" class="form-label fw-semibold">{% trans %}Orden de Visualización{% endtrans %}</label>
<input type="number" class="form-control" id="add_sort_order" name="sort_order" value="10" min="0">
</div>
<div class="col-6 d-flex align-items-center pt-3">
<div class="form-check form-switch pt-2">
<input class="form-check-input" type="checkbox" id="add_is_active" name="is_active" value="1" checked>
<label class="form-check-label fw-semibold" for="add_is_active">{% trans %}Activo{% endtrans %}</label>
<label class="form-check-label fw-semibold" for="add_is_active">{% trans %}Activo de inmediato{% endtrans %}</label>
</div>
</div>
</div>
@@ -193,7 +206,7 @@
<div class="modal-footer bg-body-tertiary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Crear Tipificación{% endtrans %}
<i class="bi bi-save me-1"></i>{% trans %}Crear Tipificación{% endtrans %}
</button>
</div>
</form>
@@ -229,6 +242,21 @@
</div>
</div>
<!-- Paleta rápida de colores en Edición -->
<div class="mb-3">
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
<div class="d-flex flex-wrap gap-2" id="presetColorsEdit">
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
</div>
</div>
<div class="mb-3">
<label for="edit_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea class="form-control" id="edit_description" name="description" rows="2"></textarea>
@@ -257,6 +285,35 @@
</div>
</div>
</div>
<!-- Modal Global de Eliminación de Tipificación (fuera de la tabla) -->
<div class="modal fade" id="deleteMilestoneTypeModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar la tipificación{% endtrans %} <strong id="deleteMilestoneTypeName" class="text-primary"></strong>?</p>
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Si existen hitos vinculados a este tipo, el sistema lo desactivará automáticamente para preservar el historial.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteMilestoneTypeForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Tipificación{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
@@ -281,7 +338,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Color preset clicks
// Color preset clicks for Add Modal
document.querySelectorAll('#presetColorsAdd button').forEach(function(btn) {
btn.addEventListener('click', function() {
var color = this.getAttribute('data-color');
@@ -290,6 +347,15 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Color preset clicks for Edit Modal
document.querySelectorAll('#presetColorsEdit button').forEach(function(btn) {
btn.addEventListener('click', function() {
var color = this.getAttribute('data-color');
var input = document.getElementById('edit_color');
if (input) input.value = color;
});
});
// Edit modal bindings
var modalEditEl = document.getElementById('modalEditType');
var modalEdit = modalEditEl ? new bootstrap.Modal(modalEditEl) : null;
@@ -317,6 +383,22 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
});
// Delete modal bindings
var deleteModalEl = document.getElementById('deleteMilestoneTypeModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var deleteNameEl = document.getElementById('deleteMilestoneTypeName');
var deleteFormEl = document.getElementById('deleteMilestoneTypeForm');
document.querySelectorAll('.btn-delete-type').forEach(function(btn) {
btn.addEventListener('click', function() {
var name = this.getAttribute('data-name');
var actionUrl = this.getAttribute('data-action-url');
if (deleteNameEl) deleteNameEl.textContent = name;
if (deleteFormEl) deleteFormEl.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
+65
View File
@@ -136,6 +136,14 @@
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
</button>
</form>
<button type="button" class="btn btn-outline-danger btn-delete-user"
data-user-id="{{ u.id }}"
data-user-name="{{ u.name }}"
data-user-email="{{ u.email }}"
data-action-url="{{ url_for('admin.user_delete', id=u.id) }}"
title="{% trans %}Eliminar Usuario{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
</td>
@@ -179,4 +187,61 @@
{% endif %}
</div>
</div>
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-person-x-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Usuario{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar permanentemente al siguiente usuario?{% endtrans %}</p>
<div class="p-2 rounded bg-body-tertiary mb-2">
<div class="fw-bold text-primary" id="deleteUserName"></div>
<div class="small text-muted font-monospace" id="deleteUserEmail"></div>
</div>
<p class="text-muted small mb-0"><i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% trans %}Esta acción desvinculará sus comisiones y reservas asociadas. No se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteUserForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Usuario{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var deleteModalEl = document.getElementById('deleteUserModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var nameEl = document.getElementById('deleteUserName');
var emailEl = document.getElementById('deleteUserEmail');
var formEl = document.getElementById('deleteUserForm');
document.querySelectorAll('.btn-delete-user').forEach(function(btn) {
btn.addEventListener('click', function() {
var name = this.getAttribute('data-user-name');
var email = this.getAttribute('data-user-email');
var actionUrl = this.getAttribute('data-action-url');
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email;
if (formEl) formEl.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
+4
View File
@@ -90,6 +90,9 @@
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}">
<i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}
</a></li>
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}">
@@ -106,6 +109,7 @@
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-warning"></i>{% trans %}Tipificaciones{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
File diff suppressed because it is too large Load Diff