feat: rediseno de calendario estilo Google Calendar y correccion de modal de roles

This commit is contained in:
Carlos Tello
2026-09-03 04:10:36 -03:00
parent 291e537574
commit 8809ff3410
4 changed files with 677 additions and 195 deletions
+23
View File
@@ -262,6 +262,29 @@
* Se expuso el endpoint `GET /api/milestone-types` para consumo desacoplado.
* Se actualizó el modal de eventos académicos (`evento-modal.component.html` y `.ts`) desacoplando las opciones estáticas y cargando dinámicamente el catálogo de tipificaciones activas con fallback automático para funcionamiento offline.
---
## 17. Rediseño de Calendario Estilo Google Calendar y Corrección Visual en Eliminación de Roles
* **Manejo de Eventos Simultáneos y Densidad Visual ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):**
* Se configuró FullCalendar con `eventMaxStack: 3`, evitando que decenas de reservas concurrentes reduzcan las columnas a franjas milimétricas del 3%.
* Se habilitó `dayMaxEvents: 3`, `dayMaxEventRows: true` y `moreLinkClick: 'popover'` nativo de Google Calendar con enlaces "+X más".
* Se fijaron `slotMinTime: "07:00:00"`, `slotMaxTime: "23:00:00"`, `slotDuration: "00:30:00"`, `slotLabelInterval: "01:00:00"` y `nowIndicator: true` con la línea roja indicadora de hora actual.
* **Diseño de Tarjetas de Evento Píldora (`.gc-event-pill` / `.gc-month-pill`):**
* Estilo Google Calendar con barra lateral de color (magenta UniCABA `#B43E8E`, ámbar `#F59E0B` para pendientes, índigo `#6366F1` para aulas virtuales).
* Encabezado compacto con hora de inicio y badge de aula (`bi bi-door-open` o `bi bi-camera-video` con fondo suave para `VIRTUAL`).
* Tipografía en `0.78rem` con elipsis para evitar desbordes y sombras sutiles.
* Compatibilidad completa con modo oscuro (`[data-bs-theme="dark"]`).
* **Modal Flotante Interactivo al Clic (`#eventPreviewModal`):**
* Reemplazo de la navegación directa por un popover flotante estilizado como Google Calendar.
* Banner artístico superior con accesos rápidos: editar (✏️), eliminar/cancelar (🗑️) y cerrar (✕).
* Fila principal con indicador cuadrado de color, título completo, código de materia y badge de estado/turno.
* Lista con íconos: 🕒 horario y fecha completa, 📍 aula/piso/sede (o enlace directo al Campus Virtual), 👤 carrera/comisión/docente, 📝 hito o notas.
* Botones de acción inferior: "Ver Ficha Completa" y "Cerrar".
* **Corrección de Modal de Eliminación de Roles ([app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html)):**
* Se trasladó el modal de confirmación de eliminación `#deleteRoleModal` fuera de la celda `<td>` y de la tabla `<table>`, situándolo a nivel raíz de la plantilla.
* Se corrigió el error visual donde el cuadro emergente temblaba, saltaba o se desplazaba por el recálculo de padding/scrollbar de Bootstrap en la celda de la tabla.
+3 -1
View File
@@ -527,7 +527,9 @@ def calendar_data():
'status': status_val,
'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or ''
'notes': reservation.notes or '',
'teacher': reservation.user.name if reservation.user else '',
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
}
})
+56 -27
View File
@@ -82,37 +82,16 @@
<i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %}
</a>
{% if not role.is_system %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
<button type="button" class="btn btn-outline-danger btn-delete-role"
data-role-id="{{ role.id }}"
data-role-name="{{ role.name }}"
data-action-url="{{ url_for('admin.role_delete', id=role.id) }}"
title="{% trans %}Eliminar Rol{% endtrans %}"
{% if role.users|length > 0 %}disabled{% endif %}>
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% if not role.is_system %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -121,4 +100,54 @@
</div>
</div>
</div>
<!-- Modal Global de Eliminación fuera de la tabla (evita desplazamientos visuales) -->
<div class="modal fade" id="deleteRoleModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center">
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
</div>
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Rol{% endtrans %}</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start py-3">
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong id="deleteRoleName" class="text-primary"></strong>?</p>
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form id="deleteRoleForm" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger px-3">
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Rol{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var deleteModalEl = document.getElementById('deleteRoleModal');
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
var deleteRoleName = document.getElementById('deleteRoleName');
var deleteRoleForm = document.getElementById('deleteRoleForm');
document.querySelectorAll('.btn-delete-role').forEach(function(btn) {
btn.addEventListener('click', function() {
var roleName = this.getAttribute('data-role-name');
var actionUrl = this.getAttribute('data-action-url');
if (deleteRoleName) deleteRoleName.textContent = roleName;
if (deleteRoleForm) deleteRoleForm.action = actionUrl;
if (deleteModal) deleteModal.show();
});
});
});
</script>
{% endblock %}
+588 -160
View File
@@ -158,68 +158,111 @@
</div>
</div>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-0 pb-0">
<!-- Modal Interactivo al Clic (Estilo Google Calendar Preview & Edición Rápida) -->
<div class="modal fade" id="eventPreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width: 490px;">
<div class="modal-content border-0 shadow-lg gc-modal-card">
<!-- Banner Artístico Superior con Botones Rápidos (Estilo Google Calendar) -->
<div class="gc-modal-banner" id="gcModalBanner">
<div class="d-flex justify-content-end align-items-center gap-2 p-2 gc-banner-actions">
<a id="btnPreviewEdit" href="#" class="btn btn-sm btn-icon text-white" title="{% trans %}Editar / Ver Ficha{% endtrans %}">
<i class="bi bi-pencil fs-6"></i>
</a>
{% if current_user.has_permission('reservations', 'read_write') %}
<form id="formPreviewCancel" method="POST" class="d-inline" onsubmit="return confirm('{% trans %}¿Confirma que desea cancelar esta reserva?{% endtrans %}');">
<button type="submit" class="btn btn-sm btn-icon text-white" title="{% trans %}Cancelar Reserva{% endtrans %}">
<i class="bi bi-trash fs-6"></i>
</button>
</form>
{% endif %}
<button type="button" class="btn btn-sm btn-icon text-white" data-bs-dismiss="modal" aria-label="Close">
<i class="bi bi-x-lg fs-6"></i>
</button>
</div>
</div>
<!-- Contenido de la Tarjeta Google Calendar -->
<div class="modal-body p-4 pt-3">
<!-- Fila Principal con Cuadrado de Color y Título en Grande -->
<div class="d-flex align-items-start gap-3 mb-3">
<div class="gc-color-box rounded-2 mt-1 shadow-sm" id="gcPreviewColorBox" style="width: 22px; height: 22px; background-color: #B43E8E; flex-shrink: 0;"></div>
<div class="flex-grow-1 min-w-0">
<h4 class="h5 fw-bold mb-1 text-break text-body" id="previewSubjectName">Nombre de Asignatura</h4>
<div class="d-flex align-items-center flex-wrap gap-2">
<span class="badge font-monospace bg-secondary-subtle text-secondary border" id="previewSubjectCode">ASIG001</span>
<span class="badge" id="previewStatusBadge">Confirmada</span>
<span class="badge bg-secondary-subtle text-secondary border d-none" id="previewShiftBadge">Turno Tarde</span>
</div>
</div>
</div>
<!-- Lista de Atributos con Iconografía Limpia -->
<div class="d-flex flex-column gap-3 ps-2 gc-info-list">
<!-- 🕒 Horario y Fecha -->
<div class="d-flex align-items-center gap-3">
<div class="gc-icon-circle text-muted">
<i class="bi bi-clock fs-5"></i>
</div>
<div>
<span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
<h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3" id="reservationDetails">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
</h6>
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
<p class="mb-2">
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
</p>
<p class="small text-muted mb-0" id="modalCareerWrap">
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
</p>
<div class="fw-semibold text-body" id="previewTimeRange">Lunes, 1 de septiembre · 17:30 a 21:30 hs</div>
<small class="text-muted" id="previewDateFull">Horario regular de cursada</small>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
</h6>
<p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
<p class="small text-muted mb-0" id="modalBuilding">-</p>
<!-- 📍 Espacio / Aula / Virtual -->
<div class="d-flex align-items-center gap-3">
<div class="gc-icon-circle text-danger" id="previewLocationIcon">
<i class="bi bi-geo-alt fs-5"></i>
</div>
<div class="flex-grow-1">
<div class="fw-semibold text-body" id="previewClassroom">Aula 304</div>
<small class="text-muted" id="previewBuilding">Piso 3 · Edificio Central (Perón 802)</small>
<div id="previewVirtualWrapper" class="d-none mt-1">
<span class="badge bg-primary-subtle text-primary border">
<i class="bi bi-camera-video me-1"></i>{% trans %}Campus Virtual UniCABA (Teams / Moodle){% endtrans %}
</span>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
</h6>
<p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
</div>
<!-- 👤 Docente, Carrera y Comisión -->
<div class="d-flex align-items-center gap-3">
<div class="gc-icon-circle text-primary">
<i class="bi bi-person fs-5"></i>
</div>
<div>
<div class="fw-semibold text-body" id="previewCareer">Licenciatura en Sistemas</div>
<small class="text-muted">
<span id="previewCommission">Comisión 1</span>
<span class="mx-1">·</span>
<span id="previewTeacher">Docente Asignado</span>
<span class="mx-1">·</span>
<span id="previewAttendees">35 estudiantes</span>
</small>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
</h6>
<p class="mb-1">
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
</p>
<small class="text-muted" id="modalAttendees">-</small>
<!-- 📝 Hito Académico / Detalle -->
<div class="d-flex align-items-start gap-3" id="previewNotesSection">
<div class="gc-icon-circle text-warning mt-1">
<i class="bi bi-flag fs-5"></i>
</div>
<div>
<div class="fw-semibold text-body" id="previewPurpose">Clase Regular</div>
<small class="text-muted text-break" id="previewNotes">Sin observaciones adicionales</small>
</div>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<a href="#" id="modalFullLink" class="btn btn-primary">
<!-- Footer de Acciones Rápidas -->
<div class="modal-footer border-0 p-3 pt-0 d-flex justify-content-between">
<a id="previewFullLink" href="#" class="btn btn-outline-primary btn-sm px-3">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">
{% trans %}Cerrar{% endtrans %}
</button>
</div>
</div>
</div>
@@ -230,16 +273,19 @@
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style>
/* FullCalendar Modern Overrides */
/* ==========================================================================
Google Calendar Aesthetic & FullCalendar Overrides (Light & Dark)
========================================================================== */
#calendar {
font-family: inherit;
min-height: 700px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
min-height: 750px;
}
.fc .fc-toolbar-title {
font-size: 1.25rem !important;
font-size: 1.35rem !important;
font-weight: 700 !important;
color: var(--text-primary);
letter-spacing: -0.2px;
}
.fc .fc-button-primary {
@@ -262,116 +308,366 @@
}
.fc-theme-standard td, .fc-theme-standard th {
border-color: var(--card-border, #E2E8F0) !important;
border-color: rgba(0, 0, 0, 0.08) !important;
}
[data-bs-theme="dark"] .fc-theme-standard td,
[data-bs-theme="dark"] .fc-theme-standard th {
border-color: rgba(255, 255, 255, 0.08) !important;
}
/* Column Headers (Google Calendar Style) */
.fc .fc-col-header-cell {
padding: 6px 0 !important;
background: transparent !important;
}
.fc .fc-col-header-cell-cushion {
color: var(--text-primary);
color: var(--bs-body-color, #3c4043);
font-weight: 600;
padding: 8px 4px !important;
font-size: 0.82rem;
text-decoration: none !important;
letter-spacing: 0.3px;
}
.fc-day-today .fc-col-header-cell-cushion {
color: #B43E8E !important;
font-weight: 700 !important;
}
/* Slot Lines & Label Height */
.fc .fc-timegrid-slot {
height: 28px !important;
}
.fc .fc-timegrid-slot-label-cushion {
color: var(--text-secondary, #64748B);
font-size: 0.78rem;
font-size: 0.72rem;
font-weight: 500;
}
/* Event Card Visual Hierarchy */
.fc-event {
cursor: pointer;
/* Red Now Indicator Line */
.fc .fc-timegrid-now-indicator-line {
border-color: #ea4335 !important;
border-width: 2px !important;
z-index: 12 !important;
}
.fc .fc-timegrid-now-indicator-arrow {
border-color: #ea4335 !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
}
/* Base Event Wrapper Overrides */
.fc-timegrid-event-harness {
margin: 0 !important;
}
.fc-timegrid-event, .fc-daygrid-event {
background: transparent !important;
border: none !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
box-shadow: none !important;
padding: 0 !important;
}
.fc-event:hover {
transform: translateY(-2px) scale(1.01);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
z-index: 10 !important;
.fc-event-main {
padding: 0 !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
}
.fc-event-confirmed {
background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
color: #FFFFFF !important;
}
.fc-event-pending {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
color: #FFFFFF !important;
}
.fc-event-custom-container {
padding: 5px 7px;
/* -------------------------------------------------------------
Google Calendar Event Pill (.gc-event-pill)
------------------------------------------------------------- */
.gc-event-pill {
position: relative;
display: flex;
height: 100%;
width: 100%;
min-height: 26px;
border-radius: 6px;
background: rgba(180, 62, 142, 0.12);
border: 1px solid rgba(180, 62, 142, 0.28);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
}
.gc-event-pill:hover {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
z-index: 20 !important;
filter: brightness(0.97);
}
[data-bs-theme="dark"] .gc-event-pill {
background: rgba(180, 62, 142, 0.22);
border-color: rgba(180, 62, 142, 0.45);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
[data-bs-theme="dark"] .gc-event-pill:hover {
filter: brightness(1.2);
}
/* Left Accent Stripe */
.gc-event-stripe {
width: 4px;
flex-shrink: 0;
height: 100%;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
/* Event Content Container */
.gc-event-content {
flex: 1;
min-width: 0;
padding: 2px 5px;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
}
.fc-event-time-pill {
font-size: 0.72rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 2px;
/* Event Header: Time + Room Badge */
.gc-event-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 3px;
line-height: 1.1;
margin-bottom: 2px;
}
/* High Visual Hierarchy for Subject Name */
.fc-event-subject-title {
font-size: 0.88rem !important;
font-weight: 700 !important;
line-height: 1.22 !important;
color: #FFFFFF !important;
letter-spacing: -0.15px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.gc-event-time {
font-size: 0.68rem;
font-weight: 700;
color: #7d2662;
white-space: nowrap;
}
[data-bs-theme="dark"] .gc-event-time {
color: #ffb1d9;
}
.gc-event-room {
font-size: 0.64rem !important;
font-weight: 600;
padding: 1px 4px !important;
border-radius: 4px;
background-color: rgba(180, 62, 142, 0.20) !important;
color: #63174c !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
margin-bottom: 4px;
max-width: 70%;
line-height: 1.2;
}
/* Secondary Badges */
.fc-event-badges {
[data-bs-theme="dark"] .gc-event-room {
background-color: rgba(255, 255, 255, 0.16) !important;
color: #ffffff !important;
}
.gc-event-room.gc-badge-virtual {
background-color: rgba(99, 102, 241, 0.20) !important;
color: #4338ca !important;
}
[data-bs-theme="dark"] .gc-event-room.gc-badge-virtual {
background-color: rgba(99, 102, 241, 0.35) !important;
color: #c7d2fe !important;
}
/* Subject Title (Compact with Ellipsis) */
.gc-event-title {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.18;
color: #202124;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.1px;
}
[data-bs-theme="dark"] .gc-event-title {
color: #f8f9fa;
}
/* Status Variants */
.gc-event-pill.status-pending {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.32);
}
.gc-event-pill.status-pending .gc-event-time {
color: #b45309;
}
[data-bs-theme="dark"] .gc-event-pill.status-pending {
background: rgba(245, 158, 11, 0.22);
}
[data-bs-theme="dark"] .gc-event-pill.status-pending .gc-event-time {
color: #fcd34d;
}
.gc-event-pill.status-virtual {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.30);
}
[data-bs-theme="dark"] .gc-event-pill.status-virtual {
background: rgba(99, 102, 241, 0.22);
}
/* -------------------------------------------------------------
Month View Pill (.gc-month-pill)
------------------------------------------------------------- */
.gc-month-pill {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
margin-top: auto;
gap: 4px;
padding: 1px 6px;
margin: 1px 2px;
border-radius: 4px;
background: rgba(180, 62, 142, 0.12);
color: #202124;
font-size: 0.72rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
border-left: 3px solid var(--event-color, #B43E8E);
transition: filter 0.12s ease;
}
.fc-badge-code {
background-color: rgba(0, 0, 0, 0.3) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 600 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
.gc-month-pill:hover {
filter: brightness(0.95);
}
.fc-badge-room {
background-color: rgba(255, 255, 255, 0.28) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
[data-bs-theme="dark"] .gc-month-pill {
background: rgba(180, 62, 142, 0.25);
color: #f1f3f4;
}
.gc-month-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.gc-month-time {
font-size: 0.68rem;
font-weight: 700;
color: #7d2662;
flex-shrink: 0;
}
[data-bs-theme="dark"] .gc-month-time {
color: #ffb1d9;
}
.gc-month-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.gc-month-room {
font-size: 0.64rem;
color: #5f6368;
flex-shrink: 0;
}
[data-bs-theme="dark"] .gc-month-room {
color: #9aa0a6;
}
/* FullCalendar "+X más" Popover estilo Google Calendar */
.fc-popover {
border-radius: 12px !important;
border: none !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22) !important;
background: var(--bs-body-bg, #ffffff) !important;
z-index: 1055 !important;
}
.fc-popover-header {
background: var(--bs-tertiary-bg, #f8f9fa) !important;
padding: 8px 12px !important;
border-top-left-radius: 12px !important;
border-top-right-radius: 12px !important;
font-weight: 700 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
font-size: 0.85rem !important;
}
.fc-more-link {
font-size: 0.72rem !important;
font-weight: 600 !important;
color: #B43E8E !important;
text-decoration: none !important;
padding: 1px 4px !important;
border-radius: 3px !important;
}
.fc-more-link:hover {
background-color: rgba(180, 62, 142, 0.12) !important;
}
/* -------------------------------------------------------------
Google Calendar Modal Card (.gc-modal-card)
------------------------------------------------------------- */
.gc-modal-card {
border-radius: 16px !important;
overflow: hidden !important;
}
.gc-modal-banner {
height: 80px;
background: linear-gradient(135deg, #B43E8E 0%, #832765 60%, #4338CA 100%);
position: relative;
background-size: cover;
}
.gc-modal-banner.banner-virtual {
background: linear-gradient(135deg, #4338CA 0%, #2563EB 60%, #0284C7 100%);
}
.gc-modal-banner.banner-pending {
background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%);
}
.gc-banner-actions .btn-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.28);
border: none;
transition: background 0.15s ease, transform 0.15s ease;
}
/* Month View Optimization */
.fc-daygrid-event .fc-event-custom-container {
padding: 2px 4px;
.gc-banner-actions .btn-icon:hover {
background: rgba(0, 0, 0, 0.50);
transform: scale(1.05);
}
.fc-daygrid-event .fc-event-subject-title {
font-size: 0.78rem !important;
-webkit-line-clamp: 1;
.gc-icon-circle {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bs-tertiary-bg, #f1f3f4);
flex-shrink: 0;
}
</style>
{% endblock %}
@@ -394,6 +690,9 @@ document.addEventListener('DOMContentLoaded', function() {
var activeBadge = document.getElementById('activeFilterBadge');
var countEl = document.getElementById('eventCountNumber');
var days = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
var months = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
function updateFilterSummaryBadge() {
var parts = [];
if (filterRoom.value && filterRoom.selectedOptions[0]) {
@@ -439,10 +738,21 @@ document.addEventListener('DOMContentLoaded', function() {
minute: '2-digit',
hour12: false
},
slotMinTime: '07:30:00',
slotMaxTime: '22:30:00',
slotMinTime: '07:00:00',
slotMaxTime: '23:00:00',
slotDuration: '00:30:00',
slotLabelInterval: '01:00:00',
nowIndicator: true,
height: 'auto',
/* Manejo de Eventos Simultáneos estilo Google Calendar */
dayMaxEvents: 3,
dayMaxEventRows: true,
eventMaxStack: 3,
moreLinkClick: 'popover',
slotEventOverlap: true,
expandRows: true,
stickyHeaderDates: true,
/* Dynamic Server-Side Filtered Events */
events: function(info, successCallback, failureCallback) {
@@ -476,64 +786,182 @@ document.addEventListener('DOMContentLoaded', function() {
});
},
/* Custom Card Render with Visual Hierarchy */
/* Diseño de Tarjetas de Evento Estilo Google Calendar */
eventContent: function(arg) {
var ext = arg.event.extendedProps || {};
var subjectName = ext.subject_name || arg.event.title || '';
var subjectCode = ext.subject_code || '';
var classroom = ext.classroom || '';
var isVirtual = ext.is_virtual || false;
var timeText = arg.timeText || '';
var status = (ext.status || 'CONFIRMED').toUpperCase();
var isMonthView = arg.view.type === 'dayGridMonth';
var container = document.createElement('div');
container.className = 'fc-event-custom-container';
var eventColor = '#B43E8E';
if (status === 'PENDING') {
eventColor = '#F59E0B';
} else if (isVirtual) {
eventColor = '#6366F1';
}
var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
var badgesHtml = `
<div class="fc-event-badges">
${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
// Vista de Mes: Píldora Google Calendar Compacta
if (isMonthView) {
var monthPill = document.createElement('div');
monthPill.className = 'gc-month-pill ' + (isVirtual ? 'gc-month-virtual' : '');
monthPill.style.setProperty('--event-color', eventColor);
monthPill.title = subjectName + (classroom ? ' · ' + classroom : '');
var startHour = timeText ? timeText.split(' - ')[0] : '';
monthPill.innerHTML = `
<span class="gc-month-dot" style="background-color: ${eventColor};"></span>
${startHour ? `<span class="gc-month-time">${startHour}</span>` : ''}
<span class="gc-month-title">${subjectName}</span>
<span class="gc-month-room">${isVirtual ? '<i class="bi bi-camera-video"></i>' : classroom}</span>
`;
return { domNodes: [monthPill] };
}
// Vista Semanal / Diaria: Tarjeta Píldora con Franja Lateral y Acento
var pill = document.createElement('div');
pill.className = 'gc-event-pill ' + (status === 'PENDING' ? 'status-pending' : '') + (isVirtual ? ' status-virtual' : '');
pill.style.setProperty('--event-color', eventColor);
pill.title = subjectName + (classroom ? ' - ' + classroom : '');
var stripe = `<div class="gc-event-stripe" style="background-color: ${eventColor};"></div>`;
var startHourOnly = timeText ? timeText.split(' - ')[0] : '';
var roomBadge = classroom ? `
<span class="gc-event-room badge ${isVirtual ? 'gc-badge-virtual' : ''}">
<i class="bi ${isVirtual ? 'bi-camera-video' : 'bi-door-open'} me-1"></i>${classroom}
</span>` : '';
var header = `
<div class="gc-event-header">
<span class="gc-event-time">${startHourOnly}</span>
${roomBadge}
</div>`;
var title = `<div class="gc-event-title" title="${subjectName}">${subjectName}</div>`;
container.innerHTML = timeHtml + titleHtml + badgesHtml;
return { domNodes: [container] };
pill.innerHTML = stripe + `<div class="gc-event-content">${header}${title}</div>`;
return { domNodes: [pill] };
},
/* Interactive Instant Modal */
/* Intercepción de Clic: Popover / Modal Flotante de Vista Previa Google Calendar */
eventClick: function(info) {
info.jsEvent.preventDefault();
var ext = info.event.extendedProps || {};
var subjectName = ext.subject_name || info.event.title || '';
var subjectName = ext.subject_name || info.event.title || 'Asignatura';
var subjectCode = ext.subject_code || '-';
var classroom = ext.classroom || 'Sin Aula';
var classroom = ext.classroom || 'Sin Aula Asignada';
var building = ext.building || 'Edificio Central';
var career = ext.career || 'General';
var status = ext.status || 'CONFIRMED';
var career = ext.career || 'Tecnicatura / Licenciatura UniCABA';
var status = (ext.status || 'CONFIRMED').toUpperCase();
var isVirtual = ext.is_virtual || false;
var teacher = ext.teacher || 'Docente Responsable';
var commission = ext.commission ? `Comisión ${ext.commission}` : 'Comisión Regular';
var attendees = ext.attendees || 0;
var purpose = ext.purpose || 'Clase Regular';
var notes = ext.notes || 'Sin observaciones registradas';
var shift = ext.shift || '';
var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
document.getElementById('modalSubjectTitle').textContent = subjectName;
document.getElementById('modalSubjectName').textContent = subjectName;
document.getElementById('modalSubjectCode').textContent = subjectCode;
document.getElementById('modalCareer').textContent = career;
document.getElementById('modalClassroom').textContent = classroom;
document.getElementById('modalBuilding').textContent = building;
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
document.getElementById('modalStatus').textContent = status;
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
if (info.event.url) {
var btn = document.getElementById('modalFullLink');
btn.href = info.event.url;
btn.classList.remove('d-none');
} else {
document.getElementById('modalFullLink').classList.add('d-none');
var eventColor = '#B43E8E';
var bannerClass = 'gc-modal-banner';
if (status === 'PENDING') {
eventColor = '#F59E0B';
bannerClass += ' banner-pending';
} else if (isVirtual) {
eventColor = '#6366F1';
bannerClass += ' banner-virtual';
}
var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
// Actualizar Banner y Color Box
var bannerEl = document.getElementById('gcModalBanner');
if (bannerEl) bannerEl.className = bannerClass;
var colorBox = document.getElementById('gcPreviewColorBox');
if (colorBox) colorBox.style.backgroundColor = eventColor;
// Textos Principales
document.getElementById('previewSubjectName').textContent = subjectName;
document.getElementById('previewSubjectCode').textContent = subjectCode;
// Badges
var statusBadge = document.getElementById('previewStatusBadge');
if (statusBadge) {
if (status === 'CONFIRMED') {
statusBadge.className = 'badge bg-success-subtle text-success border border-success-subtle';
statusBadge.textContent = 'Confirmada';
} else if (status === 'PENDING') {
statusBadge.className = 'badge bg-warning-subtle text-warning border border-warning-subtle';
statusBadge.textContent = 'Pendiente';
} else {
statusBadge.className = 'badge bg-secondary-subtle text-secondary border';
statusBadge.textContent = status;
}
}
var shiftBadge = document.getElementById('previewShiftBadge');
if (shiftBadge) {
if (shift) {
shiftBadge.textContent = 'Turno ' + shift;
shiftBadge.classList.remove('d-none');
} else {
shiftBadge.classList.add('d-none');
}
}
// Horario Formateado
if (info.event.start) {
var start = info.event.start;
var end = info.event.end;
var dayName = days[start.getDay()];
var dayNum = start.getDate();
var monthName = months[start.getMonth()];
var startTime = start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false});
var endTime = end ? end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
document.getElementById('previewTimeRange').textContent = `${dayName}, ${dayNum} de ${monthName} · ${startTime}${endTime ? ' a ' + endTime + ' hs' : ''}`;
document.getElementById('previewDateFull').textContent = `Horario académico registrado`;
}
// Espacio Asignado
var locIcon = document.getElementById('previewLocationIcon');
var virtualWrap = document.getElementById('previewVirtualWrapper');
if (isVirtual) {
if (locIcon) locIcon.innerHTML = '<i class="bi bi-camera-video fs-5 text-primary"></i>';
document.getElementById('previewClassroom').textContent = 'Campus Virtual';
document.getElementById('previewBuilding').textContent = 'Entorno Digital UniCABA';
if (virtualWrap) virtualWrap.classList.remove('d-none');
} else {
if (locIcon) locIcon.innerHTML = '<i class="bi bi-geo-alt fs-5 text-danger"></i>';
document.getElementById('previewClassroom').textContent = classroom;
document.getElementById('previewBuilding').textContent = `${ext.floor !== null && ext.floor !== undefined ? (ext.floor === 0 ? 'Planta Baja' : 'Piso ' + ext.floor) + ' · ' : ''}${building}`;
if (virtualWrap) virtualWrap.classList.add('d-none');
}
// Carrera, Comisión, Docente y Asistentes
document.getElementById('previewCareer').textContent = career;
document.getElementById('previewCommission').textContent = commission;
document.getElementById('previewTeacher').textContent = teacher;
document.getElementById('previewAttendees').textContent = `${attendees} estudiantes esperados`;
// Hito / Notas
document.getElementById('previewPurpose').textContent = purpose;
document.getElementById('previewNotes').textContent = notes;
// Enlaces y Acciones
var detailUrl = info.event.url || ('/schedule/reservation/' + info.event.id + '/view');
var fullLink = document.getElementById('previewFullLink');
if (fullLink) fullLink.href = detailUrl;
var editBtn = document.getElementById('btnPreviewEdit');
if (editBtn) editBtn.href = detailUrl;
var cancelForm = document.getElementById('formPreviewCancel');
if (cancelForm) {
cancelForm.action = ext.cancel_url || ('/schedule/cancel/' + info.event.id);
}
// Mostrar Modal Google Calendar
var modal = new bootstrap.Modal(document.getElementById('eventPreviewModal'));
modal.show();
}
});