feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
This commit is contained in:
@@ -0,0 +1,664 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}New Reservation - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-calendar-plus text-primary me-2"></i>New Reservation
|
||||
</h1>
|
||||
</div>
|
||||
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Reservations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-calendar3 text-primary me-2"></i>Detalles de la Reserva y Espacio Físico
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" id="reservation-form" novalidate>
|
||||
|
||||
<!-- 1. SELECTORES EN CASCADA: Edificio -> Piso -> Aula -->
|
||||
<div class="mb-4">
|
||||
<div class="p-3 rounded bg-body-tertiary border">
|
||||
<label class="form-label fw-bold text-primary mb-2">
|
||||
<i class="bi bi-geo-alt-fill me-1"></i>Selección de Espacio Físico / Modalidad <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="row g-3">
|
||||
<!-- Edificio / Sede -->
|
||||
<div class="col-md-4">
|
||||
<label for="cascade-building" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-buildings text-primary me-1"></i>1. Edificio / Sede
|
||||
</label>
|
||||
<select id="cascade-building" class="form-select form-select-sm">
|
||||
<option value="">-- Selecciona Edificio --</option>
|
||||
{% for b in buildings %}
|
||||
<option value="{{ b.id }}" data-virtual="{{ 'true' if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) else 'false' }}">
|
||||
{% if b.code == 'VIRTUAL' or 'VIRTUAL' in (b.name|upper) %}🌐{% else %}🏢{% endif %} {{ b.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text small text-muted">Sede física o Campus Virtual</div>
|
||||
</div>
|
||||
|
||||
<!-- Piso / Nivel -->
|
||||
<div class="col-md-4">
|
||||
<label for="cascade-floor" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-layers text-secondary me-1"></i>2. Piso / Nivel
|
||||
</label>
|
||||
<select id="cascade-floor" class="form-select form-select-sm" disabled>
|
||||
<option value="">-- Selecciona Piso --</option>
|
||||
</select>
|
||||
<div class="form-text small text-muted" id="floorHelpText">Filtra por planta o nivel</div>
|
||||
</div>
|
||||
|
||||
<!-- Aula / Espacio (Campo del Formulario) -->
|
||||
<div class="col-md-4">
|
||||
<label for="classroom-select" class="form-label small fw-semibold text-muted mb-1">
|
||||
<i class="bi bi-door-open text-success me-1"></i>3. Aula / Espacio <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.classroom_id(class="form-select form-select-sm", id="classroom-select") }}
|
||||
{% if form.classroom_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.classroom_id.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text small text-muted">Capacidad y disponibilidad</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Comisión y Materia -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12">
|
||||
{{ form.commission_id.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.commission_id(class="form-select", id="commission-select") }}
|
||||
{% if form.commission_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.commission_id.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text small text-muted">Materia, código de comisión y cantidad de alumnos inscriptos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Horario de Inicio y Fin -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
{{ form.start_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.start_time(class="form-control", id="start-time") }}
|
||||
{% if form.start_time.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.start_time.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">¿Cuándo comenzará la clase?</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
{{ form.end_time.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.end_time(class="form-control", id="end-time") }}
|
||||
{% if form.end_time.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.end_time.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">¿Cuándo finalizará la clase?</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Propósito y Asistentes -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-8">
|
||||
{{ form.purpose.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.purpose(class="form-control", placeholder=_("Clase regular, examen, sesión de estudio, etc.")) }}
|
||||
{% if form.purpose.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.purpose.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{{ form.expected_attendees.label(class="form-label fw-semibold") }} <span class="text-danger">*</span>
|
||||
{{ form.expected_attendees(class="form-control", id="expected_attendees", placeholder="30") }}
|
||||
{% if form.expected_attendees.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.expected_attendees.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Cantidad de asistentes esperados</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Notas -->
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.notes(class="form-control", rows="2", placeholder=_("Cualquier nota adicional o requerimientos especiales...")) }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.notes.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 6. Enlace de Aula Virtual (Zoom, Meet, Teams, Moodle) -->
|
||||
<div class="mb-4" id="virtual-link-container">
|
||||
<label for="virtual_link" class="form-label fw-semibold">
|
||||
<i class="bi bi-camera-video text-primary me-1"></i>Enlace de Aula Virtual (Zoom, Google Meet, Teams, Moodle)
|
||||
</label>
|
||||
{{ form.virtual_link(class="form-control", id="virtual_link", placeholder="https://zoom.us/j/... o https://meet.google.com/...") }}
|
||||
{% if form.virtual_link.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.virtual_link.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text small text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>Obligatorio para Campus Virtual o clases remotas/híbridas. Se autocompleta con el enlace de la comisión seleccionada.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. TARJETA DE RESUMEN DESGLOSADA: Edificio, Piso, Aula, Modalidad -->
|
||||
<div class="card bg-body-tertiary border-0 shadow-xs mb-4" id="reservation-summary">
|
||||
<div class="card-body p-3">
|
||||
<h6 class="card-subtitle mb-2 text-muted small fw-bold">
|
||||
<i class="bi bi-info-circle text-info me-1"></i>Resumen de la Reserva
|
||||
</h6>
|
||||
|
||||
<!-- Badges desglosados de Ubicación -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="p-2 rounded bg-body border">
|
||||
<small class="text-muted d-block">🏢 Edificio / Sede</small>
|
||||
<strong id="summary-building" class="text-primary">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-2 rounded bg-body border">
|
||||
<small class="text-muted d-block">🪜 Piso / Nivel</small>
|
||||
<strong id="summary-floor" class="text-secondary">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-2 rounded bg-body border">
|
||||
<small class="text-muted d-block">🚪 Aula Seleccionada</small>
|
||||
<strong id="summary-classroom" class="text-success">No seleccionada</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fila de Enlace Virtual -->
|
||||
<div class="row g-2 mb-3" id="summary-virtual-row">
|
||||
<div class="col-12">
|
||||
<div class="p-2 rounded bg-body border d-flex align-items-center justify-content-between">
|
||||
<div class="text-truncate me-2">
|
||||
<small class="text-muted d-block">🎥 Enlace Virtual (Videoconferencia)</small>
|
||||
<span id="summary-virtual-link" class="small font-monospace text-primary text-break">Sin enlace configurado</span>
|
||||
</div>
|
||||
<span class="badge bg-success-subtle text-success border" id="summary-virtual-badge" style="display:none;">
|
||||
<i class="bi bi-camera-video me-1"></i>Virtual
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duración y Comisión -->
|
||||
<div class="row g-2 small">
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">⏱️ Duración:</span>
|
||||
<strong id="summary-duration" class="text-body ms-1">No calculada</strong>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<span class="text-muted">📚 Comisión:</span>
|
||||
<strong id="summary-commission" class="text-body ms-1">No seleccionada</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/schedule/today_schedule" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancelar
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success px-4">
|
||||
<i class="bi bi-calendar-check me-1"></i>Realizar Reserva
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="application/json" id="classroom-data">
|
||||
{
|
||||
{% for c in classroom_list %}
|
||||
"{{ c.id }}": {
|
||||
"id": {{ c.id }},
|
||||
"building_id": {{ (c.building_id or 0)|tojson }},
|
||||
"building_name": {{ c.building_name|tojson }},
|
||||
"floor": {{ c.floor|tojson }},
|
||||
"floor_display": {{ c.floor_display|tojson }},
|
||||
"code": {{ c.code|tojson }},
|
||||
"code_display": {{ c.code_display|tojson }},
|
||||
"location_display": {{ c.location_display|tojson }},
|
||||
"capacity": {{ c.capacity_display|tojson }},
|
||||
"capacity_num": {{ c.capacity }},
|
||||
"is_virtual": {{ c.is_virtual|tojson }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="commission-data">
|
||||
{
|
||||
{% for c in commission_list %}
|
||||
"{{ c.id }}": {
|
||||
"id": {{ c.id }},
|
||||
"code": {{ c.code|tojson }},
|
||||
"virtual_link": {{ (c.virtual_link or '')|tojson }},
|
||||
"max_students": {{ c.max_students }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/json" id="i18n-strings">
|
||||
{
|
||||
"not_selected": "No seleccionada",
|
||||
"not_calculated": "No calculada",
|
||||
"invalid_time": "Rango horario inválido",
|
||||
"hour": "hora",
|
||||
"hours": "horas",
|
||||
"minute": "minuto",
|
||||
"minutes": "minutos",
|
||||
"capacity": "Capacidad",
|
||||
"capacity_exceeded": "Los asistentes esperados superan la capacidad del aula",
|
||||
"no_virtual_link": "Sin enlace configurado (Clase presencial)"
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let classroomData = {};
|
||||
try {
|
||||
const rawData = document.getElementById('classroom-data')?.textContent;
|
||||
if (rawData) classroomData = JSON.parse(rawData);
|
||||
} catch (err) {
|
||||
console.error('Error parsing classroomData:', err);
|
||||
}
|
||||
|
||||
let commissionData = {};
|
||||
try {
|
||||
const rawComm = document.getElementById('commission-data')?.textContent;
|
||||
if (rawComm) commissionData = JSON.parse(rawComm);
|
||||
} catch (err) {
|
||||
console.error('Error parsing commissionData:', err);
|
||||
}
|
||||
|
||||
let i18n = {};
|
||||
try {
|
||||
const rawI18n = document.getElementById('i18n-strings')?.textContent;
|
||||
if (rawI18n) i18n = JSON.parse(rawI18n);
|
||||
} catch (err) {
|
||||
console.error('Error parsing i18n:', err);
|
||||
}
|
||||
|
||||
const $building = $('#cascade-building');
|
||||
const $floor = $('#cascade-floor');
|
||||
const $classroom = $('#classroom-select');
|
||||
const $floorHelp = $('#floorHelpText');
|
||||
|
||||
// Guardar todas las opciones originales de aulas
|
||||
const originalClassroomOptions = [];
|
||||
$classroom.find('option').each(function() {
|
||||
originalClassroomOptions.push({
|
||||
value: $(this).val(),
|
||||
text: $(this).text()
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Manejo del cambio de Edificio
|
||||
$building.on('change', function() {
|
||||
const selectedBuildingId = $(this).val();
|
||||
const isVirtual = $(this).find(':selected').data('virtual') === true || $(this).find(':selected').data('virtual') === 'true';
|
||||
|
||||
$floor.empty().append('<option value="">-- ' + (isVirtual ? 'No aplica (Campus Virtual)' : 'Selecciona Piso') + ' --</option>');
|
||||
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
|
||||
|
||||
if (!selectedBuildingId) {
|
||||
$floor.prop('disabled', true);
|
||||
$classroom.prop('disabled', false);
|
||||
// Restaurar todas
|
||||
originalClassroomOptions.forEach(opt => {
|
||||
if (opt.value) $classroom.append($('<option>', { value: opt.value, text: opt.text }));
|
||||
});
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVirtual) {
|
||||
$floor.prop('disabled', true);
|
||||
$floorHelp.text('Modalidad 100% remota sin piso asignado');
|
||||
|
||||
// Filtrar solo aulas virtuales
|
||||
let virtualCount = 0;
|
||||
let lastVirtualId = null;
|
||||
Object.values(classroomData).forEach(c => {
|
||||
if (c.is_virtual || c.building_id == selectedBuildingId) {
|
||||
$classroom.append($('<option>', {
|
||||
value: c.id,
|
||||
text: `${c.code_display} (Cap: ${c.capacity})`
|
||||
}));
|
||||
lastVirtualId = c.id;
|
||||
virtualCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (virtualCount === 1 && lastVirtualId) {
|
||||
$classroom.val(lastVirtualId);
|
||||
}
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
// Edificio Físico
|
||||
$floor.prop('disabled', false);
|
||||
$floorHelp.text('Filtra por planta o nivel');
|
||||
|
||||
// Extraer pisos únicos de este edificio
|
||||
const buildingFloors = [];
|
||||
Object.values(classroomData).forEach(c => {
|
||||
if (c.building_id == selectedBuildingId && c.floor) {
|
||||
if (!buildingFloors.some(f => f.floor === c.floor)) {
|
||||
buildingFloors.push({
|
||||
floor: c.floor,
|
||||
floor_display: c.floor_display
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ordenar pisos: PB primero, luego numérico
|
||||
buildingFloors.sort((a, b) => {
|
||||
if (a.floor === '0' || a.floor.toUpperCase() === 'PB') return -1;
|
||||
if (b.floor === '0' || b.floor.toUpperCase() === 'PB') return 1;
|
||||
return a.floor.localeCompare(b.floor, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
buildingFloors.forEach(f => {
|
||||
$floor.append($('<option>', {
|
||||
value: f.floor,
|
||||
text: f.floor_display
|
||||
}));
|
||||
});
|
||||
|
||||
// Poblar aulas de este edificio inicialmente
|
||||
Object.values(classroomData).forEach(c => {
|
||||
if (c.building_id == selectedBuildingId) {
|
||||
$classroom.append($('<option>', {
|
||||
value: c.id,
|
||||
text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity})`
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// 2. Manejo del cambio de Piso
|
||||
$floor.on('change', function() {
|
||||
const selectedBuildingId = $building.val();
|
||||
const selectedFloor = $(this).val();
|
||||
|
||||
$classroom.empty().append('<option value="">-- Selecciona Aula --</option>');
|
||||
|
||||
let count = 0;
|
||||
let lastId = null;
|
||||
|
||||
Object.values(classroomData).forEach(c => {
|
||||
const matchesBuilding = !selectedBuildingId || c.building_id == selectedBuildingId;
|
||||
const matchesFloor = !selectedFloor || c.floor == selectedFloor;
|
||||
|
||||
if (matchesBuilding && matchesFloor) {
|
||||
$classroom.append($('<option>', {
|
||||
value: c.id,
|
||||
text: `${c.code} (Cap: ${c.capacity})`
|
||||
}));
|
||||
lastId = c.id;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
if (count === 1 && lastId) {
|
||||
$classroom.val(lastId);
|
||||
}
|
||||
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// 3. Manejo del cambio de Aula directo
|
||||
$classroom.on('change', function() {
|
||||
const cId = $(this).val();
|
||||
if (cId && classroomData[cId]) {
|
||||
const c = classroomData[cId];
|
||||
// Sincronizar selectores en cascada si no coinciden
|
||||
if ($building.val() != c.building_id) {
|
||||
$building.val(c.building_id);
|
||||
// Si no es virtual, sincronizar piso
|
||||
if (!c.is_virtual) {
|
||||
$floor.prop('disabled', false);
|
||||
$floor.val(c.floor);
|
||||
}
|
||||
} else if (!c.is_virtual && $floor.val() != c.floor) {
|
||||
$floor.val(c.floor);
|
||||
}
|
||||
}
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
// Inicializar cascada si ya hay un aula seleccionada (ej. post-error o query string)
|
||||
const initialClassroomId = $classroom.val();
|
||||
if (initialClassroomId && classroomData[initialClassroomId]) {
|
||||
const c = classroomData[initialClassroomId];
|
||||
$building.val(c.building_id).trigger('change');
|
||||
if (!c.is_virtual) {
|
||||
$floor.val(c.floor).trigger('change');
|
||||
}
|
||||
$classroom.val(initialClassroomId);
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const classroomId = $classroom.val();
|
||||
const commissionId = $('#commission-select').val();
|
||||
const startTime = $('#start-time').val();
|
||||
const endTime = $('#end-time').val();
|
||||
|
||||
// Update classroom badges
|
||||
if (classroomId && classroomData[classroomId]) {
|
||||
const c = classroomData[classroomId];
|
||||
$('#summary-building').text(c.building_name);
|
||||
$('#summary-floor').text(c.is_virtual ? 'Remoto (Virtual)' : c.floor_display);
|
||||
$('#summary-classroom').text(`${c.code} (Cap: ${c.capacity})`);
|
||||
} else {
|
||||
$('#summary-building').text('-');
|
||||
$('#summary-floor').text('-');
|
||||
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
|
||||
}
|
||||
|
||||
// Update commission summary and autofill virtual link if available
|
||||
if (commissionId) {
|
||||
const commissionText = $('#commission-select option:selected').text();
|
||||
$('#summary-commission').text(commissionText);
|
||||
} else {
|
||||
$('#summary-commission').text(i18n.not_selected || 'No seleccionada');
|
||||
}
|
||||
|
||||
// Update virtual link in summary
|
||||
const vLink = ($('#virtual_link').val() || '').trim();
|
||||
const isVirtual = (classroomId && classroomData[classroomId] && classroomData[classroomId].is_virtual);
|
||||
|
||||
if (vLink) {
|
||||
$('#summary-virtual-link').text(vLink);
|
||||
$('#summary-virtual-badge').show();
|
||||
} else if (isVirtual) {
|
||||
$('#summary-virtual-link').html('<span class="text-warning fw-semibold"><i class="bi bi-exclamation-triangle me-1"></i>Campus Virtual (Recomendado agregar enlace Zoom/Meet)</span>');
|
||||
$('#summary-virtual-badge').hide();
|
||||
} else {
|
||||
$('#summary-virtual-link').text(i18n.no_virtual_link || 'Sin enlace configurado (Clase presencial)');
|
||||
$('#summary-virtual-badge').hide();
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
if (startTime && endTime) {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
|
||||
if (end > start) {
|
||||
const diffMs = end - start;
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
let durationText = '';
|
||||
if (diffHours > 0) {
|
||||
const hLabel = diffHours > 1 ? (i18n.hours || 'horas') : (i18n.hour || 'hora');
|
||||
durationText += `${diffHours} ${hLabel}`;
|
||||
}
|
||||
if (diffMins > 0) {
|
||||
if (durationText) durationText += ' ';
|
||||
const mLabel = diffMins > 1 ? (i18n.minutes || 'minutos') : (i18n.minute || 'minuto');
|
||||
durationText += `${diffMins} ${mLabel}`;
|
||||
}
|
||||
|
||||
$('#summary-duration').text(durationText || '0 min');
|
||||
} else {
|
||||
$('#summary-duration').text(i18n.invalid_time || 'Rango horario inválido');
|
||||
}
|
||||
} else {
|
||||
$('#summary-duration').text(i18n.not_calculated || 'No calculada');
|
||||
}
|
||||
}
|
||||
|
||||
// Check capacity vs expected attendees
|
||||
function checkCapacity() {
|
||||
const classroomId = $classroom.val();
|
||||
const attendees = parseInt($('#expected_attendees').val()) || 0;
|
||||
|
||||
if (classroomId && classroomData[classroomId] && attendees > 0) {
|
||||
const c = classroomData[classroomId];
|
||||
// No validar si es virtual o capacidad ilimitada
|
||||
if (c.is_virtual || c.capacity === '∞' || c.capacity_num === 0) {
|
||||
$('#expected_attendees').removeClass('is-invalid');
|
||||
$('#expected_attendees').siblings('.invalid-feedback').remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (attendees > c.capacity_num) {
|
||||
$('#expected_attendees').addClass('is-invalid');
|
||||
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
|
||||
if ($('#expected_attendees').siblings('.invalid-feedback').length === 0) {
|
||||
$('#expected_attendees').after(`
|
||||
<div class="invalid-feedback">
|
||||
${msg} (${attendees} > ${c.capacity_num})
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
} else {
|
||||
$('#expected_attendees').removeClass('is-invalid');
|
||||
$('#expected_attendees').siblings('.invalid-feedback').remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$('#commission-select').on('change', function() {
|
||||
const cId = $(this).val();
|
||||
if (cId && commissionData[cId]) {
|
||||
const comm = commissionData[cId];
|
||||
if (comm.virtual_link && !$('#virtual_link').val().trim()) {
|
||||
$('#virtual_link').val(comm.virtual_link);
|
||||
}
|
||||
}
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
$('#virtual_link').on('input change', updateSummary);
|
||||
$('#start-time, #end-time').on('change', updateSummary);
|
||||
$('#expected_attendees').on('input', function() {
|
||||
checkCapacity();
|
||||
updateSummary();
|
||||
});
|
||||
|
||||
updateSummary();
|
||||
|
||||
// Form validation
|
||||
$('#reservation-form').on('submit', function(e) {
|
||||
let isValid = true;
|
||||
|
||||
if (!$classroom.val()) {
|
||||
$classroom.addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$classroom.removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#commission-select').val()) {
|
||||
$('#commission-select').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#commission-select').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#start-time').val()) {
|
||||
$('#start-time').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#start-time').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#end-time').val()) {
|
||||
$('#end-time').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#end-time').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
const startTime = new Date($('#start-time').val());
|
||||
const endTime = new Date($('#end-time').val());
|
||||
|
||||
if (endTime <= startTime) {
|
||||
$('#end-time').addClass('is-invalid');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
checkCapacity();
|
||||
if ($('#expected_attendees').hasClass('is-invalid')) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
});
|
||||
|
||||
// Set minimum datetime to current
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
$('#start-time, #end-time').attr('min', now.toISOString().slice(0, 16));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user