feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
@@ -0,0 +1,304 @@
|
||||
/* Custom Enhancements for Edu-Space Admin */
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table Hover */
|
||||
.table:hover tbody tr:hover {
|
||||
background-color: rgba(var(--brand-primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
/* Status Badge Colors */
|
||||
.bg-confirmed-custom {
|
||||
background-color: #10B981;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.bg-pending-custom {
|
||||
background-color: #F59E0B;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.bg-cancelled-custom {
|
||||
background-color: #EF4444;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(var(--brand-primary-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--brand-primary);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Calendar Customizations */
|
||||
.fc-event {
|
||||
border-radius: 8px;
|
||||
border: none !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
margin: 1px 0 !important;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Cards */
|
||||
.stat-card {
|
||||
background: var(--gradient-primary);
|
||||
color: #FFFFFF;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 20px rgba(var(--brand-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.stat-card-primary { background: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%); }
|
||||
.stat-card-success { background: linear-gradient(135deg, #059669 0%, #10B981 100%); }
|
||||
.stat-card-info { background: linear-gradient(135deg, #682CAD 0%, #8B5CF6 100%); }
|
||||
.stat-card-warning { background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%); }
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.quick-action-btn {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Empty State Styles */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Notification Toast enhancements */
|
||||
.toast {
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Pagination Styles */
|
||||
.pagination .page-link {
|
||||
border-radius: 8px;
|
||||
margin: 0 3px;
|
||||
border: 1px solid var(--card-border);
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background: var(--gradient-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Shortcuts */
|
||||
.dashboard-stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
.dashboard-stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.1) !important;
|
||||
border-color: rgba(30, 77, 140, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.navbar, .btn, .pagination, .dropdown {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #DDD !important;
|
||||
box-shadow: none !important;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #FFFFFF !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* View Switcher Controls */
|
||||
.view-switcher-group {
|
||||
flex-direction: row !important;
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.view-switcher-group .btn {
|
||||
margin-bottom: 0 !important;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.view-switcher-group .btn.active {
|
||||
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.reservation-card {
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.reservation-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
border-color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
/* Role Badges & Pills */
|
||||
.badge-admin {
|
||||
background: linear-gradient(135deg, #7C3AED 0%, #B43E8E 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.badge-bedelia {
|
||||
background: linear-gradient(135deg, #D97706 0%, #B45309 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.badge-docente {
|
||||
background: linear-gradient(135deg, #0284C7 0%, #0369A1 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.badge-alumno {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
letter-spacing: 0.3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Role Dashboard Hero & Cards */
|
||||
.dashboard-role-banner {
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||
border: 1px solid rgba(180, 62, 142, 0.18);
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .dashboard-role-banner {
|
||||
background: linear-gradient(135deg, rgba(180, 62, 142, 0.15) 0%, rgba(42, 27, 61, 0.5) 100%);
|
||||
border-color: rgba(180, 62, 142, 0.25);
|
||||
}
|
||||
|
||||
.role-view-selector .btn {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.role-view-selector .btn.active {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.action-menu-card {
|
||||
border-radius: 0.85rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.action-menu-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.08) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .action-menu-card {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .action-menu-card:hover {
|
||||
border-color: var(--brand-primary-tint) !important;
|
||||
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.quick-icon-circle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Sistema de Diseño & Tokens de Color - Edu-Space Admin
|
||||
* Paleta Institucional Magenta / Uva y Soporte Claro/Oscuro
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Brand Primary (Magenta / Uva) --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-rgb: 180, 62, 142;
|
||||
--brand-primary-shade: #9E367D;
|
||||
--brand-primary-tint: #BC5199;
|
||||
--brand-primary-contrast: #FFFFFF;
|
||||
|
||||
/* --- Accent Colors (Modo Claro) --- */
|
||||
--accent-purple: #682CAD;
|
||||
--accent-purple-rgb: 104, 44, 173;
|
||||
--secondary-magenta: #7B2049;
|
||||
--secondary-magenta-rgb: 123, 32, 73;
|
||||
|
||||
/* --- Global Surface & Text (Modo Claro) --- */
|
||||
--app-bg: #F8FAFC;
|
||||
--card-bg: #FFFFFF;
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.3);
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
|
||||
|
||||
--text-primary: #1E293B;
|
||||
--text-secondary: #64748B;
|
||||
--text-muted: #94A3B8;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--navbar-text: #FFFFFF;
|
||||
--navbar-border: transparent;
|
||||
--navbar-shadow: 0 4px 20px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--input-bg: #FFFFFF;
|
||||
--input-border: #CBD5E1;
|
||||
--input-text: #1E293B;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.25);
|
||||
|
||||
/* --- Distinctive Gradient --- */
|
||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--gradient-primary-shadow: 0 8px 24px rgba(180, 62, 142, 0.25);
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||
|
||||
/* --- Badges (Modo Claro) --- */
|
||||
--badge-primary-bg: #B43E8E;
|
||||
--badge-primary-text: #FFFFFF;
|
||||
--badge-accent-bg: #682CAD;
|
||||
--badge-accent-text: #FFFFFF;
|
||||
--badge-magenta-bg: #7B2049;
|
||||
--badge-magenta-text: #FFFFFF;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
MODO OSCURO ([data-bs-theme="dark"])
|
||||
======================================================= */
|
||||
[data-bs-theme="dark"] {
|
||||
/* --- Brand Tweaks for Dark Theme --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-tint: #C95FA6;
|
||||
|
||||
/* --- Accent Colors (Modo Oscuro) --- */
|
||||
--accent-purple: #AE7FFF;
|
||||
--secondary-magenta: #FF8AB6;
|
||||
|
||||
/* --- Surfaces & Contrast in Dark Mode --- */
|
||||
--app-bg: #121622;
|
||||
--card-bg: #1E2442;
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.5);
|
||||
--card-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
|
||||
--card-hover-shadow: 0 12px 30px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--text-primary: #E2E8F0;
|
||||
--text-secondary: #94A3B8;
|
||||
--text-muted: #64748B;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #24142D 0%, #161A2E 100%);
|
||||
--navbar-text: #F8FAFC;
|
||||
--navbar-border: 1px solid rgba(180, 62, 142, 0.25);
|
||||
--navbar-shadow: 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--input-bg: #161B30;
|
||||
--input-border: #2D3748;
|
||||
--input-text: #E2E8F0;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.35);
|
||||
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.18) 0%, rgba(30, 36, 66, 0.8) 100%);
|
||||
|
||||
/* --- Badges & Chips (Translucent in Dark Mode) --- */
|
||||
--badge-primary-bg: rgba(180, 62, 142, 0.25);
|
||||
--badge-primary-text: #FF8AB6;
|
||||
--badge-primary-border: 1px solid rgba(180, 62, 142, 0.45);
|
||||
|
||||
--badge-accent-bg: rgba(104, 44, 173, 0.25);
|
||||
--badge-accent-text: #AE7FFF;
|
||||
--badge-accent-border: 1px solid rgba(174, 127, 255, 0.45);
|
||||
|
||||
--badge-magenta-bg: rgba(123, 32, 73, 0.25);
|
||||
--badge-magenta-text: #FF96B4;
|
||||
--badge-magenta-border: 1px solid rgba(255, 138, 182, 0.4);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
ELEMENTOS GLOBALES & TRANSICIONES
|
||||
======================================================= */
|
||||
body {
|
||||
background-color: var(--app-bg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar-app {
|
||||
background: var(--navbar-bg) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: var(--navbar-border);
|
||||
box-shadow: var(--navbar-shadow);
|
||||
padding: 0.65rem 1.25rem;
|
||||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar-app .navbar-brand {
|
||||
color: var(--navbar-text) !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-app .nav-link {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease, transform 0.15s ease;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) and (min-width: 992px) {
|
||||
.navbar-app {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
.navbar-app .nav-link {
|
||||
padding: 0.4rem 0.5rem !important;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.navbar-app .navbar-brand span {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-app .nav-link:hover,
|
||||
.navbar-app .nav-link:focus {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.navbar-app .nav-link.active {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.navbar-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
|
||||
/* En Modo Claro el navbar es magenta (#B43E8E), aplicamos color blanco al isotipo */
|
||||
[data-bs-theme="light"] .navbar-logo,
|
||||
:root:not([data-bs-theme="dark"]) .navbar-logo {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* En Modo Oscuro se mantiene el isotipo con su tono natural */
|
||||
[data-bs-theme="dark"] .navbar-logo {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* User Avatar Component */
|
||||
.user-avatar-small {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #B43E8E, #682CAD);
|
||||
color: #FFFFFF;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
/* Theme Toggle Button */
|
||||
.theme-toggle-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #FFFFFF;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
transform: rotate(15deg) scale(1.05);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Language Switcher Button (Alto Contraste en Navbar y Modo Claro) */
|
||||
.navbar-app .language-switcher .btn {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.16) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.32) !important;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .btn:hover,
|
||||
.navbar-app .language-switcher .btn:focus,
|
||||
.navbar-app .language-switcher .dropdown-toggle.show {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.28) !important;
|
||||
border-color: rgba(255, 255, 255, 0.5) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .dropdown-menu {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Dropdown Item Active (Gradiente Magenta Institucional en lugar de Azul) */
|
||||
.dropdown-item {
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
background: var(--gradient-primary) !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active i,
|
||||
.dropdown-item:active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
CARDS & CONTENEDORES
|
||||
======================================================= */
|
||||
.card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--card-hover-border) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: transparent !important;
|
||||
border-bottom: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: rgba(0, 0, 0, 0.02) !important;
|
||||
border-top: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .card-footer {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Hero Banners */
|
||||
.hero-banner {
|
||||
background: var(--gradient-hero) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BOTONES Y ACCIONES
|
||||
======================================================= */
|
||||
.btn {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
color: var(--brand-primary-contrast) !important;
|
||||
box-shadow: 0 4px 14px rgba(var(--brand-primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
box-shadow: 0 6px 18px rgba(var(--brand-primary-rgb), 0.45);
|
||||
transform: translateY(-1px);
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary:focus {
|
||||
background: var(--gradient-primary) !important;
|
||||
border-color: var(--brand-primary-shade) !important;
|
||||
color: #FFFFFF !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* Distinctive Text Accent */
|
||||
.text-primary,
|
||||
.text-brand {
|
||||
color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .text-primary,
|
||||
[data-bs-theme="dark"] .text-brand {
|
||||
color: var(--brand-primary-tint) !important;
|
||||
}
|
||||
|
||||
.text-accent-purple {
|
||||
color: var(--accent-purple) !important;
|
||||
}
|
||||
|
||||
.text-secondary-magenta {
|
||||
color: var(--secondary-magenta) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BADGES & CHIPS
|
||||
======================================================= */
|
||||
.badge {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
padding: 0.4em 0.75em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.badge-brand {
|
||||
background-color: var(--badge-primary-bg) !important;
|
||||
color: var(--badge-primary-text) !important;
|
||||
border: var(--badge-primary-border, none);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background-color: var(--badge-accent-bg) !important;
|
||||
color: var(--badge-accent-text) !important;
|
||||
border: var(--badge-accent-border, none);
|
||||
}
|
||||
|
||||
.badge-schedule {
|
||||
background-color: var(--badge-magenta-bg) !important;
|
||||
color: var(--badge-magenta-text) !important;
|
||||
border: var(--badge-magenta-border, none);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORMULARIOS & INPUTS
|
||||
======================================================= */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--input-bg) !important;
|
||||
border-color: var(--input-border) !important;
|
||||
color: var(--input-text) !important;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--input-focus-border) !important;
|
||||
box-shadow: var(--input-focus-shadow) !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
TARJETA DE LOGIN (.auth-card)
|
||||
======================================================= */
|
||||
.auth-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-left: 6px solid var(--brand-primary) !important;
|
||||
border-radius: 18px !important;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .auth-card {
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 25px rgba(180, 62, 142, 0.15);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
padding: 2.25rem 2rem 1.25rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
max-height: 52px;
|
||||
max-width: 220px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
margin-bottom: 1rem;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.auth-controls {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Controles de Login en Modo Claro (Tarjeta Blanca) */
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================
|
||||
FULLCALENDAR OVERRIDES
|
||||
======================================================= */
|
||||
.fc {
|
||||
--fc-border-color: var(--card-border);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: rgba(0, 0, 0, 0.02);
|
||||
--fc-today-bg-color: rgba(var(--brand-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fc {
|
||||
--fc-neutral-bg-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border-radius: 8px !important;
|
||||
border: none !important;
|
||||
padding: 3px 8px !important;
|
||||
font-size: 0.8rem !important;
|
||||
font-weight: 500 !important;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.fc-event:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.3) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:hover {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:disabled {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORTIGATE-STYLE RBAC PERMISSIONS MATRIX
|
||||
======================================================= */
|
||||
.fg-permission-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fg-permission-card .card-header {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.9rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-permission-card .card-header {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.fg-table {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fg-table th {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.85rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-table th {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fg-table td {
|
||||
padding: 0.85rem 1.25rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fg-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.fg-table tbody tr:hover {
|
||||
background-color: rgba(180, 62, 142, 0.04);
|
||||
}
|
||||
|
||||
/* FortiGate Segmented Control */
|
||||
.fg-segmented-control {
|
||||
display: inline-flex;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-segmented-control {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: #1A1F36;
|
||||
}
|
||||
|
||||
.fg-seg-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-right: 1px solid #ced4da;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn {
|
||||
color: #CBD5E1;
|
||||
border-right-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fg-seg-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Estado Activo FortiGate (Verde Esmeralda #2D9F6F) */
|
||||
.fg-seg-btn.active {
|
||||
background-color: #2D9F6F !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fg-seg-btn.active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Set All Dropdown Button */
|
||||
.btn-fg-set-all {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ced4da;
|
||||
background: #FFFFFF;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-fg-set-all {
|
||||
background: #1E2442;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,491 @@
|
||||
class GeneticAlgorithmManager {
|
||||
constructor() {
|
||||
this.apiBaseUrl = '/api/genetic';
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Initialize any UI elements if needed
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.initializeUI();
|
||||
});
|
||||
}
|
||||
|
||||
initializeUI() {
|
||||
// Set up event listeners for genetic algorithm buttons
|
||||
const optimizeBtn = document.getElementById('optimize-assignments-btn');
|
||||
const previewBtn = document.getElementById('preview-optimization-btn');
|
||||
const applyBtn = document.getElementById('apply-optimization-btn');
|
||||
|
||||
if (optimizeBtn) {
|
||||
optimizeBtn.addEventListener('click', () => this.handleOptimizeReservations());
|
||||
}
|
||||
|
||||
if (previewBtn) {
|
||||
previewBtn.addEventListener('click', () => this.handlePreviewOptimization());
|
||||
}
|
||||
|
||||
if (applyBtn) {
|
||||
applyBtn.addEventListener('click', () => this.handleApplyOptimization());
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeReservations(commissionIds, startDate = null, endDate = null) {
|
||||
try {
|
||||
const requestBody = {
|
||||
commission_ids: commissionIds
|
||||
};
|
||||
|
||||
if (startDate) {
|
||||
requestBody.start_date = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
requestBody.end_date = endDate;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiBaseUrl}/optimize`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Optimization failed');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error optimizing reservations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async previewOptimization(commissionIds) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/preview-optimization`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
commission_ids: commissionIds,
|
||||
dry_run: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Preview failed');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error previewing optimization:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async applyOptimization(reservations) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/apply-optimization`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reservations: reservations
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to apply optimization');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error applying optimization:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAlgorithmStatus() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/algorithm-status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCSRFToken()
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to get algorithm status');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error getting algorithm status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handleOptimizeReservations() {
|
||||
try {
|
||||
// Get selected commissions from UI
|
||||
const commissionIds = this.getSelectedCommissionIds();
|
||||
|
||||
if (commissionIds.length === 0) {
|
||||
this.showAlert('Please select at least one commission to optimize.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading(true);
|
||||
|
||||
const result = await this.optimizeReservations(commissionIds);
|
||||
|
||||
if (result.success) {
|
||||
this.displayOptimizationResults(result.data);
|
||||
this.showAlert('Optimization completed successfully!', 'success');
|
||||
} else {
|
||||
this.showAlert('Optimization failed: ' + (result.error || 'Unknown error'), 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert('Error during optimization: ' + error.message, 'danger');
|
||||
} finally {
|
||||
this.showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePreviewOptimization() {
|
||||
try {
|
||||
const commissionIds = this.getSelectedCommissionIds();
|
||||
|
||||
if (commissionIds.length === 0) {
|
||||
this.showAlert('Please select at least one commission to preview.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading(true);
|
||||
|
||||
const result = await this.previewOptimization(commissionIds);
|
||||
|
||||
if (result.success) {
|
||||
this.displayPreviewResults(result.data);
|
||||
this.showAlert('Preview generated successfully!', 'success');
|
||||
} else {
|
||||
this.showAlert('Preview failed: ' + (result.error || 'Unknown error'), 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert('Error during preview: ' + error.message, 'danger');
|
||||
} finally {
|
||||
this.showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async handleApplyOptimization() {
|
||||
try {
|
||||
const pendingReservations = this.getPendingReservations();
|
||||
|
||||
if (!pendingReservations || pendingReservations.length === 0) {
|
||||
this.showAlert('No pending reservations to apply.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to apply these reservations? This will create actual reservation records.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading(true);
|
||||
|
||||
const result = await this.applyOptimization(pendingReservations);
|
||||
|
||||
if (result.success) {
|
||||
this.showAlert(`Successfully applied ${result.data.applied_count} reservations!`, 'success');
|
||||
this.clearPendingReservations();
|
||||
// Redirect to reservations page
|
||||
window.location.href = '/schedule';
|
||||
} else {
|
||||
this.showAlert('Failed to apply reservations: ' + (result.error || 'Unknown error'), 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert('Error applying reservations: ' + error.message, 'danger');
|
||||
} finally {
|
||||
this.showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedCommissionIds() {
|
||||
const checkboxes = document.querySelectorAll('input[name="commission_ids"]:checked');
|
||||
return Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||
}
|
||||
|
||||
getPendingReservations() {
|
||||
// Try to get from localStorage or a hidden form field
|
||||
const stored = localStorage.getItem('pending_optimizations');
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
}
|
||||
|
||||
storePendingReservations(reservations) {
|
||||
localStorage.setItem('pending_optimizations', JSON.stringify(reservations));
|
||||
}
|
||||
|
||||
clearPendingReservations() {
|
||||
localStorage.removeItem('pending_optimizations');
|
||||
}
|
||||
|
||||
displayOptimizationResults(data) {
|
||||
// Store the results for potential application
|
||||
this.storePendingReservations(data.reservations);
|
||||
|
||||
// Create results display
|
||||
const resultsContainer = document.getElementById('optimization-results');
|
||||
if (!resultsContainer) return;
|
||||
|
||||
const scorePercentage = (data.fitness_score * 100).toFixed(1);
|
||||
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">Optimization Results</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary">${data.assigned_reservations}</h4>
|
||||
<p class="mb-0">Reservations Assigned</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info">${scorePercentage}%</h4>
|
||||
<p class="mb-0">Fitness Score</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning">${data.total_requests}</h4>
|
||||
<p class="mb-0">Total Requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${data.conflicts.length > 0 ? `
|
||||
<div class="alert alert-warning">
|
||||
<strong>Warning:</strong> ${data.conflicts.length} potential conflicts detected.
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary" onclick="geneticManager.viewReservationDetails()">
|
||||
View Details
|
||||
</button>
|
||||
<button class="btn btn-success" onclick="geneticManager.handleApplyOptimization()">
|
||||
Apply Reservations
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="geneticManager.clearResults()">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
displayPreviewResults(data) {
|
||||
const previewContainer = document.getElementById('preview-results');
|
||||
if (!previewContainer) return;
|
||||
|
||||
const commissionsHtml = data.commission_details.map(commission => `
|
||||
<tr>
|
||||
<td>${commission.code}</td>
|
||||
<td>${commission.name}</td>
|
||||
<td>${commission.current_students}/${commission.max_students}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const optimization = data.optimization_result;
|
||||
|
||||
previewContainer.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">Optimization Preview</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h6>Commissions to Optimize</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Students</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${commissionsHtml}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Expected Results</h6>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>Reservations Created:</span>
|
||||
<strong>${optimization.assigned_reservations}</strong>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>Fitness Score:</span>
|
||||
<strong>${(optimization.fitness_score * 100).toFixed(1)}%</strong>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>Conflicts:</span>
|
||||
<strong class="${optimization.conflicts.length > 0 ? 'text-danger' : 'text-success'}">
|
||||
${optimization.conflicts.length}
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary" onclick="geneticManager.handleOptimizeReservations()">
|
||||
Run Full Optimization
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="geneticManager.clearPreview()">
|
||||
Close Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
viewReservationDetails() {
|
||||
const reservations = this.getPendingReservations();
|
||||
if (!reservations || reservations.length === 0) {
|
||||
this.showAlert('No reservation details available.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create modal or redirect to detailed view
|
||||
const detailsHtml = reservations.map((res, index) => `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td>${res.purpose}</td>
|
||||
<td>Classroom ${res.classroom_id}</td>
|
||||
<td>${new Date(res.start_time).toLocaleString()}</td>
|
||||
<td>${new Date(res.end_time).toLocaleString()}</td>
|
||||
<td>${res.expected_attendees}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const modal = document.getElementById('detailsModal');
|
||||
if (modal) {
|
||||
modal.querySelector('.modal-body').innerHTML = `
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Purpose</th>
|
||||
<th>Classroom</th>
|
||||
<th>Start Time</th>
|
||||
<th>End Time</th>
|
||||
<th>Attendees</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${detailsHtml}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const bootstrapModal = new bootstrap.Modal(modal);
|
||||
bootstrapModal.show();
|
||||
}
|
||||
}
|
||||
|
||||
clearResults() {
|
||||
const resultsContainer = document.getElementById('optimization-results');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = '';
|
||||
}
|
||||
this.clearPendingReservations();
|
||||
}
|
||||
|
||||
clearPreview() {
|
||||
const previewContainer = document.getElementById('preview-results');
|
||||
if (previewContainer) {
|
||||
previewContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
showLoading(show) {
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showAlert(message, type = 'info') {
|
||||
// Create and show alert
|
||||
const alertContainer = document.getElementById('alert-container');
|
||||
if (!alertContainer) return;
|
||||
|
||||
const alertHtml = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
alertContainer.insertAdjacentHTML('beforeend', alertHtml);
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(() => {
|
||||
const alert = alertContainer.lastElementChild;
|
||||
if (alert) {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
getCSRFToken() {
|
||||
// Get CSRF token from meta tag or cookie
|
||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaTag) {
|
||||
return metaTag.getAttribute('content');
|
||||
}
|
||||
|
||||
// Fallback to cookie
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'csrf_token') {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the genetic algorithm manager
|
||||
const geneticManager = new GeneticAlgorithmManager();
|
||||
|
||||
// Export for global access
|
||||
window.geneticManager = geneticManager;
|
||||
@@ -0,0 +1,303 @@
|
||||
// Main JavaScript file for Edu-Space Admin
|
||||
|
||||
$(document).ready(function() {
|
||||
// Initialize tooltips
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
// Initialize popovers
|
||||
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
|
||||
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
|
||||
return new bootstrap.Popover(popoverTriggerEl);
|
||||
});
|
||||
|
||||
// Auto-hide alerts after 5 seconds
|
||||
setTimeout(function() {
|
||||
$('.alert.fade').alert('close');
|
||||
}, 5000);
|
||||
|
||||
// Confirm delete actions
|
||||
$('.delete-confirm').on('click', function(e) {
|
||||
if (!confirm('Are you sure you want to delete this item? This action cannot be undone.')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Form validation enhancements
|
||||
$('form').on('submit', function() {
|
||||
var form = $(this);
|
||||
var submitBtn = form.find('button[type="submit"]');
|
||||
|
||||
// Show loading state
|
||||
submitBtn.prop('disabled', true);
|
||||
var originalText = submitBtn.html();
|
||||
submitBtn.html('<i class="bi bi-hourglass-split me-2"></i>Loading...');
|
||||
|
||||
// Re-enable after 10 seconds in case of error
|
||||
setTimeout(function() {
|
||||
submitBtn.prop('disabled', false);
|
||||
submitBtn.html(originalText);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
// Copy to clipboard functionality
|
||||
$('.copy-to-clipboard').on('click', function() {
|
||||
var text = $(this).data('text');
|
||||
if (!text) {
|
||||
text = $(this).text();
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showToast('Copied to clipboard!', 'success');
|
||||
}).catch(function() {
|
||||
showToast('Failed to copy to clipboard', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Quick search functionality
|
||||
$('#quickSearch').on('input', function() {
|
||||
var searchTerm = $(this).val().toLowerCase();
|
||||
|
||||
$('.searchable-item').each(function() {
|
||||
var item = $(this);
|
||||
var text = item.text().toLowerCase();
|
||||
|
||||
if (text.includes(searchTerm)) {
|
||||
item.show();
|
||||
} else {
|
||||
item.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize datepickers
|
||||
$('.datetimepicker').each(function() {
|
||||
$(this).flatpickr({
|
||||
enableTime: true,
|
||||
dateFormat: "Y-m-d H:i",
|
||||
time_24hr: true,
|
||||
minDate: "today"
|
||||
});
|
||||
});
|
||||
|
||||
$('.datepicker').each(function() {
|
||||
$(this).flatpickr({
|
||||
dateFormat: "Y-m-d",
|
||||
minDate: "today"
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-save functionality for multi-step forms
|
||||
$('.auto-save').on('change input', function() {
|
||||
var form = $(this).closest('form');
|
||||
var formData = form.serialize();
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('autosave_' + form.attr('id'), formData);
|
||||
showToast('Draft saved automatically', 'info');
|
||||
});
|
||||
|
||||
// Restore auto-saved data
|
||||
$('form.auto-save-restore').each(function() {
|
||||
var form = $(this);
|
||||
var savedData = localStorage.getItem('autosave_' + form.attr('id'));
|
||||
|
||||
if (savedData) {
|
||||
// Show restore prompt
|
||||
if (confirm('Found unsaved changes. Would you like to restore them?')) {
|
||||
var params = new URLSearchParams(savedData);
|
||||
params.forEach(function(value, key) {
|
||||
form.find('[name="' + key + '"]').val(value);
|
||||
});
|
||||
showToast('Draft restored', 'info');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Print functionality
|
||||
$('.btn-print').on('click', function() {
|
||||
window.print();
|
||||
});
|
||||
|
||||
// Export functionality
|
||||
$('.btn-export').on('click', function() {
|
||||
var format = $(this).data('format');
|
||||
var url = $(this).data('url');
|
||||
|
||||
if (format === 'excel') {
|
||||
window.open(url + '?export=excel', '_blank');
|
||||
} else if (format === 'pdf') {
|
||||
window.open(url + '?export=pdf', '_blank');
|
||||
} else if (format === 'csv') {
|
||||
window.open(url + '?export=csv', '_blank');
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
$(document).on('keydown', function(e) {
|
||||
// Ctrl/Cmd + K for search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
$('#quickSearch').focus();
|
||||
}
|
||||
|
||||
// Escape to close modals
|
||||
if (e.key === 'Escape') {
|
||||
$('.modal.show').modal('hide');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tooltips dynamically for AI-generated content
|
||||
initializeDynamicTooltips();
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
var toastHtml = `
|
||||
<div class="toast align-items-center text-white bg-${type === 'error' ? 'danger' : type} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
var toastContainer = $('.toast-container');
|
||||
var toastElement = $(toastHtml);
|
||||
toastContainer.append(toastElement);
|
||||
|
||||
var toast = new bootstrap.Toast(toastElement[0]);
|
||||
toast.show();
|
||||
|
||||
// Remove toast element after it's hidden
|
||||
toastElement.on('hidden.bs.toast', function() {
|
||||
$(this).remove();
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAction(message, callback) {
|
||||
if (confirm(message)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(dateTimeString) {
|
||||
var date = new Date(dateTimeString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
var date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatTime(timeString) {
|
||||
var date = new Date(timeString);
|
||||
return date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
function initializeDynamicTooltips() {
|
||||
// Initialize tooltips for dynamically generated content
|
||||
$('[data-bs-toggle="tooltip"]:not([aria-describedby])').each(function() {
|
||||
new bootstrap.Tooltip(this);
|
||||
});
|
||||
}
|
||||
|
||||
// API Helper functions
|
||||
function apiCall(url, method = 'GET', data = null) {
|
||||
return $.ajax({
|
||||
url: url,
|
||||
method: method,
|
||||
data: data,
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleApiError(xhr) {
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
showToast(xhr.responseJSON.message, 'error');
|
||||
} else {
|
||||
showToast('An error occurred. Please try again.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Form validation helpers
|
||||
function validateForm(form) {
|
||||
var isValid = true;
|
||||
var errors = [];
|
||||
|
||||
form.find('[required]').each(function() {
|
||||
var field = $(this);
|
||||
if (!field.val()) {
|
||||
isValid = false;
|
||||
errors.push(field.attr('data-label') || field.attr('name'));
|
||||
field.addClass('is-invalid');
|
||||
} else {
|
||||
field.removeClass('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
showToast('Please fill in the required fields: ' + errors.join(', '), 'error');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Image preview functionality
|
||||
function previewImage(input, previewElement) {
|
||||
if (input.files && input.files[0]) {
|
||||
var reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
previewElement.attr('src', e.target.result);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Export to CSV function
|
||||
function exportToCSV(data, filename) {
|
||||
var csv = '';
|
||||
var headers = Object.keys(data[0]);
|
||||
|
||||
// Add headers
|
||||
csv += headers.join(',') + '\n';
|
||||
|
||||
// Add data
|
||||
data.forEach(function(row) {
|
||||
headers.forEach(function(header, index) {
|
||||
if (index > 0) csv += ',';
|
||||
csv += '"' + (row[header] || '') + '"';
|
||||
});
|
||||
csv += '\n';
|
||||
});
|
||||
|
||||
// Create download link
|
||||
var hiddenElement = document.createElement('a');
|
||||
hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv);
|
||||
hiddenElement.target = '_blank';
|
||||
hiddenElement.download = filename + '.csv';
|
||||
hiddenElement.click();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Theme Toggle Manager - Edu-Space Admin
|
||||
* Manejo de Modo Claro y Modo Oscuro con persistencia en localStorage y anti-flickering.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 1. Obtener tema almacenado o preferencia del sistema
|
||||
function getPreferredTheme() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) {
|
||||
return storedTheme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// 2. Aplicar tema al elemento <html>
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
updateToggleButtons(theme);
|
||||
}
|
||||
|
||||
// 3. Actualizar iconos de los botones de toggle
|
||||
function updateToggleButtons(theme) {
|
||||
const buttons = document.querySelectorAll('.theme-toggle-btn');
|
||||
buttons.forEach(btn => {
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
if (theme === 'dark') {
|
||||
icon.className = 'bi bi-sun-fill';
|
||||
btn.setAttribute('aria-label', 'Cambiar a modo claro');
|
||||
btn.setAttribute('title', 'Modo Claro');
|
||||
} else {
|
||||
icon.className = 'bi bi-moon-stars-fill';
|
||||
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
|
||||
btn.setAttribute('title', 'Modo Oscuro');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Función global para alternar tema
|
||||
window.toggleTheme = function() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || getPreferredTheme();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
localStorage.setItem('theme', newTheme);
|
||||
applyTheme(newTheme);
|
||||
|
||||
// Si el usuario está autenticado, sincronizar asíncronamente con su perfil en el backend
|
||||
if (document.body && document.body.getAttribute('data-user-authenticated') === 'true') {
|
||||
fetch('/api/user/theme', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: newTheme })
|
||||
}).catch(err => {
|
||||
console.warn('No se pudo sincronizar el tema con el servidor:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 5. Inicializar tema inmediatamente (anti-flicker)
|
||||
const initialTheme = getPreferredTheme();
|
||||
applyTheme(initialTheme);
|
||||
|
||||
// 6. Al cargar el DOM, registrar eventos en los botones de toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateToggleButtons(document.documentElement.getAttribute('data-bs-theme') || initialTheme);
|
||||
|
||||
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.toggleTheme();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 7. Escuchar cambios de preferencia del sistema si el usuario no fijó una manual
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* ViewSwitcher — Gestor Universal y Extensible de Modos de Visualización
|
||||
*
|
||||
* Permite alternar instantáneamente entre vista de Tabla (grilla densa)
|
||||
* y vista de Tarjetas (mosaico responsive), preservando filtros, búsquedas
|
||||
* y persistiendo la preferencia del usuario en localStorage.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
class ViewSwitcherManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupViewSwitchers();
|
||||
});
|
||||
}
|
||||
|
||||
setupViewSwitchers() {
|
||||
const switchers = document.querySelectorAll('.view-switcher-group');
|
||||
if (!switchers.length) return;
|
||||
|
||||
switchers.forEach(group => {
|
||||
const context = group.getAttribute('data-context') || 'default';
|
||||
const defaultView = group.getAttribute('data-default-view') || 'cards';
|
||||
|
||||
// Determinar vista inicial:
|
||||
// 1. URL search param (?display=...)
|
||||
// 2. localStorage
|
||||
// 3. defaultView del contenedor
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlView = urlParams.get('display');
|
||||
const storedView = localStorage.getItem(`view_preference_${context}`);
|
||||
const activeView = urlView || storedView || defaultView;
|
||||
|
||||
this.applyView(context, activeView, false);
|
||||
|
||||
// Asignar listeners a los botones del selector
|
||||
const buttons = group.querySelectorAll('.view-switcher-btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const targetView = btn.getAttribute('data-view');
|
||||
if (targetView) {
|
||||
this.applyView(context, targetView, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica la vista seleccionada en el contexto dado.
|
||||
* @param {string} context - Identificador del módulo (ej: 'classrooms', 'reservations')
|
||||
* @param {string} viewName - Nombre de la vista ('table', 'cards', etc.)
|
||||
* @param {boolean} persist - Si debe persistirse en localStorage y actualizar URL
|
||||
*/
|
||||
applyView(context, viewName, persist = true) {
|
||||
// 1. Actualizar botones activos en el grupo selector
|
||||
const group = document.querySelector(`.view-switcher-group[data-context="${context}"]`);
|
||||
if (group) {
|
||||
const buttons = group.querySelectorAll('.view-switcher-btn');
|
||||
buttons.forEach(btn => {
|
||||
const btnView = btn.getAttribute('data-view');
|
||||
if (btnView === viewName) {
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-primary', 'active');
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
} else {
|
||||
btn.classList.remove('btn-primary', 'active');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
btn.setAttribute('aria-pressed', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Conmutar visibilidad de los contenedores de vista
|
||||
const containers = document.querySelectorAll(`[data-view-context="${context}"]`);
|
||||
let matched = false;
|
||||
|
||||
containers.forEach(container => {
|
||||
const containerView = container.getAttribute('data-view-container');
|
||||
if (containerView === viewName) {
|
||||
container.classList.remove('d-none');
|
||||
container.removeAttribute('aria-hidden');
|
||||
matched = true;
|
||||
} else {
|
||||
container.classList.add('d-none');
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: si la vista solicitada no tiene contenedor, restaurar a la primera visible
|
||||
if (!matched && containers.length > 0) {
|
||||
containers[0].classList.remove('d-none');
|
||||
viewName = containers[0].getAttribute('data-view-container') || 'cards';
|
||||
}
|
||||
|
||||
// 3. Persistir preferencia y actualizar formularios y URL
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(`view_preference_${context}`, viewName);
|
||||
} catch (e) {
|
||||
console.warn('localStorage no disponible para guardar preferencia de vista:', e);
|
||||
}
|
||||
|
||||
// Actualizar parámetro display en la URL sin recargar
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('display', viewName);
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
// 4. Sincronizar campo hidden 'display' en formularios de búsqueda/filtro
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
let hiddenInput = form.querySelector('input[name="display"]');
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'display';
|
||||
form.appendChild(hiddenInput);
|
||||
}
|
||||
hiddenInput.value = viewName;
|
||||
});
|
||||
|
||||
// 5. Emitir evento personalizado para componentes dinámicos
|
||||
window.dispatchEvent(new CustomEvent('viewModeChanged', {
|
||||
detail: { context, view: viewName }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Instanciar singleton
|
||||
window.ViewSwitcher = new ViewSwitcherManager();
|
||||
})();
|
||||