feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README
This commit is contained in:
@@ -3,29 +3,29 @@
|
||||
{% block title %}Reservations Calendar - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="container-fluid px-0">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<h1 class="h3 mb-1">
|
||||
<h1 class="h4 mb-1 fw-bold">
|
||||
<i class="bi bi-calendar3 text-primary me-2"></i>Reservations Calendar
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">View & Manage Reservations — Horarios de cursada y asignación de aulas UniCABA</p>
|
||||
<p class="text-muted small mb-0">Gestión de Reservas — Horarios de cursada y asignación de aulas UniCABA</p>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
<div class="col-md-5 text-md-end mt-2 mt-md-0">
|
||||
{% if user.has_permission('reservations', 'read_write') %}
|
||||
<a href="/schedule/add_reservation" class="btn btn-primary me-2">
|
||||
<a href="/schedule/add_reservation" class="btn btn-primary btn-sm me-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Reservation
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
|
||||
<a href="/schedule/list_reservations" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-list me-1"></i>List View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Toolbar (Classroom & Subject Filters to prevent overlapping) -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-2 align-items-center">
|
||||
<!-- Filter by Classroom -->
|
||||
@@ -141,32 +141,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Card -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
|
||||
<i class="bi bi-info-circle me-1"></i>Referencia:
|
||||
<!-- Grid Layout: Panel Lateral de Comisiones Pendientes + Calendario Principal -->
|
||||
<div class="row g-3">
|
||||
<!-- Sidebar de Comisiones Pendientes de Aula (Drag & Drop) -->
|
||||
<div class="col-xl-3 col-lg-4" id="unassignedCommissionsSidebar">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold small text-body">
|
||||
<i class="bi bi-grip-vertical text-primary me-1"></i>Comisiones por Asignar
|
||||
</span>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
|
||||
<small class="text-muted">Clase Confirmada</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
|
||||
<small class="text-muted">Pendiente</small>
|
||||
</div>
|
||||
<span class="badge bg-primary rounded-pill" id="unassignedCount">{{ unassigned_commissions|length }}</span>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>Haz clic en cualquier clase para ver los detalles completos
|
||||
<div class="card-body p-2" style="max-height: 800px; overflow-y: auto;">
|
||||
<div class="alert alert-info border-0 py-2 px-2 small mb-2 d-flex align-items-center">
|
||||
<i class="bi bi-info-circle-fill me-2 fs-6"></i>
|
||||
<span>Arrastra una comisión a la grilla para asignarla al aula filtrada.</span>
|
||||
</div>
|
||||
<div id="externalEventsContainer" class="d-flex flex-column gap-2">
|
||||
{% for c in unassigned_commissions %}
|
||||
<div class="fc-event-drag-item p-2 rounded-2 border shadow-sm"
|
||||
data-commission-id="{{ c.id }}"
|
||||
data-subject-name="{{ c.subject.name if c.subject else 'Materia' }}"
|
||||
data-code="{{ c.get_full_code or c.code }}"
|
||||
data-students="{{ c.current_students or 0 }}"
|
||||
data-shift="{{ c.shift or 'Regular' }}"
|
||||
style="cursor: grab; background-color: var(--bs-body-bg); border-left: 4px solid #B43E8E !important; user-select: none;">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong class="text-primary small font-monospace">{{ c.get_full_code or c.code }}</strong>
|
||||
<span class="badge bg-secondary-subtle text-secondary small"><i class="bi bi-people me-1"></i>{{ c.current_students or 0 }} al.</span>
|
||||
</div>
|
||||
<div class="small fw-semibold text-truncate text-body mt-1">{{ c.subject.name if c.subject else 'Materia' }}</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Turno: {{ c.shift or 'Regular' }}</small>
|
||||
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.65rem;">Arrastrar <i class="bi bi-arrows-move"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted small" id="noUnassignedPlaceholder">
|
||||
<i class="bi bi-check2-circle fs-3 text-success d-block mb-1"></i>
|
||||
Todas las comisiones tienen aula asignada.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-3">
|
||||
<!-- FullCalendar Container -->
|
||||
<div id="calendar"></div>
|
||||
|
||||
<!-- Columna del Calendario Principal -->
|
||||
<div class="col-xl-9 col-lg-8" id="calendarMainCol">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
|
||||
<i class="bi bi-info-circle me-1"></i>Referencia:
|
||||
</span>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
|
||||
<small class="text-muted">Clase Confirmada</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
|
||||
<small class="text-muted">Pendiente</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-arrows-move text-primary me-1"></i><strong>Drag & Drop activo:</strong> Mueve clases en el calendario con validación de choques en tiempo real
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-3">
|
||||
<!-- FullCalendar Container -->
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -759,6 +808,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
initialView: 'timeGridWeek',
|
||||
initialDate: todayDate,
|
||||
allDaySlot: false,
|
||||
editable: true,
|
||||
droppable: true,
|
||||
eventDurationEditable: true,
|
||||
eventResizableFromStart: false,
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
@@ -784,6 +837,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
slotEventOverlap: true,
|
||||
expandRows: true,
|
||||
stickyHeaderDates: true,
|
||||
|
||||
/* Drag and Drop Handlers con Validación de Colisión en Tiempo Real */
|
||||
eventDrop: function(info) {
|
||||
handleEventDragOrResize(info, false);
|
||||
},
|
||||
eventResize: function(info) {
|
||||
handleEventDragOrResize(info, true);
|
||||
},
|
||||
eventReceive: function(info) {
|
||||
handleExternalDrop(info);
|
||||
},
|
||||
|
||||
/* Dynamic Server-Side Filtered Events */
|
||||
events: function(info, successCallback, failureCallback) {
|
||||
@@ -1018,6 +1082,153 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
|
||||
// Soporte Drag and Drop externo para Comisiones Pendientes
|
||||
var containerEl = document.getElementById('externalEventsContainer');
|
||||
if (containerEl && typeof FullCalendar.Draggable !== 'undefined') {
|
||||
new FullCalendar.Draggable(containerEl, {
|
||||
itemSelector: '.fc-event-drag-item',
|
||||
eventData: function(eventEl) {
|
||||
return {
|
||||
title: (eventEl.getAttribute('data-code') || 'Comisión') + ' - ' + (eventEl.getAttribute('data-subject-name') || ''),
|
||||
extendedProps: {
|
||||
commission_id: eventEl.getAttribute('data-commission-id'),
|
||||
subject_name: eventEl.getAttribute('data-subject-name'),
|
||||
is_external_commission: true
|
||||
},
|
||||
duration: '02:00'
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(title, message, details, type) {
|
||||
var toastContainer = document.querySelector('.toast-container');
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
||||
toastContainer.style.zIndex = '1090';
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
var bgClass = type === 'danger' ? 'bg-danger text-white' : (type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white');
|
||||
var iconClass = type === 'danger' ? 'bi-exclamation-octagon-fill' : (type === 'warning' ? 'bi-exclamation-triangle-fill' : 'bi-check-circle-fill');
|
||||
|
||||
var detailsHtml = '';
|
||||
if (details && details.length > 0) {
|
||||
detailsHtml = '<ul class="mb-0 mt-1 ps-3 small">' + details.map(function(d) { return '<li>' + d + '</li>'; }).join('') + '</ul>';
|
||||
}
|
||||
|
||||
var toastEl = document.createElement('div');
|
||||
toastEl.className = 'toast align-items-center ' + bgClass + ' border-0 shadow-lg mb-2';
|
||||
toastEl.setAttribute('role', 'alert');
|
||||
toastEl.setAttribute('aria-live', 'assertive');
|
||||
toastEl.setAttribute('aria-atomic', 'true');
|
||||
toastEl.innerHTML = '<div class="d-flex"><div class="toast-body"><div class="fw-bold"><i class="bi ' + iconClass + ' me-2"></i>' + title + '</div><div class="small">' + message + '</div>' + detailsHtml + '</div><button type="button" class="btn-close ' + (type === 'warning' ? '' : 'btn-close-white') + ' me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button></div>';
|
||||
toastContainer.appendChild(toastEl);
|
||||
var bsToast = new bootstrap.Toast(toastEl, { delay: 6000 });
|
||||
bsToast.show();
|
||||
toastEl.addEventListener('hidden.bs.toast', function() {
|
||||
toastEl.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function handleEventDragOrResize(info, isResize) {
|
||||
var startIso = info.event.start ? info.event.start.toISOString() : null;
|
||||
var endIso = info.event.end ? info.event.end.toISOString() : (info.event.start ? new Date(info.event.start.getTime() + 2 * 3600 * 1000).toISOString() : null);
|
||||
var ext = info.event.extendedProps || {};
|
||||
var classroomId = filterRoom.value ? parseInt(filterRoom.value, 10) : (ext.classroom_id || null);
|
||||
|
||||
fetch('/api/v1/reservations/drag-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reservation_id: parseInt(info.event.id, 10),
|
||||
start_time: startIso,
|
||||
end_time: endIso,
|
||||
classroom_id: classroomId
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
return res.json().then(function(data) {
|
||||
return { status: res.status, data: data };
|
||||
});
|
||||
})
|
||||
.then(function(result) {
|
||||
if (result.status === 409 || (result.data && result.data.status === 'conflict')) {
|
||||
info.revert();
|
||||
var reasons = (result.data.conflicts || []).concat(result.data.block_reasons || []);
|
||||
showToast('Conflicto de Horario Detectado', result.data.message || 'No se puede reubicar la clase en ese espacio/horario.', reasons, 'danger');
|
||||
} else if (result.status >= 400) {
|
||||
info.revert();
|
||||
showToast('Error', result.data.message || 'Ocurrió un error al actualizar la reserva.', [], 'danger');
|
||||
} else {
|
||||
showToast('Reserva Actualizada', result.data.message || 'Horario y aula confirmados con éxito.', [], 'success');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
info.revert();
|
||||
console.error('Drag update error:', err);
|
||||
showToast('Error de Conexión', 'No se pudo comunicar con el servidor.', [], 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function handleExternalDrop(info) {
|
||||
var ext = info.event.extendedProps || {};
|
||||
var commissionId = ext.commission_id;
|
||||
var startIso = info.event.start ? info.event.start.toISOString() : null;
|
||||
var endIso = info.event.end ? info.event.end.toISOString() : (info.event.start ? new Date(info.event.start.getTime() + 2 * 3600 * 1000).toISOString() : null);
|
||||
var classroomId = filterRoom.value ? parseInt(filterRoom.value, 10) : null;
|
||||
|
||||
if (!classroomId) {
|
||||
info.revert();
|
||||
showToast('Aula Requerida', 'Por favor selecciona un Aula en el filtro superior antes de arrastrar comisiones a la grilla.', [], 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/v1/reservations/drag-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
commission_id: parseInt(commissionId, 10),
|
||||
start_time: startIso,
|
||||
end_time: endIso,
|
||||
classroom_id: classroomId
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
return res.json().then(function(data) {
|
||||
return { status: res.status, data: data };
|
||||
});
|
||||
})
|
||||
.then(function(result) {
|
||||
if (result.status === 409 || (result.data && result.data.status === 'conflict')) {
|
||||
info.revert();
|
||||
var reasons = (result.data.conflicts || []).concat(result.data.block_reasons || []);
|
||||
showToast('Conflicto al Asignar Comisión', result.data.message || 'El aula o docente no están disponibles en este horario.', reasons, 'danger');
|
||||
} else if (result.status >= 400) {
|
||||
info.revert();
|
||||
showToast('Error', result.data.message || 'No se pudo asignar el aula a la comisión.', [], 'danger');
|
||||
} else {
|
||||
showToast('Comisión Asignada', result.data.message || 'Comisión asignada con éxito al aula.', [], 'success');
|
||||
var draggedItem = document.querySelector('.fc-event-drag-item[data-commission-id="' + commissionId + '"]');
|
||||
if (draggedItem) {
|
||||
draggedItem.remove();
|
||||
var countBadge = document.getElementById('unassignedCount');
|
||||
if (countBadge) {
|
||||
var curr = parseInt(countBadge.textContent, 10) || 1;
|
||||
countBadge.textContent = Math.max(0, curr - 1);
|
||||
}
|
||||
}
|
||||
calendar.refetchEvents();
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
info.revert();
|
||||
console.error('External drop error:', err);
|
||||
showToast('Error de Conexión', 'No se pudo registrar la asignación.', [], 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
calendar.render();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user