2 Commits
12 changed files with 2292 additions and 125 deletions
+2
View File
@@ -2,6 +2,7 @@ from .user import User
from .building import Building
from .classroom import Classroom, ClassroomResource
from .career import Career
from .academic_term import AcademicTerm
from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
@@ -14,6 +15,7 @@ __all__ = [
'Classroom',
'ClassroomResource',
'Career',
'AcademicTerm',
'Subject',
'Commission',
'Reservation',
+34
View File
@@ -0,0 +1,34 @@
from app import db
from datetime import datetime
class AcademicTerm(db.Model):
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
__tablename__ = 'academic_terms'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'name': self.name,
'year': self.year,
'period_type': self.period_type,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'is_current': self.is_current,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<AcademicTerm {self.code} - {self.name}>'
+591
View File
@@ -526,3 +526,594 @@ def api_milestone_types():
return jsonify([t.to_dict() for t in types])
# ---------------------------------------------------------
# 1. GESTIÓN DE CARRERAS (CAREERS)
# ---------------------------------------------------------
@admin_bp.route('/careers')
@login_required
def careers_list():
"""List and manage academic careers"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.career import Career
from app.models.subject import Subject
search = request.args.get('search', '').strip()
query = Career.query
if search:
query = query.filter(
or_(
Career.name.ilike(f'%{search}%'),
Career.code.ilike(f'%{search}%'),
Career.description.ilike(f'%{search}%')
)
)
careers = query.order_by(Career.name.asc()).all()
# Calculate stats per career
stats = {}
for c in careers:
subj_count = len(c.subjects) if c.subjects else 0
comm_count = sum(len(s.commissions) for s in c.subjects) if c.subjects else 0
stats[c.id] = {
'subjects_count': subj_count,
'commissions_count': comm_count
}
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/careers/list.html',
careers=careers,
stats=stats,
search=search,
can_edit=can_edit
)
@admin_bp.route('/careers/add', methods=['POST'])
@login_required
def career_add():
"""Create a new academic career"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre de la carrera es obligatorio.'), 'error')
return redirect(url_for('admin.careers_list'))
existing = Career.query.filter(Career.name.ilike(name)).first()
if existing:
flash(_('Ya existe una carrera registrada con el nombre "%(name)s".', name=name), 'error')
return redirect(url_for('admin.careers_list'))
career = Career(name=name, code=code or None, description=description or None)
db.session.add(career)
db.session.commit()
flash(_('Carrera "%(name)s" creada exitosamente.', name=career.name), 'success')
return redirect(url_for('admin.careers_list'))
@admin_bp.route('/careers/<int:id>/edit', methods=['POST'])
@login_required
def career_edit(id):
"""Edit an academic career"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
career = Career.query.get_or_404(id)
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre de la carrera es obligatorio.'), 'error')
return redirect(url_for('admin.careers_list'))
existing = Career.query.filter(Career.name.ilike(name), Career.id != id).first()
if existing:
flash(_('Ya existe otra carrera registrada con el nombre "%(name)s".', name=name), 'error')
return redirect(url_for('admin.careers_list'))
career.name = name
career.code = code or None
career.description = description or None
db.session.commit()
flash(_('Carrera "%(name)s" actualizada correctamente.', name=career.name), 'success')
return redirect(url_for('admin.careers_list'))
@admin_bp.route('/careers/<int:id>/delete', methods=['POST'])
@login_required
def career_delete(id):
"""Delete an academic career if it has no associated subjects"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
career = Career.query.get_or_404(id)
if career.subjects and len(career.subjects) > 0:
flash(_('No se puede eliminar la carrera "%(name)s" porque tiene %(count)d asignaturas vinculadas.', name=career.name, count=len(career.subjects)), 'error')
return redirect(url_for('admin.careers_list'))
name = career.name
db.session.delete(career)
db.session.commit()
flash(_('Carrera "%(name)s" eliminada permanentemente.', name=name), 'success')
return redirect(url_for('admin.careers_list'))
# ---------------------------------------------------------
# 2. GESTIÓN DE ASIGNATURAS (SUBJECTS)
# ---------------------------------------------------------
@admin_bp.route('/subjects')
@login_required
def subjects_list():
"""List and manage subjects / materias"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
search = request.args.get('search', '').strip()
career_filter = request.args.get('career_id', type=int)
active_filter = request.args.get('active', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 20
query = Subject.query
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Subject.department.ilike(f'%{search}%')
)
)
if career_filter:
query = query.filter(Subject.career_id == career_filter)
if active_filter in ['true', 'false']:
query = query.filter(Subject.is_active == (active_filter == 'true'))
pagination = query.order_by(Subject.code.asc(), Subject.name.asc()).paginate(page=page, per_page=per_page, error_out=False)
careers = Career.query.order_by(Career.name.asc()).all()
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/subjects/list.html',
subjects=pagination.items,
pagination=pagination,
careers=careers,
search=search,
career_filter=career_filter,
active_filter=active_filter,
can_edit=can_edit
)
@admin_bp.route('/subjects/add', methods=['POST'])
@login_required
def subject_add():
"""Create a new subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
code = request.form.get('code', '').strip().upper()
name = request.form.get('name', '').strip()
department = request.form.get('department', '').strip()
career_id = request.form.get('career_id', type=int)
credits = request.form.get('credits', 4, type=int)
description = request.form.get('description', '').strip()
if not code or not name:
flash(_('El código y el nombre de la asignatura son obligatorios.'), 'error')
return redirect(url_for('admin.subjects_list'))
existing = Subject.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe una asignatura con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.subjects_list'))
career_name = None
if career_id:
c = Career.query.get(career_id)
if c:
career_name = c.name
subject = Subject(
code=code,
name=name,
department=department or career_name or 'General',
career_id=career_id if career_id else None,
credits=credits or 4,
description=description or None,
is_active=True
)
db.session.add(subject)
db.session.commit()
flash(_('Asignatura "%(name)s" (%(code)s) creada exitosamente.', name=subject.name, code=subject.code), 'success')
return redirect(url_for('admin.subjects_list'))
@admin_bp.route('/subjects/<int:id>/edit', methods=['POST'])
@login_required
def subject_edit(id):
"""Edit an existing subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
subject = Subject.query.get_or_404(id)
code = request.form.get('code', '').strip().upper()
name = request.form.get('name', '').strip()
department = request.form.get('department', '').strip()
career_id = request.form.get('career_id', type=int)
credits = request.form.get('credits', 4, type=int)
description = request.form.get('description', '').strip()
is_active = request.form.get('is_active') == 'on'
if not code or not name:
flash(_('El código y el nombre de la asignatura son obligatorios.'), 'error')
return redirect(url_for('admin.subjects_list'))
existing = Subject.query.filter(Subject.code == code, Subject.id != id).first()
if existing:
flash(_('Ya existe otra asignatura con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.subjects_list'))
career_name = None
if career_id:
c = Career.query.get(career_id)
if c:
career_name = c.name
subject.code = code
subject.name = name
subject.department = department or career_name or subject.department
subject.career_id = career_id if career_id else None
subject.credits = credits or 4
subject.description = description or None
subject.is_active = is_active
db.session.commit()
flash(_('Asignatura "%(name)s" actualizada exitosamente.', name=subject.name), 'success')
return redirect(url_for('admin.subjects_list'))
@admin_bp.route('/subjects/<int:id>/toggle', methods=['POST'])
@login_required
def subject_toggle(id):
"""Toggle active status of a subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
subject = Subject.query.get_or_404(id)
subject.is_active = not subject.is_active
db.session.commit()
status_str = _('activada') if subject.is_active else _('desactivada')
flash(_('Asignatura "%(name)s" %(status)s.', name=subject.name, status=status_str), 'success')
return redirect(url_for('admin.subjects_list'))
# ---------------------------------------------------------
# 3. GESTIÓN DE CICLO LECTIVO (ACADEMIC TERMS)
# ---------------------------------------------------------
@admin_bp.route('/academic-terms')
@login_required
def academic_terms_list():
"""List and manage academic cycles / terms"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.academic_term import AcademicTerm
from app.models.subject import Commission
from app.models.reservation import Reservation
terms = AcademicTerm.query.order_by(AcademicTerm.year.desc(), AcademicTerm.code.desc()).all()
# Calculate associated commissions and reservations
stats = {}
for t in terms:
comm_count = Commission.query.filter(
or_(Commission.semester == t.code, db.and_(Commission.year == t.year, Commission.semester.ilike(f'%{t.code}%')))
).count()
stats[t.id] = {
'commissions_count': comm_count
}
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/academic_terms/list.html',
terms=terms,
stats=stats,
can_edit=can_edit
)
@admin_bp.route('/academic-terms/add', methods=['POST'])
@login_required
def academic_term_add():
"""Create a new academic cycle"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
from datetime import datetime
code = request.form.get('code', '').strip()
name = request.form.get('name', '').strip()
year = request.form.get('year', type=int)
period_type = request.form.get('period_type', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
is_current = request.form.get('is_current') == 'on'
if not code or not name or not year:
flash(_('El código, nombre y año del ciclo lectivo son obligatorios.'), 'error')
return redirect(url_for('admin.academic_terms_list'))
existing = AcademicTerm.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe un ciclo lectivo con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.academic_terms_list'))
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else None
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
if is_current:
# Unset previous current terms
AcademicTerm.query.update({'is_current': False})
term = AcademicTerm(
code=code,
name=name,
year=year,
period_type=period_type or 'Segundo Cuatrimestre',
start_date=start_date,
end_date=end_date,
is_current=is_current,
is_active=True
)
db.session.add(term)
db.session.commit()
flash(_('Ciclo lectivo "%(name)s" creado exitosamente.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
@admin_bp.route('/academic-terms/<int:id>/edit', methods=['POST'])
@login_required
def academic_term_edit(id):
"""Edit an academic cycle"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
from datetime import datetime
term = AcademicTerm.query.get_or_404(id)
code = request.form.get('code', '').strip()
name = request.form.get('name', '').strip()
year = request.form.get('year', type=int)
period_type = request.form.get('period_type', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
is_current = request.form.get('is_current') == 'on'
is_active = request.form.get('is_active') == 'on'
if not code or not name or not year:
flash(_('El código, nombre y año del ciclo lectivo son obligatorios.'), 'error')
return redirect(url_for('admin.academic_terms_list'))
existing = AcademicTerm.query.filter(AcademicTerm.code == code, AcademicTerm.id != id).first()
if existing:
flash(_('Ya existe otro ciclo lectivo con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.academic_terms_list'))
if is_current and not term.is_current:
AcademicTerm.query.update({'is_current': False})
term.code = code
term.name = name
term.year = year
term.period_type = period_type or term.period_type
term.start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else None
term.end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
term.is_current = is_current
term.is_active = is_active
db.session.commit()
flash(_('Ciclo lectivo "%(name)s" actualizado correctamente.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
@admin_bp.route('/academic-terms/<int:id>/set-current', methods=['POST'])
@login_required
def academic_term_set_current(id):
"""Set an academic cycle as the currently active one"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
term = AcademicTerm.query.get_or_404(id)
AcademicTerm.query.update({'is_current': False})
term.is_current = True
term.is_active = True
db.session.commit()
flash(_('El ciclo lectivo "%(name)s" fue establecido como Ciclo Activo del sistema.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
# ---------------------------------------------------------
# 4. GESTIÓN DE OCUPACIÓN Y MÉTRICAS (OCCUPANCY DASHBOARD)
# ---------------------------------------------------------
@admin_bp.route('/occupancy-metrics')
@login_required
def occupancy_metrics():
"""Consolidated institutional dashboard of classroom occupancy, shifts, and space usage"""
if not (current_user.has_permission('metrics', 'read') or current_user.can_manage()):
abort(403)
from app.models.classroom import Classroom
from app.models.building import Building
from app.models.reservation import Reservation, ReservationStatus
import calendar
from datetime import datetime, date, timedelta
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
# Buildings & Classrooms
buildings = Building.query.filter_by(is_active=True).all()
vb = next((b for b in buildings if b.code == 'VIRTUAL' or 'virtual' in b.name.lower()), None)
vb_id = vb.id if vb else 0
physical_rooms = Classroom.query.filter(
Classroom.is_active == True,
Classroom.building_id != vb_id if vb_id else True,
~Classroom.code.ilike('%VIRTUAL%')
).all()
vc = Classroom.query.filter(
Classroom.building_id == vb_id if vb_id else Classroom.code.ilike('%VIRTUAL%')
).first()
# Reservations in month
month_res = Reservation.query.filter(
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all()
# Calculate physical stats
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
room_metrics = []
for c in physical_rooms:
c_res = [r for r in month_res if r.classroom_id == c.id]
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
room_metrics.append({
'classroom': c,
'day_pct': day_pct,
'day_count': len(day_res),
'week_pct': week_pct,
'week_count': len(week_res),
'month_pct': month_pct,
'month_count': len(c_res)
})
room_metrics.sort(key=lambda x: x['month_pct'], reverse=True)
top_rooms = room_metrics[:5]
low_rooms = list(reversed(room_metrics))[:5]
# Global averages
avg_day_pct = int(round(sum(rm['day_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
avg_week_pct = int(round(sum(rm['week_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
avg_month_pct = int(round(sum(rm['month_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
# Virtual classes
v_res = [r for r in month_res if vc and r.classroom_id == vc.id]
v_today = sum(1 for r in v_res if today_start <= r.start_time <= today_end)
v_week = sum(1 for r in v_res if week_start <= r.start_time <= week_end)
v_month = len(v_res)
# Shifts distribution
shift_counts = {'Mañana': 0, 'Tarde': 0, 'Vespertino': 0, 'Noche': 0, 'Otro': 0}
for r in month_res:
s = (r.shift or '').strip().capitalize()
if s in shift_counts:
shift_counts[s] += 1
elif 'Mañana' in s:
shift_counts['Mañana'] += 1
elif 'Tarde' in s:
shift_counts['Tarde'] += 1
elif 'Noche' in s:
shift_counts['Noche'] += 1
else:
shift_counts['Otro'] += 1
# Floors occupancy
floor_stats = {}
for rm in room_metrics:
flr = rm['classroom'].floor_display
if flr not in floor_stats:
floor_stats[flr] = {'rooms': 0, 'month_pct_sum': 0}
floor_stats[flr]['rooms'] += 1
floor_stats[flr]['month_pct_sum'] += rm['month_pct']
floor_summary = [
{'floor': f, 'avg_pct': int(round(data['month_pct_sum'] / data['rooms'])), 'count': data['rooms']}
for f, data in floor_stats.items()
]
floor_summary.sort(key=lambda x: x['avg_pct'], reverse=True)
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
return render_template(
'admin/metrics/occupancy.html',
physical_rooms_count=len(physical_rooms),
avg_day_pct=avg_day_pct,
avg_week_pct=avg_week_pct,
avg_month_pct=avg_month_pct,
top_rooms=top_rooms,
low_rooms=low_rooms,
v_today=v_today,
v_week=v_week,
v_month=v_month,
shift_counts=shift_counts,
floor_summary=floor_summary,
total_month_classes=len(month_res),
date_filters=date_filters
)
+160 -58
View File
@@ -89,49 +89,34 @@ def add_classroom():
@classrooms_bp.route('/list')
@login_required
def list_classrooms():
"""List all classrooms with Building and Floor filters"""
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
try:
from app.models.building import Building
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str)
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
query = Classroom.query
if active_only:
query = query.filter_by(is_active=True)
if building_filter and building_filter.isdigit():
query = query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if search:
query = query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
classrooms = query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=20, error_out=False
)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()]
# Fechas y cálculo de métricas de ocupación (Día, Semana, Mes)
from app.models.reservation import Reservation, ReservationStatus
from app.models.subject import Commission, Subject
import calendar
from datetime import date, timedelta
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str).strip()
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
view_mode = request.args.get('view_mode', '').strip().lower()
# Identificar edificio virtual y aula virtual base
virtual_building = Building.query.filter(
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
).first()
virtual_bldg_id = virtual_building.id if virtual_building else None
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
if building_filter and str(building_filter) == str(virtual_bldg_id):
view_mode = 'virtual'
elif not view_mode:
view_mode = 'physical'
# Fechas operativas (Día, Semana, Mes)
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
@@ -143,8 +128,62 @@ def list_classrooms():
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
# Consultar en lote las reservas del mes para las aulas visibles
from app.models.reservation import Reservation, ReservationStatus
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
# --- AULAS FÍSICAS (Presenciales) ---
physical_query = Classroom.query
if virtual_bldg_id:
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
else:
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
if active_only:
physical_query = physical_query.filter_by(is_active=True)
physical_count = physical_query.count()
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
physical_query = physical_query.filter(Classroom.floor == flr)
if search and view_mode == 'physical':
physical_query = physical_query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=20, error_out=False
)
# Edificios y Pisos estrictamente físicos para el filtro
buildings = Building.query.filter_by(is_active=True)
if virtual_bldg_id:
buildings = buildings.filter(Building.id != virtual_bldg_id)
buildings = buildings.order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
Classroom.is_active == True,
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
Classroom.floor.isnot(None)
).distinct().order_by(Classroom.floor.asc()).all()]
# Métricas de ocupación física
visible_ids = [c.id for c in classrooms.items]
month_reservations = Reservation.query.filter(
Reservation.classroom_id.in_(visible_ids),
@@ -153,10 +192,6 @@ def list_classrooms():
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all() if visible_ids else []
# Horas base operativas académicas:
# Día: 15 hs (07:00 a 22:00 = 900 min)
# Semana: 6 días x 15 hs = 5400 min
# Mes: ~24 días hábiles x 15 hs = 21600 min
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
@@ -164,20 +199,16 @@ def list_classrooms():
occupancy_metrics = {}
for c in classrooms.items:
c_res = [r for r in month_reservations if r.classroom_id == c.id]
# Día
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100))) if not c.is_virtual else len(day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
# Semana
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100))) if not c.is_virtual else len(week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
# Mes
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100))) if not c.is_virtual else len(c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
occupancy_metrics[c.id] = {
'day_pct': day_pct,
@@ -188,12 +219,77 @@ def list_classrooms():
'month_count': len(c_res)
}
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
vc = Classroom.query.filter(
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
).first()
vc_id = vc.id if vc else 1
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
# Agrupar las salas virtuales por comisión
comm_dict = {}
for r in all_vres:
c = r.commission
cid = c.id if c else 0
if cid not in comm_dict:
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
comm_dict[cid] = {
'commission_id': cid,
'commission_code': c.code if c else 'General',
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
'subject_code': c.subject.code if (c and c.subject) else '',
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
'max_students': c.max_students if c else (r.expected_attendees or 0),
'virtual_link': vlink,
'reservations': []
}
comm_dict[cid]['reservations'].append(r)
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
virtual_rooms = []
for cid, data in comm_dict.items():
res_list = data['reservations']
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
shift = res_list[0].shift if res_list else 'A Demanda'
if search and view_mode == 'virtual':
s_low = search.lower()
matches = (
s_low in data['subject_name'].lower() or
s_low in data['commission_code'].lower() or
s_low in data['subject_code'].lower() or
s_low in data['career_name'].lower()
)
if not matches:
continue
virtual_rooms.append({
'commission_id': data['commission_id'],
'commission_code': data['commission_code'],
'subject_name': data['subject_name'],
'subject_code': data['subject_code'],
'career_name': data['career_name'],
'max_students': data['max_students'],
'virtual_link': data['virtual_link'],
'shift': shift,
'day_count': day_cnt,
'week_count': week_cnt,
'month_count': month_cnt,
'total_count': len(res_list)
})
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
global_virtual_metrics = {
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
'total_count': len(all_vres),
'commissions_count': len(comm_dict)
}
return render_template('classrooms/list.html',
@@ -204,6 +300,12 @@ def list_classrooms():
floor_filter=floor_filter,
active_only=active_only,
search=search,
view_mode=view_mode,
physical_count=physical_count,
virtual_count=len(comm_dict),
virtual_rooms=virtual_rooms,
virtual_classroom_id=vc_id,
global_virtual_metrics=global_virtual_metrics,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms')
@@ -0,0 +1,252 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Ciclo Lectivo{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<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>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-calendar-range text-primary me-2"></i>{% trans %}Gestión de Ciclo Lectivo y Períodos Académicos{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Administración de cuatrimestres, años académicos, fechas de inicio/fin y selección del ciclo lectivo activo.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddTerm">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nuevo Ciclo Lectivo{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Cursadas{% endtrans %}
</a>
</div>
</div>
<!-- Academic Terms Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Nombre del Ciclo Lectivo{% endtrans %}</th>
<th>{% trans %}Año{% endtrans %}</th>
<th>{% trans %}Período / Modalidad{% endtrans %}</th>
<th>{% trans %}Fechas del Período{% endtrans %}</th>
<th>{% trans %}Comisiones{% endtrans %}</th>
<th>{% trans %}Estado Institucional{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for term in terms %}
{% set t_stat = stats.get(term.id, {'commissions_count': 0}) %}
<tr class="{% if term.is_current %}table-primary-subtle{% endif %}">
<td>
<span class="badge font-monospace bg-primary-subtle text-primary border fs-6">{{ term.code }}</span>
</td>
<td>
<div class="fw-bold text-body">{{ term.name }}</div>
<small class="text-muted">ID: #{{ term.id }}</small>
</td>
<td>
<span class="fw-semibold text-body">{{ term.year }}</span>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border">
{{ term.period_type }}
</span>
</td>
<td>
{% if term.start_date and term.end_date %}
<small class="text-body font-monospace">
<i class="bi bi-calendar3 me-1 text-primary"></i>{{ term.start_date.strftime('%d/%m/%Y') }} - {{ term.end_date.strftime('%d/%m/%Y') }}
</small>
{% else %}
<small class="text-muted">{% trans %}Sin fechas fijadas{% endtrans %}</small>
{% endif %}
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-diagram-3 me-1"></i>{{ t_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
</span>
</td>
<td>
{% if term.is_current %}
<span class="badge bg-success text-white px-2 py-1 shadow-sm">
<i class="bi bi-check-circle-fill me-1"></i>{% trans %}Ciclo Activo Actual{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border">
{% if term.is_active %}{% trans %}Habilitado{% endtrans %}{% else %}{% trans %}Archivado{% endtrans %}{% endif %}
</span>
{% endif %}
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if not term.is_current %}
<form method="POST" action="{{ url_for('admin.academic_term_set_current', id=term.id) }}" class="d-inline"
onsubmit="return confirm('{% trans %}¿Desea establecer este ciclo lectivo como el Activo para todo el sistema?{% endtrans %}');">
<button type="submit" class="btn btn-outline-success py-0 px-2" title="{% trans %}Establecer como Ciclo Activo{% endtrans %}">
<i class="bi bi-check2-circle me-1"></i>{% trans %}Activar{% endtrans %}
</button>
</form>
{% endif %}
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditTerm{{ term.id }}"
title="{% trans %}Editar Ciclo Lectivo{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
</div>
<!-- Modal Editar Ciclo Lectivo -->
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.academic_term_edit', id=term.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Ciclo Lectivo{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
<select name="period_type" class="form-select form-select-sm">
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date.strftime('%Y-%m-%d') if term.start_date else '' }}">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date.strftime('%Y-%m-%d') if term.end_date else '' }}">
</div>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
{% trans %}Establecer como Ciclo Lectivo Activo Actual{% endtrans %}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
<label class="form-check-label small" for="isAct{{ term.id }}">{% trans %}Habilitado en el Sistema{% endtrans %}</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
{% trans %}No se encontraron ciclos lectivos registrados.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Nuevo Ciclo Lectivo -->
{% if can_edit %}
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.academic_term_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}Nuevo Ciclo Lectivo / Período Académico{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. 2027-1" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
<input type="number" name="year" class="form-control form-control-sm" value="2026" min="2020" max="2050" required>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Ciclo Lectivo 2027 - 1º Cuatrimestre" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
<select name="period_type" class="form-select form-select-sm">
<option value="Primer Cuatrimestre">Primer Cuatrimestre</option>
<option value="Segundo Cuatrimestre" selected>Segundo Cuatrimestre</option>
<option value="Anual">Anual</option>
<option value="Curso de Verano">Curso de Verano</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
<input type="date" name="start_date" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
<input type="date" name="end_date" class="form-control form-control-sm">
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_current" id="addIsCurr">
<label class="form-check-label small fw-semibold text-primary" for="addIsCurr">
{% trans %}Establecer de inmediato como Ciclo Lectivo Activo Actual{% endtrans %}
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Ciclo Lectivo{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+208
View File
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Carreras{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<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>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Gestión de Carreras y Titulaciones{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Administración de programas académicos, tecnicaturas, licenciaturas y planes de estudio.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddCareer">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Carrera{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-book me-1"></i>{% trans %}Ver Asignaturas{% endtrans %}
</a>
</div>
</div>
<!-- Search & Filter Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<div class="col-md-6">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar carrera por nombre, código o descripción...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-6 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('admin.careers_list') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Careers Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Carrera / Titulación{% endtrans %}</th>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Asignaturas{% endtrans %}</th>
<th>{% trans %}Comisiones Activas{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for career in careers %}
{% set c_stat = stats.get(career.id, {'subjects_count': 0, 'commissions_count': 0}) %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="rounded-circle p-2 bg-primary-subtle text-primary me-3">
<i class="bi bi-mortarboard-fill"></i>
</div>
<div>
<span class="fw-bold text-body">{{ career.name }}</span>
</div>
</div>
</td>
<td>
{% if career.code %}
<span class="badge font-monospace bg-secondary-subtle text-secondary border">{{ career.code }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.subjects_list', career_id=career.id) }}" class="badge bg-info-subtle text-info border text-decoration-none">
<i class="bi bi-book me-1"></i>{{ c_stat.subjects_count }} {% trans %}asignaturas{% endtrans %}
</a>
</td>
<td>
<span class="badge bg-primary-subtle text-primary border">
<i class="bi bi-diagram-3 me-1"></i>{{ c_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
</span>
</td>
<td>
<span class="small text-muted text-truncate d-inline-block" style="max-width: 250px;">
{{ career.description or '-' }}
</span>
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditCareer{{ career.id }}"
title="{% trans %}Editar Carrera{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
{% if c_stat.subjects_count == 0 %}
<form method="POST" action="{{ url_for('admin.career_delete', id=career.id) }}" class="d-inline"
onsubmit="return confirm('{% trans %}¿Confirma que desea eliminar esta carrera permanentemente?{% endtrans %}');">
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="{% trans %}Eliminar{% endtrans %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
<!-- Modal Editar Carrera -->
<div class="modal fade text-start" id="modalEditCareer{{ career.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.career_edit', id=career.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Carrera{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
<input type="text" name="name" class="form-control" value="{{ career.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
<input type="text" name="code" class="form-control" value="{{ career.code or '' }}" placeholder="ej. LIC-SIS, PUEP">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control" rows="3">{{ career.description or '' }}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-mortarboard fs-1 d-block mb-2"></i>
{% trans %}No se encontraron carreras registradas.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Nueva Carrera -->
{% if can_edit %}
<div class="modal fade" id="modalAddCareer" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.career_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Nueva Carrera / Titulación{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
<input type="text" name="name" class="form-control" placeholder="ej. Licenciatura en Tecnologías Digitales" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
<input type="text" name="code" class="form-control" placeholder="ej. TEC-DIG">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control" rows="3" placeholder="Objetivos y alcance de la titulación..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Carrera{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+332
View File
@@ -0,0 +1,332 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Ocupación y Métricas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
.kpi-card {
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.07);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
.kpi-icon-circle {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.metric-bar-container {
height: 10px;
border-radius: 6px;
background-color: var(--bs-tertiary-bg);
overflow: hidden;
}
.metric-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.6s ease;
}
</style>
{% endblock %}
{% block content %}
<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>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-graph-up-arrow text-primary me-2"></i>{% trans %}Gestión de Ocupación y Métricas de Espacios{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Analítica institucional de aprovechamiento de aulas físicas, demanda en Campus Virtual y distribución horaria.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-outline-primary">
<i class="bi bi-buildings me-1"></i>{% trans %}Aulas Físicas{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-outline-info">
<i class="bi bi-globe2 me-1"></i>{% trans %}Campus Virtual (∞){% endtrans %}
</a>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
</div>
</div>
<!-- 1. Fila de KPIs Globales de Ocupación -->
<div class="row g-3 mb-4">
<!-- Ocupación Hoy -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Tasa de Ocupación Hoy{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-primary mt-1">{{ avg_day_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-primary-subtle text-primary">
<i class="bi bi-clock-history"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-primary" style="width: {{ avg_day_pct }}%;"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Promedio sobre 15 hs operativas ({{ physical_rooms_count }} aulas presenciales){% endtrans %}
</small>
</div>
</div>
<!-- Ocupación Semana -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación Semanal{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-info mt-1">{{ avg_week_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-info-subtle text-info">
<i class="bi bi-calendar-week"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-info" style="width: {{ avg_week_pct }}%;"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Semana en curso (Lunes a Sábado){% endtrans %}
</small>
</div>
</div>
<!-- Ocupación Mensual -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación del Mes{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-success mt-1">{{ avg_month_pct }}%</h2>
</div>
<div class="kpi-icon-circle bg-success-subtle text-success">
<i class="bi bi-calendar3"></i>
</div>
</div>
<div class="metric-bar-container mt-3">
<div class="metric-bar-fill bg-success" style="width: {{ avg_month_pct }}%;"></div>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Total mes calendario activo{% endtrans %}
</small>
</div>
</div>
<!-- Clases Totales Dictadas -->
<div class="col-xl-3 col-sm-6">
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Total Clases en el Mes{% endtrans %}</span>
<h2 class="h3 fw-bold mb-0 text-warning mt-1">{{ total_month_classes }}</h2>
</div>
<div class="kpi-icon-circle bg-warning-subtle text-warning">
<i class="bi bi-mortarboard"></i>
</div>
</div>
<div class="d-flex gap-2 mt-3 small font-monospace">
<span class="badge bg-primary-subtle text-primary border">Físicas: {{ total_month_classes - v_month }}</span>
<span class="badge bg-info-subtle text-info border">Virtuales: {{ v_month }}</span>
</div>
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
{% trans %}Reservas y sesiones registradas en el período{% endtrans %}
</small>
</div>
</div>
</div>
<!-- 2. Detalle y Distribución Espacial -->
<div class="row g-4 mb-4">
<!-- Ocupación por Piso / Nivel -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title h6 fw-bold mb-0">
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Tasa de Ocupación por Piso / Nivel{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary border">Presencial</span>
</div>
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for fs in floor_summary %}
<div>
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-semibold text-body small">
{{ fs.floor }} ({{ fs.count }} {% trans %}aulas{% endtrans %})
</span>
<span class="fw-bold font-monospace small {% if fs.avg_pct > 50 %}text-danger{% elif fs.avg_pct > 25 %}text-warning{% else %}text-success{% endif %}">
{{ fs.avg_pct }}%
</span>
</div>
<div class="metric-bar-container">
<div class="metric-bar-fill {% if fs.avg_pct > 50 %}bg-danger{% elif fs.avg_pct > 25 %}bg-warning{% else %}bg-success{% endif %}"
style="width: {{ fs.avg_pct }}%;"></div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Distribución por Turno -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title h6 fw-bold mb-0">
<i class="bi bi-brightness-high text-warning me-2"></i>{% trans %}Distribución de Clases por Turno{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary border">Mes Completo</span>
</div>
<div class="card-body">
<div class="row g-2 text-center mb-3">
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Mañana{% endtrans %}</div>
<div class="h5 fw-bold text-primary mb-0">{{ shift_counts.Mañana }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Tarde{% endtrans %}</div>
<div class="h5 fw-bold text-warning mb-0">{{ shift_counts.Tarde }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Vespertino{% endtrans %}</div>
<div class="h5 fw-bold text-info mb-0">{{ shift_counts.Vespertino }}</div>
</div>
</div>
<div class="col-3">
<div class="p-2 rounded bg-body-tertiary border">
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Noche{% endtrans %}</div>
<div class="h5 fw-bold text-danger mb-0">{{ shift_counts.Noche }}</div>
</div>
</div>
</div>
<!-- Campus Virtual Box -->
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="fw-bold text-primary mb-1"><i class="bi bi-globe2 me-2"></i>{% trans %}Campus Virtual (A Demanda · ∞){% endtrans %}</h6>
<p class="small text-muted mb-0">{% trans %}Salas simultáneas sin límite físico de cupo ni colisión.{% endtrans %}</p>
</div>
<div class="text-end">
<div class="badge bg-primary text-white fs-6">{{ v_month }} clases mes</div>
<small class="d-block text-muted mt-1">{{ v_today }} hoy · {{ v_week }} semana</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 3. Top Aulas de Mayor Demanda vs Menor Demanda -->
<div class="row g-4">
<!-- Mayor Demanda -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="card-title h6 fw-bold mb-0 text-success">
<i class="bi bi-arrow-up-circle-fill me-2"></i>{% trans %}Top Aulas con Mayor Demanda y Ocupación{% endtrans %}
</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Piso{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for rm in top_rooms %}
<tr>
<td class="fw-bold text-primary">{{ rm.classroom.code }}</td>
<td>{{ rm.classroom.floor_display }}</td>
<td>{{ rm.classroom.capacity }} asientos</td>
<td>
<span class="badge bg-success-subtle text-success border border-success-subtle font-monospace">
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
</span>
</td>
<td class="text-end">
<a href="{{ url_for('schedule.list_reservations', classroom_id=rm.classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.72rem;">
{% trans %}Ver Reservas{% endtrans %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Menor Demanda / Oportunidades -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="card-title h6 fw-bold mb-0 text-secondary">
<i class="bi bi-arrow-down-circle-fill me-2"></i>{% trans %}Aulas con Mayor Disponibilidad (Menor Uso){% endtrans %}
</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Piso{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for rm in low_rooms %}
<tr>
<td class="fw-bold text-body">{{ rm.classroom.code }}</td>
<td>{{ rm.classroom.floor_display }}</td>
<td>{{ rm.classroom.capacity }} asientos</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
</span>
</td>
<td class="text-end">
<a href="{{ url_for('schedule.add_reservation') }}?classroom_id={{ rm.classroom.id }}"
class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
{% trans %}Reservar{% endtrans %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+306
View File
@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Asignaturas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<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>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-book text-primary me-2"></i>{% trans %}Gestión de Asignaturas y Materias{% endtrans %}
</h1>
<p class="text-muted mb-0 small">
{% trans %}Catálogo institucional de asignaturas, códigos, créditos académicos y carreras vinculadas.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddSubject">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Asignatura{% endtrans %}
</button>
{% endif %}
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Comisiones{% endtrans %}
</a>
</div>
</div>
<!-- Filters Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<!-- Buscador -->
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar por código, nombre o departamento...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<!-- Filtro Carrera -->
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-mortarboard text-primary"></i></span>
<select name="career_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}" {% if career_filter == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filtro Estado -->
<div class="col-md-2">
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>{% trans %}Solo activas{% endtrans %}</option>
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>{% trans %}Solo inactivas{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Subjects Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Código{% endtrans %}</th>
<th>{% trans %}Nombre de la Asignatura{% endtrans %}</th>
<th>{% trans %}Carrera{% endtrans %}</th>
<th>{% trans %}Departamento{% endtrans %}</th>
<th>{% trans %}Créditos{% endtrans %}</th>
<th>{% trans %}Comisiones{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
{% if can_edit %}
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for s in subjects %}
<tr>
<td>
<span class="badge font-monospace bg-primary-subtle text-primary border">{{ s.code }}</span>
</td>
<td>
<span class="fw-bold text-body">{{ s.name }}</span>
{% if s.description %}
<small class="text-muted d-block text-truncate" style="max-width: 300px;">{{ s.description }}</small>
{% endif %}
</td>
<td>
{% if s.career_obj %}
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-mortarboard me-1"></i>{{ s.career_obj.name }}
</span>
{% else %}
<span class="text-muted small">General</span>
{% endif %}
</td>
<td>
<span class="small text-muted">{{ s.department or '-' }}</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ s.credits }} cr.</span>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-diagram-3 me-1"></i>{{ s.commissions|length if s.commissions else 0 }}
</span>
</td>
<td>
<span class="badge rounded-pill {% if s.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{% if s.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
</span>
</td>
{% if can_edit %}
<td class="text-end">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
title="{% trans %}Editar Asignatura{% endtrans %}">
<i class="bi bi-pencil"></i>
</button>
<form method="POST" action="{{ url_for('admin.subject_toggle', id=s.id) }}" class="d-inline">
<button type="submit" class="btn btn-outline-warning py-0 px-2"
title="{% if s.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
</button>
</form>
</div>
<!-- Modal Editar Asignatura -->
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.subject_edit', id=s.id) }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Asignatura{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
<select name="career_id" class="form-select form-select-sm">
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción / Objetivos{% endtrans %}</label>
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
<label class="form-check-label small" for="activeSubj{{ s.id }}">{% trans %}Asignatura Activa{% endtrans %}</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-book fs-1 d-block mb-2"></i>
{% trans %}No se encontraron asignaturas con los criterios indicados.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Paginación -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-2">
<nav aria-label="Subjects pagination">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.prev_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Anterior{% endtrans %}</a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.subjects_list', page=p, search=search, career_id=career_filter, active=active_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.next_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Siguiente{% endtrans %}</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
<!-- Modal Nueva Asignatura -->
{% if can_edit %}
<div class="modal fade" id="modalAddSubject" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('admin.subject_add') }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-book text-primary me-2"></i>{% trans %}Nueva Asignatura{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. ASIG0015" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
<input type="number" name="credits" class="form-control form-control-sm" value="4" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Álgebra Lineal" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
<select name="career_id" class="form-select form-select-sm">
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
{% for c in careers %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
<input type="text" name="department" class="form-control form-control-sm" placeholder="ej. Ciencias Exactas, Informática">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea name="description" class="form-control form-control-sm" rows="2" placeholder="Programa de la materia..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Asignatura{% endtrans %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+24 -11
View File
@@ -104,19 +104,32 @@
{% if current_user.is_authenticated and current_user.can_manage() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
<i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
</a>
<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><hr class="dropdown-divider"></li>
<ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
<!-- Espacios & Infraestructura -->
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Espacios e Infraestructura{% endtrans %}</li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Gestión de Ocupación, Métricas{% endtrans %}</a></li>
<li><hr class="dropdown-divider my-1"></li>
<!-- Gestión Académica -->
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Gestión Académica{% endtrans %}</li>
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% 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>
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-door-open me-2 text-info"></i>{% trans %}Aulas y Métricas{% 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 text-secondary"></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>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
<li><hr class="dropdown-divider my-1"></li>
<!-- Administración & Sistema -->
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Administración & Seguridad{% endtrans %}</li>
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Usuarios{% 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 y Permisos{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
</ul>
</li>
{% endif %}
+383 -44
View File
@@ -1,17 +1,63 @@
{% extends "base.html" %}
{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %}
{% block title %}{% trans %}Aulas y Espacios{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
.classroom-card {
.custom-space-pills .nav-link {
font-weight: 600;
font-size: 0.9rem;
padding: 0.5rem 1.1rem;
border-radius: 0.5rem;
color: var(--bs-body-color);
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
transition: all 0.15s ease;
}
.custom-space-pills .nav-link.active {
background-color: var(--bs-primary);
color: #fff !important;
border-color: var(--bs-primary);
box-shadow: 0 4px 12px rgba(180, 62, 142, 0.25);
}
.custom-space-pills .nav-link:hover:not(.active) {
background-color: var(--bs-secondary-bg);
transform: translateY(-1px);
}
.classroom-card, .virtual-room-card {
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border-radius: 0.65rem;
}
.classroom-card:hover {
.classroom-card:hover, .virtual-room-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
}
.virtual-banner {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(180, 62, 142, 0.08) 100%);
border: 1px solid rgba(99, 102, 241, 0.25) !important;
border-radius: 0.75rem;
}
[data-bs-theme="dark"] .virtual-banner {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.16) 0%, rgba(180, 62, 142, 0.16) 100%);
border: 1px solid rgba(99, 102, 241, 0.35) !important;
}
.virtual-stat-box {
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 0.6rem 0.85rem;
transition: transform 0.15s ease, border-color 0.15s ease;
text-decoration: none !important;
display: block;
}
.virtual-stat-box:hover {
transform: translateY(-2px);
border-color: var(--bs-primary) !important;
}
.metric-pill {
padding: 0.35rem 0.2rem;
border-radius: 0.45rem;
@@ -84,27 +130,68 @@
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<!-- 1. Encabezado y Selector de Entorno (Presencial vs Virtual) -->
<div class="row mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
</h1>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</a>
<h1 class="h3 mb-1">
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Aulas y Espacios de Aprendizaje{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Administre las aulas físicas de sedes presenciales y las aulas virtuales a demanda del Campus Virtual.{% endtrans %}</p>
</div>
<div>
{% if view_mode == 'virtual' %}
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-success shadow-sm">
<i class="bi bi-camera-video me-1"></i>{% trans %}Nueva Clase Virtual a Demanda{% endtrans %}
</a>
{% else %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary shadow-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Aula Física{% endtrans %}
</a>
{% endif %}
</div>
</div>
<!-- Pestañas de Selección de Modalidad -->
<div class="mt-3">
<ul class="nav nav-pills custom-space-pills gap-2">
<li class="nav-item">
<a class="nav-link {% if view_mode == 'physical' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='physical', active_only='true' if active_only else 'false') }}">
<i class="bi bi-buildings me-2"></i>{% trans %}Aulas Presenciales{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'physical' %}bg-light text-primary{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{{ physical_count }} {% trans %}aulas{% endtrans %}
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if view_mode == 'virtual' %}active{% endif %}"
href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
<i class="bi bi-globe2 me-2 text-info"></i>{% trans %}Campus Virtual & Aulas a Demanda{% endtrans %}
<span class="badge rounded-pill ms-2 {% if view_mode == 'virtual' %}bg-light text-primary{% else %}bg-primary-subtle text-primary border{% endif %}">
<i class="bi bi-infinity me-1"></i>{{ virtual_count }} {% trans %}salas activas{% endtrans %}
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
<!-- Filters -->
<div class="row">
<!-- ========================================================================= -->
<!-- VISTA 1: AULAS FÍSICAS (PRESENCIALES) -->
<!-- ========================================================================= -->
{% if view_mode == 'physical' %}
<!-- Filtros de Aulas Físicas -->
<div class="row mb-3">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<input type="hidden" name="view_mode" value="physical">
<div class="col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
@@ -114,12 +201,12 @@
</div>
</div>
<!-- Filtro por Edificio -->
<!-- Filtro por Edificio Físico -->
<div class="col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-buildings text-primary"></i></span>
<select name="building_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans %}Todos los edificios{% endtrans %}</option>
<option value="">{% trans %}Todos los edificios presenciales{% endtrans %}</option>
{% for b in buildings %}
<option value="{{ b.id }}" {% if building_filter == (b.id|string) %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
@@ -127,7 +214,7 @@
</div>
</div>
<!-- Filtro por Piso -->
<!-- Filtro por Piso Real -->
<div class="col-md-2">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-layers text-secondary"></i></span>
@@ -144,7 +231,7 @@
<div class="col-md-2">
<div class="form-check pt-1">
<input class="form-check-input" type="checkbox" name="active_only"
<input class="form-check-input" type="checkbox" name="active_only" value="true"
id="activeOnly" {% if active_only %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="activeOnly">
{% trans %}Solo activas{% endtrans %}
@@ -156,7 +243,7 @@
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-filter"></i> {% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-sm btn-outline-secondary">
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Limpiar{% endtrans %}
</a>
</div>
@@ -166,7 +253,7 @@
</div>
</div>
<!-- Classrooms Grid -->
<!-- Grilla de Aulas Físicas -->
{% if classrooms and classrooms.items %}
<div class="row g-3 mt-1">
{% for classroom in classrooms.items %}
@@ -199,21 +286,15 @@
<!-- Capacidad y Recursos -->
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
<div>
<span class="text-muted">{% trans %}Cap:{% endtrans %}</span>
<strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity"></i></span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
<span class="text-muted">{% trans %}Capacidad:{% endtrans %}</span>
<strong class="text-body ms-1">{{ classroom.capacity }} {% trans %}asientos{% endtrans %}</strong>
</div>
<div>
<span class="text-muted"><i class="bi bi-tools me-1"></i>{{ classroom.resources|length if classroom.resources else 0 }}</span>
</div>
</div>
<!-- Barra de Métricas de Ocupación (Día, Semana, Mes) -->
<!-- Barra de Métricas de Ocupación FÍSICA (%) -->
{% set metrics = occupancy_metrics.get(classroom.id, {'day_pct': 0, 'day_count': 0, 'week_pct': 0, 'week_count': 0, 'month_pct': 0, 'month_count': 0}) %}
<div class="classroom-occupancy-bar p-2 rounded bg-body-tertiary border mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
@@ -231,7 +312,7 @@
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.day_pct > 80 %}high{% elif metrics.day_pct > 40 %}medium{% elif metrics.day_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Hoy{% endtrans %}: {{ metrics.day_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.day_count }} cl.{% else %}{{ metrics.day_pct }}%{% endif %}</div>
<div class="metric-val">{{ metrics.day_pct }}%</div>
</a>
</div>
<!-- Semana -->
@@ -240,7 +321,7 @@
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.week_pct > 80 %}high{% elif metrics.week_pct > 40 %}medium{% elif metrics.week_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Semana{% endtrans %}: {{ metrics.week_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.week_count }} cl.{% else %}{{ metrics.week_pct }}%{% endif %}</div>
<div class="metric-val">{{ metrics.week_pct }}%</div>
</a>
</div>
<!-- Mes -->
@@ -249,7 +330,7 @@
class="d-block p-1 text-decoration-none border metric-pill metric-{% if metrics.month_pct > 80 %}high{% elif metrics.month_pct > 40 %}medium{% elif metrics.month_pct > 0 %}low{% else %}zero{% endif %}"
title="{% trans %}Ocupación Mes{% endtrans %}: {{ metrics.month_count }} {% trans %}clases registradas{% endtrans %}">
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
<div class="metric-val">{% if classroom.is_virtual %}{{ metrics.month_count }} cl.{% else %}{{ metrics.month_pct }}%{% endif %}</div>
<div class="metric-val">{{ metrics.month_pct }}%</div>
</a>
</div>
</div>
@@ -285,14 +366,14 @@
{% endfor %}
</div>
<!-- Pagination -->
<!-- Paginación Aulas Físicas -->
{% if classrooms.pages > 1 %}
<nav aria-label="Classroom pagination">
<ul class="pagination justify-content-center">
<nav aria-label="Classroom pagination" class="mt-4">
<ul class="pagination pagination-sm justify-content-center">
{% if classrooms.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
{% trans %}Previous{% endtrans %}
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.prev_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{% trans %}Anterior{% endtrans %}
</a>
</li>
{% endif %}
@@ -301,7 +382,7 @@
{% if page_num %}
{% if page_num != classrooms.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=page_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{{ page_num }}
</a>
</li>
@@ -319,8 +400,8 @@
{% if classrooms.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
{% trans %}Next{% endtrans %}
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical', page=classrooms.next_num, search=search, active_only='true' if active_only else 'false', building_id=building_filter, floor=floor_filter) }}">
{% trans %}Siguiente{% endtrans %}
</a>
</li>
{% endif %}
@@ -333,20 +414,278 @@
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
<h4 class="mt-3 text-muted">{% trans %}No se encontraron aulas físicas{% endtrans %}</h4>
<p class="text-muted">
{% if search %}
{% trans %}No classrooms match your search criteria.{% endtrans %}
{% trans %}Ningún aula coincide con los criterios de búsqueda.{% endtrans %}
{% else %}
{% trans %}No classrooms have been registered yet.{% endtrans %}
{% trans %}No hay aulas físicas registradas en esta sede.{% endtrans %}
{% endif %}
</p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
<i class="bi bi-plus-circle"></i> {% trans %}Agregar Primera Aula{% endtrans %}
</a>
</div>
</div>
</div>
{% endif %}
<!-- ========================================================================= -->
<!-- VISTA 2: CAMPUS VIRTUAL (AULAS A DEMANDA · INFINITAS) -->
<!-- ========================================================================= -->
{% else %}
<!-- Banner de Infraestructura Cloud y Concurrencia Ilimitada -->
<div class="card virtual-banner p-3 mb-4 shadow-sm">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary text-white px-2 py-1"><i class="bi bi-cloud-check-fill me-1"></i>Cloud Infrastructure</span>
<h5 class="mb-0 fw-bold text-primary">
{% trans %}Campus Virtual UniCABA · Aulas a Demanda{% endtrans %}
</h5>
</div>
<p class="small text-muted mb-0 mt-1">
{% trans %}A diferencia de los espacios físicos, las aulas virtuales se crean y escalan de manera <strong>infinita y concurrente</strong> según la necesidad pedagógica de cada comisión.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2 fs-6">
<i class="bi bi-infinity me-1"></i>{% trans %}Capacidad Ilimitada{% endtrans %}
</span>
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-sm btn-primary">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar Clase Virtual{% endtrans %}
</a>
</div>
</div>
<!-- Indicadores de Demanda Global -->
<div class="row g-2">
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.today_schedule') }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Hoy{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-primary">{{ global_virtual_metrics.today_count }}</h4>
</div>
<div class="text-primary fs-4"><i class="bi bi-calendar-event"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales programadas hoy{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.week_start, date_to=date_filters.week_end) }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones Semana{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-info">{{ global_virtual_metrics.week_count }}</h4>
</div>
<div class="text-info fs-4"><i class="bi bi-calendar-week"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Clases virtuales esta semana{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}" class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Sesiones del Mes{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-success">{{ global_virtual_metrics.month_count }}</h4>
</div>
<div class="text-success fs-4"><i class="bi bi-calendar3"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Total clases remotas en el mes{% endtrans %}</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<div class="virtual-stat-box">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.7rem;">{% trans %}Salas Activas{% endtrans %}</span>
<h4 class="mb-0 fw-bold text-warning">{{ global_virtual_metrics.commissions_count }}</h4>
</div>
<div class="text-warning fs-4"><i class="bi bi-diagram-3"></i></div>
</div>
<small class="text-muted d-block mt-1" style="font-size: 0.72rem;">{% trans %}Comisiones con entorno virtual{% endtrans %}</small>
</div>
</div>
</div>
</div>
<!-- Barra de Búsqueda de Salas Virtuales -->
<div class="row mb-3">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-center">
<input type="hidden" name="view_mode" value="virtual">
<div class="col-md-6">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Buscar sala virtual por asignatura, comisión o carrera...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-6 text-md-end">
<button type="submit" class="btn btn-sm btn-primary me-1">
<i class="bi bi-funnel me-1"></i>{% trans %}Buscar Salas{% endtrans %}
</button>
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Catálogo de Aulas Virtuales por Comisión -->
{% if virtual_rooms %}
<div class="row g-3">
{% for room in virtual_rooms %}
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
<div class="card virtual-room-card border shadow-sm h-100">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Encabezado de la Sala Virtual -->
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="text-truncate me-2">
<h5 class="card-title mb-0 fw-bold text-primary text-truncate fs-6" title="{{ room.subject_name }}">
<i class="bi bi-laptop me-1 text-info"></i>{{ room.subject_name }}
</h5>
</div>
<span class="badge rounded-pill bg-info-subtle text-info border border-info-subtle" style="font-size: 0.68rem;">
<i class="bi bi-infinity me-1"></i>A Demanda
</span>
</div>
<!-- Badges: Comisión, Carrera y Turno -->
<div class="d-flex flex-wrap gap-1 mb-2">
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.7rem;">
<i class="bi bi-diagram-3 me-1"></i>Com. {{ room.commission_code }}
</span>
{% if room.subject_code %}
<span class="badge bg-secondary-subtle text-secondary border" style="font-size: 0.7rem;">
{{ room.subject_code }}
</span>
{% endif %}
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">
{{ room.shift }}
</span>
</div>
<!-- Cupo e Información Pedagógica -->
<div class="d-flex justify-content-between align-items-center small py-1 px-2 bg-body-tertiary rounded mb-2" style="font-size: 0.75rem;">
<div>
<span class="text-muted">{% trans %}Alumnos:{% endtrans %}</span>
<strong class="text-body ms-1">{{ room.max_students }}</strong>
</div>
<div>
<span class="text-muted">{% trans %}Modalidad:{% endtrans %}</span>
<strong class="text-info ms-1">Remoto ∞</strong>
</div>
</div>
<!-- Botón de Videollamada (Zoom / Meet / Teams) -->
<div class="mb-2">
{% if room.virtual_link %}
<a href="{{ room.virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success w-100 py-1 d-flex align-items-center justify-content-center"
style="font-size: 0.75rem;" title="{% trans %}Abrir enlace de videollamada{% endtrans %}">
<i class="bi bi-camera-video-fill me-1"></i>{% trans %}Unirse a Videollamada{% endtrans %}
</a>
{% else %}
<div class="p-1 rounded bg-body-tertiary border text-center small text-muted" style="font-size: 0.72rem;">
<i class="bi bi-link-45deg me-1"></i>{% trans %}Enlace a demanda por clase{% endtrans %}
</div>
{% endif %}
</div>
<!-- Barra de Demanda de la Sala Virtual (Hoy, Semana, Mes) -->
<div class="p-2 rounded bg-body-tertiary border mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small fw-semibold text-muted" style="font-size: 0.72rem;">
<i class="bi bi-calendar3 me-1 text-primary"></i>{% trans %}Clases a Demanda{% endtrans %}
</span>
<span class="text-muted" style="font-size: 0.65rem;">
{{ room.total_count }} {% trans %}totales{% endtrans %}
</span>
</div>
<div class="row g-1 text-center">
<!-- Hoy -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.commission_code, date_from=date_filters.today, date_to=date_filters.today) }}"
class="d-block p-1 text-decoration-none border metric-pill {% if room.day_count > 0 %}metric-low{% else %}metric-zero{% endif %}"
title="{% trans %}Ver clases de hoy para esta comisión{% endtrans %}">
<div class="metric-label">{% trans %}Hoy{% endtrans %}</div>
<div class="metric-val">{{ room.day_count }} cl.</div>
</a>
</div>
<!-- Semana -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.commission_code, date_from=date_filters.week_start, date_to=date_filters.week_end) }}"
class="d-block p-1 text-decoration-none border metric-pill {% if room.week_count > 0 %}metric-medium{% else %}metric-zero{% endif %}"
title="{% trans %}Ver clases de la semana para esta comisión{% endtrans %}">
<div class="metric-label">{% trans %}Semana{% endtrans %}</div>
<div class="metric-val">{{ room.week_count }} cl.</div>
</a>
</div>
<!-- Mes -->
<div class="col-4">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.commission_code, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
class="d-block p-1 text-decoration-none border metric-pill {% if room.month_count > 0 %}metric-high{% else %}metric-zero{% endif %}"
title="{% trans %}Ver clases del mes para esta comisión{% endtrans %}">
<div class="metric-label">{% trans %}Mes{% endtrans %}</div>
<div class="metric-val">{{ room.month_count }} cl.</div>
</a>
</div>
</div>
</div>
</div>
<!-- Footer con Botones -->
<div class="pt-2 border-top d-flex justify-content-between align-items-center">
<a href="{{ url_for('schedule.list_reservations', classroom_id=virtual_classroom_id, search=room.commission_code) }}"
class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.75rem;">
<i class="bi bi-calendar-check me-1"></i>{% trans %}Ver Reservas{% endtrans %}
</a>
<a href="{{ url_for('schedule.add_reservation') }}?commission_id={{ room.commission_id }}&is_virtual=1"
class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.75rem;" title="{% trans %}Programar clase en esta sala virtual{% endtrans %}">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Programar{% endtrans %}
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="row mt-4">
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-globe text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">{% trans %}No se encontraron salas virtuales{% endtrans %}</h4>
<p class="text-muted">
{% if search %}
{% trans %}Ninguna sala virtual coincide con los términos de búsqueda.{% endtrans %}
{% else %}
{% trans %}No hay clases virtuales programadas en el Campus Virtual.{% endtrans %}
{% endif %}
</p>
<a href="{{ url_for('schedule.add_reservation') }}?is_virtual=1" class="btn btn-primary">
<i class="bi bi-camera-video me-1"></i>{% trans %}Crear Primera Reserva Virtual{% endtrans %}
</a>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
-6
View File
@@ -8,12 +8,6 @@
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('schedule.today_schedule') }}">{% trans %}Schedule{% endtrans %}</a></li>
<li class="breadcrumb-item active">{% trans %}New Reservation{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}New Reservation{% endtrans %}
</h1>
-6
View File
@@ -516,12 +516,6 @@
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">{% trans %}Dashboard{% endtrans %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans %}Comisiones{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-diagram-3 text-primary me-2"></i>{% trans %}Gestión de Comisiones y Cursadas{% endtrans %}
</h1>