feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
@@ -11,6 +11,22 @@
|
||||
|
||||
.workload-filter-row {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Заголовок карточки загруженности (вместо inline-стиля legacy-вьюхи) */
|
||||
.workload-card-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.workload-min-capacity {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.workload-date-field {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.workload-display-control {
|
||||
|
||||
@@ -4186,3 +4186,251 @@ tbody tr:hover {
|
||||
.project-dialog-field { display: grid; gap: 0.4rem; margin-top: 1rem; }
|
||||
.project-dialog-field textarea { width: 100%; resize: vertical; }
|
||||
.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; }
|
||||
|
||||
/* ===== Êëàññû ýòàïà 5 (âîëíà 1): çàìåíà inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
|
||||
|
||||
/* Çàãîëîâîê êàðòî÷êè ñ äåéñòâèåì ñïðàâà (àóäèòîðèè) */
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-toolbar h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Ñòðîêè è ãðóïïû ôîðì */
|
||||
.form-row--spacing {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-row--bottom {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-group--wide {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.form-group--flush {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group--submit {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-inline {
|
||||
height: fit-content;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ß÷åéêè òàáëèö */
|
||||
.th-actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table-cell-actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.badge--tight {
|
||||
margin: 0.1rem;
|
||||
}
|
||||
|
||||
.badge--lesson-type {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Ìîäàëüíûå îêíà ïåðåíåñ¸ííûõ âêëàäîê */
|
||||
.modal-content--medium {
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-subcard {
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modal-subcard h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Ñîñòîÿíèå çàãðóçêè lazy-âêëàäêè */
|
||||
.tab-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Äàøáîðä: ìåòðèêè è ìîíèòîðèíã */
|
||||
.dashboard-metric-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-metric-value {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-monitor-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.dashboard-monitor-table th,
|
||||
.dashboard-monitor-table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-state-row {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-state-row--wide {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-free-hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-free-busy {
|
||||
color: var(--error);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-free-badge {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Äàøáîðä: öåíòð ïðåäóïðåæäåíèé (Red Zone) */
|
||||
.red-zone-card {
|
||||
border-top: 4px solid var(--error, #ef4444);
|
||||
}
|
||||
|
||||
.red-zone-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.red-zone-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.red-zone-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.red-zone-conflicts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-check-loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ===== Ýòàï 5 (âîëíà 2): êëàññû inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
|
||||
|
||||
/* Øèðîêèå ìîäàëüíûå îêíà (ïîäãðóïïû, ïðîôèëè, êàëåíäàðíûå ãðàôèêè) */
|
||||
.modal-content--wide {
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-content--xwide {
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-content--full {
|
||||
max-width: 960px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
/* Ôîðìà ïðîôèëÿ íà âêëàäêå ñòðóêòóðû âóçà */
|
||||
.profile-form-spec {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.profile-form-name {
|
||||
flex: 1.5;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.profile-form-description {
|
||||
flex: 2;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* Ðÿä êíîïîê ôîðìû, âûðîâíåííûõ ïî íèæíåé êðîìêå */
|
||||
.form-actions-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
/* Âîëíà 3 (ýòàï 5): çàìåíà inline-ñòèëÿ èç legacy department-workspace.html */
|
||||
.schedule-section-label--flush {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -265,6 +265,20 @@
|
||||
margin: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/* ===== Корневой контейнер ===== */
|
||||
|
||||
/* body — flex-контейнер, поэтому без явного растяжения корневой
|
||||
div сжимается до max-content контента: страницы с узким контентом
|
||||
(карточки, формы) не занимают всю ширину монитора, а дашборд
|
||||
«растягивался» только после загрузки данных. Растягиваем корень
|
||||
ровно на ширину окна (flex-basis 0), а не под контент: intrinsic-замер
|
||||
max-content раздувает вложенные flex-фильтры (например, панель
|
||||
загруженности аудиторий) до ~1 млн px горизонтального скролла. */
|
||||
#admin-root {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ===== Main ===== */
|
||||
.main {
|
||||
flex: 1;
|
||||
|
||||
@@ -19,243 +19,9 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
<rect width="40" height="40" rx="12" fill="url(#lg)" />
|
||||
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<defs>
|
||||
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
|
||||
<stop stop-color="#6366f1" />
|
||||
<stop offset="1" stop-color="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>Magistr</span>
|
||||
</div>
|
||||
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<!-- Главная -->
|
||||
<a href="#" class="nav-item" data-tab="dashboard">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" />
|
||||
<rect x="14" y="3" width="7" height="5" />
|
||||
<rect x="14" y="12" width="7" height="9" />
|
||||
<rect x="3" y="16" width="7" height="5" />
|
||||
</svg>
|
||||
<span>Дашборд</span>
|
||||
</a>
|
||||
<div id="admin-root"></div>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="teacher-requests">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M8 11h8" />
|
||||
<path d="M8 16h5" />
|
||||
</svg>
|
||||
<span>Заявки</span>
|
||||
<span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="department-workspace">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18" />
|
||||
<path d="M5 21V7l7-4 7 4v14" />
|
||||
<path d="M9 21v-6h6v6" />
|
||||
<path d="M9 9h.01" />
|
||||
<path d="M15 9h.01" />
|
||||
</svg>
|
||||
<span>Кабинет кафедры</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Расписание -->
|
||||
<div class="sidebar-section-divider"></div>
|
||||
<div class="sidebar-section-title">Расписание</div>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="schedule">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
<span>Конструктор правил</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="schedule-versions">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
|
||||
<path d="M16 3l4 4-4 4" />
|
||||
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
|
||||
<path d="M8 21l-4-4 4-4" />
|
||||
</svg>
|
||||
<span>Версии расписания</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="schedule-quality">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19V9" />
|
||||
<path d="M10 19V5" />
|
||||
<path d="M16 19v-7" />
|
||||
<path d="M22 19V3" />
|
||||
<path d="M2 19h20" />
|
||||
</svg>
|
||||
<span>Качество расписания</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="academic-calendar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v18H3z"></path>
|
||||
<path d="M3 9h18"></path>
|
||||
<path d="M9 3v18"></path>
|
||||
<path d="M15 3v18"></path>
|
||||
<path d="M3 15h18"></path>
|
||||
</svg>
|
||||
<span>Календарный график</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="auditorium-workload">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||
</svg>
|
||||
<span>Загруженность</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="schedule-view">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>Просмотр расписаний</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="teacher-absences">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
|
||||
<path d="M18 8l3 3" />
|
||||
<path d="M21 8l-3 3" />
|
||||
</svg>
|
||||
<span>Запросы преподавателей</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Субъекты и ресурсы -->
|
||||
<div class="sidebar-section-divider"></div>
|
||||
<div class="sidebar-section-title">Ресурсы</div>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="classrooms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
<span>Аудиторный фонд</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="groups">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
<span>Группы обучения</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="university-structure">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
<span>Структура вуза</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="subjects">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
<span>Дисциплины</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Система -->
|
||||
<div class="sidebar-section-divider"></div>
|
||||
<div class="sidebar-section-title">Система</div>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="users">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<span>Пользователи</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="settings-dropdown" id="settings-dropdown">
|
||||
<button class="btn-settings" id="btn-settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
<span>Настройки</span>
|
||||
<svg class="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="settings-menu" id="settings-menu">
|
||||
<a href="/admin/settings/" class="settings-menu-item">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
Общие настройки
|
||||
</a>
|
||||
<div class="settings-menu-divider"></div>
|
||||
<button class="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger" id="btn-logout">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Sidebar overlay (mobile) -->
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-title-group">
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dynamic Content Injected Here -->
|
||||
<section class="content" id="app-content">
|
||||
<!-- Content loaded via main.js -->
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/theme-toggle.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
<script type="module" src="/react/admin.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { debounce } from './url-state.js';
|
||||
|
||||
export class AsyncCombobox {
|
||||
constructor(select, {
|
||||
load,
|
||||
optionValue = item => item.id,
|
||||
optionLabel = item => item.name,
|
||||
optionDescription = () => '',
|
||||
placeholder = 'Начните вводить название',
|
||||
emptyLabel = 'Ничего не найдено',
|
||||
allowEmpty = true
|
||||
}) {
|
||||
if (!select || typeof load !== 'function') throw new Error('Для поискового списка нужны select и функция загрузки');
|
||||
this.select = select;
|
||||
this.load = load;
|
||||
this.optionValue = optionValue;
|
||||
this.optionLabel = optionLabel;
|
||||
this.optionDescription = optionDescription;
|
||||
this.emptyLabel = emptyLabel;
|
||||
this.allowEmpty = allowEmpty;
|
||||
this.requestNumber = 0;
|
||||
this.items = [];
|
||||
|
||||
select.dataset.nativeSelect = 'true';
|
||||
select.hidden = true;
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'async-combobox';
|
||||
this.root.innerHTML = `
|
||||
<div class="async-combobox-control">
|
||||
<input type="search" role="combobox" aria-autocomplete="list" aria-expanded="false"
|
||||
autocomplete="off" placeholder="${escapeText(placeholder)}">
|
||||
<button type="button" class="async-combobox-clear" aria-label="Очистить выбор" ${allowEmpty ? '' : 'hidden'}>×</button>
|
||||
</div>
|
||||
<div class="async-combobox-menu" role="listbox" hidden></div>
|
||||
`;
|
||||
select.insertAdjacentElement('afterend', this.root);
|
||||
this.input = this.root.querySelector('input');
|
||||
this.menu = this.root.querySelector('[role="listbox"]');
|
||||
this.clearButton = this.root.querySelector('.async-combobox-clear');
|
||||
this.input.setAttribute('aria-label', select.getAttribute('aria-label') || placeholder);
|
||||
this.labels = select.id
|
||||
? [...document.querySelectorAll('label[for]')].filter(label => label.htmlFor === select.id)
|
||||
: [];
|
||||
if (select.id) {
|
||||
this.input.id = `${select.id}-search`;
|
||||
this.labels.forEach(label => { label.htmlFor = this.input.id; });
|
||||
}
|
||||
const listId = `${select.id || 'async-combobox'}-listbox`;
|
||||
this.menu.id = listId;
|
||||
this.input.setAttribute('aria-controls', listId);
|
||||
|
||||
this.input.addEventListener('focus', () => this.search(this.input.value));
|
||||
this.input.addEventListener('input', debounce(() => this.search(this.input.value), 320));
|
||||
this.input.addEventListener('keydown', event => this.handleKeydown(event));
|
||||
this.clearButton.addEventListener('click', () => this.clear());
|
||||
this.menu.addEventListener('mousedown', event => event.preventDefault());
|
||||
this.menu.addEventListener('keydown', event => this.handleMenuKeydown(event));
|
||||
this.handleOutsideClick = event => {
|
||||
if (!this.root.contains(event.target)) this.close();
|
||||
};
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
this.syncFromSelect();
|
||||
}
|
||||
|
||||
async search(query = '') {
|
||||
return this.loadMatches(query, true);
|
||||
}
|
||||
|
||||
async preload(query = '') {
|
||||
return this.loadMatches(query, false);
|
||||
}
|
||||
|
||||
async loadMatches(query, revealMenu) {
|
||||
const requestNumber = ++this.requestNumber;
|
||||
if (revealMenu) this.open();
|
||||
this.menu.innerHTML = '<div class="async-combobox-state"><span class="state-spinner" aria-hidden="true"></span>Поиск...</div>';
|
||||
try {
|
||||
const items = await this.load(query.trim(), this.select.value || '');
|
||||
if (requestNumber !== this.requestNumber) return;
|
||||
this.items = Array.isArray(items) ? items : [];
|
||||
this.renderItems();
|
||||
} catch (error) {
|
||||
if (requestNumber !== this.requestNumber) return;
|
||||
this.menu.innerHTML = `<div class="async-combobox-state is-error">${escapeText(error.message || 'Не удалось выполнить поиск')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
renderItems() {
|
||||
if (!this.items.length) {
|
||||
this.menu.innerHTML = `<div class="async-combobox-state">${escapeText(this.emptyLabel)}</div>`;
|
||||
return;
|
||||
}
|
||||
this.menu.innerHTML = this.items.map((item, index) => {
|
||||
const value = String(this.optionValue(item));
|
||||
const description = this.optionDescription(item);
|
||||
return `<button type="button" role="option" class="async-combobox-option" data-index="${index}"
|
||||
aria-selected="${String(this.select.value) === value}">
|
||||
<strong>${escapeText(this.optionLabel(item))}</strong>
|
||||
${description ? `<small>${escapeText(description)}</small>` : ''}
|
||||
</button>`;
|
||||
}).join('');
|
||||
this.menu.querySelectorAll('[data-index]').forEach(button => {
|
||||
button.addEventListener('click', () => this.choose(this.items[Number(button.dataset.index)]));
|
||||
});
|
||||
}
|
||||
|
||||
choose(item) {
|
||||
const value = String(this.optionValue(item));
|
||||
const label = this.optionLabel(item);
|
||||
this.select.innerHTML = `<option value="${escapeText(value)}" selected>${escapeText(label)}</option>`;
|
||||
this.select.value = value;
|
||||
this.input.value = label;
|
||||
this.input.dataset.selectedValue = value;
|
||||
this.select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
this.close();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.select.innerHTML = '<option value="" selected></option>';
|
||||
this.select.value = '';
|
||||
this.input.value = '';
|
||||
delete this.input.dataset.selectedValue;
|
||||
this.select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
this.input.focus();
|
||||
this.search('');
|
||||
}
|
||||
|
||||
syncFromSelect() {
|
||||
const selected = this.select.selectedOptions?.[0];
|
||||
this.input.value = selected?.value ? selected.textContent.trim() : '';
|
||||
if (selected?.value) this.input.dataset.selectedValue = selected.value;
|
||||
}
|
||||
|
||||
open() {
|
||||
this.menu.hidden = false;
|
||||
this.input.setAttribute('aria-expanded', 'true');
|
||||
this.root.classList.add('is-open');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menu.hidden = true;
|
||||
this.input.setAttribute('aria-expanded', 'false');
|
||||
this.root.classList.remove('is-open');
|
||||
this.syncFromSelect();
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
const options = [...this.menu.querySelectorAll('[role="option"]')];
|
||||
const current = options.indexOf(document.activeElement);
|
||||
if (event.key === 'Escape') {
|
||||
this.close();
|
||||
} else if (event.key === 'ArrowDown' && options.length) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)].focus();
|
||||
} else if (event.key === 'ArrowUp' && options.length) {
|
||||
event.preventDefault();
|
||||
options[Math.max(current - 1, 0)].focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleMenuKeydown(event) {
|
||||
const options = [...this.menu.querySelectorAll('[role="option"]')];
|
||||
const current = options.indexOf(document.activeElement);
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.input.focus();
|
||||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||||
} else if (event.key === 'ArrowUp' && current >= 0) {
|
||||
event.preventDefault();
|
||||
(current === 0 ? this.input : options[current - 1])?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
this.requestNumber += 1;
|
||||
this.root.remove();
|
||||
this.select.hidden = false;
|
||||
this.labels.forEach(label => { label.htmlFor = this.select.id; });
|
||||
}
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { api, initializeSession, logout } from './api.js';
|
||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage } from './utils.js';
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
|
||||
import { initializeTelemetry } from '../../telemetry.js';
|
||||
import { confirmAbandonDirtyChanges } from './dirty-state.js';
|
||||
import { trackUxEvent } from '../../telemetry.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const session = await initializeSession();
|
||||
if (!session || !ADMIN_APP_ROLES.includes(session.role)) {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
const currentRole = session.role;
|
||||
void initializeTelemetry();
|
||||
|
||||
// Global initialization for Custom Selects
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initAllCustomDropdowns(document.body);
|
||||
startDropdownAutoObserver();
|
||||
});
|
||||
|
||||
// Configuration
|
||||
const ROUTES = {
|
||||
dashboard: {
|
||||
title: 'Дашборд',
|
||||
file: '/admin/views/dashboard.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/dashboard.js');
|
||||
await module.initDashboard();
|
||||
}
|
||||
},
|
||||
users: {
|
||||
title: 'Управление пользователями',
|
||||
file: '/admin/views/users.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/users.js');
|
||||
await module.initUsers();
|
||||
}
|
||||
},
|
||||
'teacher-requests': {
|
||||
title: 'Заявки',
|
||||
file: '/admin/views/teacher-requests.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/teacher-requests.js');
|
||||
await module.initTeacherRequests();
|
||||
}
|
||||
},
|
||||
groups: {
|
||||
title: 'Управление группами',
|
||||
file: '/admin/views/groups.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/groups.js');
|
||||
await module.initGroups();
|
||||
}
|
||||
},
|
||||
classrooms: {
|
||||
title: 'Аудиторный фонд',
|
||||
file: '/admin/views/classrooms.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/classrooms.js');
|
||||
await module.initClassrooms();
|
||||
}
|
||||
},
|
||||
subjects: {
|
||||
title: 'Дисциплины',
|
||||
file: '/admin/views/subjects.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/subjects.js');
|
||||
await module.initSubjects();
|
||||
}
|
||||
},
|
||||
'university-structure': {
|
||||
title: 'Структура вуза',
|
||||
file: '/admin/views/university-structure.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/university-structure.js');
|
||||
await module.initUniversityStructure();
|
||||
}
|
||||
},
|
||||
'department-workspace': {
|
||||
title: 'Кабинет кафедры',
|
||||
file: '/admin/views/department-workspace.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/department-workspace.js');
|
||||
await module.initDepartmentWorkspace();
|
||||
}
|
||||
},
|
||||
'schedule-view': {
|
||||
title: 'Просмотр расписаний',
|
||||
file: '/admin/views/schedule-view.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/schedule-view.js');
|
||||
await module.initScheduleView();
|
||||
}
|
||||
},
|
||||
schedule: {
|
||||
title: 'Конструктор правил',
|
||||
file: '/admin/views/schedule.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/schedule.js');
|
||||
await module.initSchedule();
|
||||
}
|
||||
},
|
||||
'schedule-versions': {
|
||||
title: 'Версии расписания',
|
||||
file: '/admin/views/schedule-versions.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/schedule-versions.js');
|
||||
await module.initScheduleVersions();
|
||||
}
|
||||
},
|
||||
'schedule-quality': {
|
||||
title: 'Анализ качества расписания',
|
||||
file: '/admin/views/schedule-quality.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/schedule-quality.js');
|
||||
await module.initScheduleQuality();
|
||||
}
|
||||
},
|
||||
'academic-calendar': {
|
||||
title: 'Календарный учебный график',
|
||||
file: '/admin/views/academic-calendar.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/academic-calendar.js');
|
||||
await module.initAcademicCalendar();
|
||||
}
|
||||
},
|
||||
'auditorium-workload': {
|
||||
title: 'Загруженность',
|
||||
file: '/admin/views/auditorium-workload.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/auditorium-workload.js');
|
||||
await module.initAuditoriumWorkload();
|
||||
}
|
||||
},
|
||||
'teacher-absences': {
|
||||
title: 'Запросы преподавателей',
|
||||
file: '/admin/views/teacher-absences.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/teacher-absences.js');
|
||||
await module.initTeacherAbsences();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let currentTab = null;
|
||||
|
||||
// DOM Elements
|
||||
const appContent = document.getElementById('app-content');
|
||||
const pageTitle = document.getElementById('page-title');
|
||||
const navItems = document.querySelectorAll('.nav-item[data-tab]');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
||||
const btnLogout = document.getElementById('btn-logout');
|
||||
const main = document.querySelector('.main');
|
||||
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
|
||||
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
|
||||
const roleCapabilities = capabilitiesForRole(currentRole);
|
||||
const allowedTabs = new Set(roleCapabilities.adminTabs);
|
||||
|
||||
// Setup Global Effects
|
||||
applyRippleEffect();
|
||||
closeAllDropdownsOnOutsideClick();
|
||||
configureRoleInterface();
|
||||
refreshTeacherRequestBadge();
|
||||
|
||||
// Init sidebar state from localStorage on load
|
||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
main.classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
// Sidebar Close (X button)
|
||||
sidebarCloseBtn?.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
|
||||
}
|
||||
});
|
||||
|
||||
sidebarOverlay.addEventListener('click', () => {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
});
|
||||
|
||||
// Settings Dropdown
|
||||
const settingsDropdown = document.getElementById('settings-dropdown');
|
||||
const btnSettings = document.getElementById('btn-settings');
|
||||
|
||||
btnSettings.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
settingsDropdown.classList.toggle('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!settingsDropdown.contains(e.target)) {
|
||||
settingsDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
btnLogout.addEventListener('click', async () => {
|
||||
await logout();
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
teacherRequestsTitleBadge?.addEventListener('click', () => {
|
||||
switchTab('teacher-requests');
|
||||
});
|
||||
|
||||
// Navigation
|
||||
navItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const tab = item.dataset.tab;
|
||||
switchTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
async function switchTab(tab) {
|
||||
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
|
||||
if (currentTab && !(await confirmAbandonDirtyChanges())) return;
|
||||
|
||||
// UI Update
|
||||
navItems.forEach(n => n.classList.remove('active'));
|
||||
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
|
||||
pageTitle.textContent = ROUTES[tab].title;
|
||||
|
||||
// Load template
|
||||
try {
|
||||
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
const response = await fetch(ROUTES[tab].file);
|
||||
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
|
||||
|
||||
const html = await response.text();
|
||||
appContent.innerHTML = html;
|
||||
|
||||
// Initialize logic for the tab
|
||||
if (ROUTES[tab].init) {
|
||||
await ROUTES[tab].init();
|
||||
}
|
||||
|
||||
currentTab = tab;
|
||||
trackUxEvent('раздел_открыт', { tab, role: currentRole });
|
||||
if (window.location.hash !== `#${tab}`) {
|
||||
window.location.hash = tab;
|
||||
}
|
||||
} catch (e) {
|
||||
renderStatusMessage(appContent, 'Не удалось загрузить выбранную вкладку');
|
||||
console.error('Не удалось загрузить вкладку');
|
||||
}
|
||||
|
||||
// Close mobile menu if open
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
}
|
||||
|
||||
function configureRoleInterface() {
|
||||
navItems.forEach(item => {
|
||||
item.hidden = !allowedTabs.has(item.dataset.tab);
|
||||
});
|
||||
|
||||
const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]');
|
||||
if (settingsLink) {
|
||||
settingsLink.hidden = !SETTINGS_ROLES.includes(currentRole);
|
||||
}
|
||||
|
||||
const firstAllowedTitle = {
|
||||
ADMIN: 'Magistr',
|
||||
EDUCATION_OFFICE: 'Учебный отдел',
|
||||
DEPARTMENT: 'Кафедра',
|
||||
SCHEDULE_VIEWER: 'Расписания'
|
||||
}[currentRole] || 'Magistr';
|
||||
document.querySelector('.logo span').textContent = firstAllowedTitle;
|
||||
}
|
||||
|
||||
async function refreshTeacherRequestBadge(countOverride = null) {
|
||||
if (currentRole !== 'ADMIN') return;
|
||||
try {
|
||||
const count = countOverride === null
|
||||
? Number((await api.get('/api/teacher-requests/page?status=PENDING&page=0&size=1')).totalItems || 0)
|
||||
: countOverride;
|
||||
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
|
||||
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
|
||||
} catch (error) {
|
||||
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
|
||||
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
|
||||
console.warn('Не удалось загрузить счётчик заявок');
|
||||
}
|
||||
}
|
||||
|
||||
function updateTeacherRequestBadge(element, count, label) {
|
||||
if (!element) return;
|
||||
const normalizedCount = Number(count) || 0;
|
||||
element.textContent = label;
|
||||
element.hidden = normalizedCount <= 0;
|
||||
element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
|
||||
element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
|
||||
}
|
||||
|
||||
function requestCountLabel(count) {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
|
||||
return `${count} заявок`;
|
||||
}
|
||||
|
||||
document.addEventListener('teacherRequestsChanged', (event) => {
|
||||
refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
|
||||
});
|
||||
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
switchTab(allowedTabs.has(hashTab) ? hashTab : roleCapabilities.defaultAdminTab);
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
if (allowedTabs.has(hashTab) && hashTab !== currentTab) {
|
||||
switchTab(hashTab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
@@ -1,34 +0,0 @@
|
||||
import { escapeHtml } from './utils.js';
|
||||
|
||||
export function renderPagination(container, pageData, { onPage, onSize, label = 'записей' } = {}) {
|
||||
if (!container) return;
|
||||
const totalItems = Number(pageData?.totalItems || 0);
|
||||
const page = Number(pageData?.page || 0);
|
||||
const size = Number(pageData?.size || 25);
|
||||
const totalPages = Math.max(Number(pageData?.totalPages || 0), totalItems ? 1 : 0);
|
||||
const start = totalItems ? page * size + 1 : 0;
|
||||
const end = Math.min((page + 1) * size, totalItems);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="pagination-summary" aria-live="polite">
|
||||
Показано ${start}–${end} из ${totalItems} ${escapeHtml(label)}
|
||||
</div>
|
||||
<div class="pagination-actions">
|
||||
<label class="pagination-size">
|
||||
<span>На странице</span>
|
||||
<select data-pagination-size data-native-select="true">
|
||||
${[25, 50, 100].map(value => `<option value="${value}" ${value === size ? 'selected' : ''}>${value}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page - 1}" ${page <= 0 ? 'disabled' : ''}>Назад</button>
|
||||
<span class="pagination-page">${totalPages ? page + 1 : 0} / ${totalPages}</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page + 1}" ${page >= totalPages - 1 ? 'disabled' : ''}>Далее</button>
|
||||
</div>
|
||||
`;
|
||||
container.querySelectorAll('[data-pagination-page]').forEach(button => {
|
||||
button.addEventListener('click', () => onPage?.(Number(button.dataset.paginationPage)));
|
||||
});
|
||||
container.querySelector('[data-pagination-size]')?.addEventListener('change', event => {
|
||||
onSize?.(Number(event.target.value));
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,314 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
|
||||
import { alertAction, confirmAction } from '../dialog.js';
|
||||
|
||||
async function fetchEquipments() {
|
||||
try {
|
||||
return await api.get('/api/equipments');
|
||||
} catch (e) {
|
||||
console.error('Не удалось загрузить список оборудования');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!equipments || !equipments.length) {
|
||||
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
|
||||
updateSelectText(containerId, selectTextId);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = equipments.map(eq => `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>${escapeHtml(eq.name)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
updateSelectText(containerId, selectTextId);
|
||||
}
|
||||
|
||||
export async function initClassrooms() {
|
||||
const classroomsTbody = document.getElementById('classrooms-tbody');
|
||||
const createClassroomForm = document.getElementById('create-classroom-form');
|
||||
|
||||
const equipmentCheckboxes = document.getElementById('equipment-checkboxes');
|
||||
const editEquipmentCheckboxes = document.getElementById('edit-equipment-checkboxes');
|
||||
|
||||
const modalEditClassroom = document.getElementById('modal-edit-classroom');
|
||||
const modalEditClassroomClose = document.getElementById('modal-edit-classroom-close');
|
||||
const editClassroomForm = document.getElementById('edit-classroom-form');
|
||||
|
||||
let allEquipments = [];
|
||||
let editingClassroomData = null;
|
||||
|
||||
initMultiSelect('equipment-select-box', 'equipment-dropdown-menu', 'equipment-select-text', 'equipment-checkboxes');
|
||||
initMultiSelect('edit-equipment-select-box', 'edit-equipment-dropdown-menu', 'edit-equipment-select-text', 'edit-equipment-checkboxes');
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
allEquipments = await fetchEquipments();
|
||||
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
|
||||
await loadClassrooms();
|
||||
} catch (e) {
|
||||
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки данных</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClassrooms() {
|
||||
try {
|
||||
const classrooms = await api.get('/api/classrooms?includeArchived=true');
|
||||
renderClassrooms(classrooms);
|
||||
} catch (e) {
|
||||
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderClassrooms(classrooms) {
|
||||
if (!classrooms || !classrooms.length) {
|
||||
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет аудиторий</td></tr>';
|
||||
return;
|
||||
}
|
||||
classroomsTbody.innerHTML = classrooms.map(c => {
|
||||
const equipHtml = c.equipments && c.equipments.length
|
||||
? c.equipments.map(eq => escapeHtml(eq.name)).join(', ')
|
||||
: '—';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${c.id}</td>
|
||||
<td><strong>${escapeHtml(c.name)}</strong></td>
|
||||
<td>${c.capacity} чел.</td>
|
||||
<td><small>${equipHtml}</small></td>
|
||||
<td>
|
||||
<div class="status-cell">
|
||||
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
|
||||
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
|
||||
</span>
|
||||
<button class="btn btn-icon-sm btn-secondary btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус" aria-label="Сменить статус">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-actions-cell">
|
||||
<div class="table-actions">
|
||||
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
|
||||
${c.status === 'ARCHIVED'
|
||||
? `<button class="btn btn-sm btn-secondary btn-restore-classroom" data-id="${c.id}">Восстановить</button>`
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}">Архивировать</button>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
createClassroomForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-classroom-alert');
|
||||
const name = document.getElementById('new-classroom-name').value.trim();
|
||||
const capacity = parseInt(document.getElementById('new-classroom-capacity').value, 10);
|
||||
|
||||
const checkedBoxes = Array.from(equipmentCheckboxes.querySelectorAll('input:checked'));
|
||||
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
|
||||
|
||||
if (!name || isNaN(capacity)) { showAlert('create-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/classrooms', { name, capacity, equipmentIds, isAvailable: true });
|
||||
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" добавлена`, 'success');
|
||||
createClassroomForm.reset();
|
||||
updateSelectText('equipment-checkboxes', 'equipment-select-text');
|
||||
loadClassrooms();
|
||||
} catch (e) {
|
||||
showAlert('create-classroom-alert', e.message || 'Ошибка создания', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
classroomsTbody.addEventListener('click', async (e) => {
|
||||
const btnDelete = e.target.closest('.btn-archive-classroom');
|
||||
const btnToggleStatus = e.target.closest('.btn-icon-toggle');
|
||||
const btnEdit = e.target.closest('.classroom-edit-action');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!await confirmAction({
|
||||
title: 'Вывод аудитории из эксплуатации',
|
||||
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
|
||||
confirmLabel: 'Вывести из эксплуатации',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
|
||||
loadClassrooms();
|
||||
} catch (err) { await alertAction({ title: 'Ошибка архивирования', message: err.message || 'Не удалось архивировать аудиторию' }); }
|
||||
}
|
||||
|
||||
const btnRestore = e.target.closest('.btn-restore-classroom');
|
||||
if (btnRestore) {
|
||||
try {
|
||||
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
|
||||
loadClassrooms();
|
||||
} catch (err) { await alertAction({ title: 'Ошибка восстановления', message: err.message || 'Не удалось восстановить аудиторию' }); }
|
||||
}
|
||||
|
||||
if (btnToggleStatus) {
|
||||
const id = btnToggleStatus.dataset.id;
|
||||
const currentStatus = btnToggleStatus.dataset.currentStatus === 'true';
|
||||
try {
|
||||
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
|
||||
loadClassrooms();
|
||||
} catch (err) { await alertAction({ title: 'Ошибка изменения статуса', message: err.message || 'Не удалось изменить доступность аудитории' }); }
|
||||
}
|
||||
|
||||
if (btnEdit) {
|
||||
openEditClassroomModal(btnEdit.dataset.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function openEditClassroomModal(id) {
|
||||
try {
|
||||
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data
|
||||
const classrooms = await api.get('/api/classrooms?includeArchived=true');
|
||||
editingClassroomData = classrooms.find(c => c.id == id);
|
||||
|
||||
if (!editingClassroomData) return;
|
||||
|
||||
document.getElementById('edit-classroom-id').value = editingClassroomData.id;
|
||||
document.getElementById('edit-classroom-name').value = editingClassroomData.name;
|
||||
document.getElementById('edit-classroom-capacity').value = editingClassroomData.capacity;
|
||||
|
||||
const existingEquipIds = editingClassroomData.equipments.map(e => e.id);
|
||||
renderEquipmentCheckboxes(allEquipments, 'edit-equipment-checkboxes', 'edit-equipment-select-text', existingEquipIds);
|
||||
|
||||
hideAlert('edit-classroom-alert');
|
||||
modalEditClassroom.classList.add('open');
|
||||
} catch (e) {
|
||||
await alertAction({ title: 'Ошибка загрузки', message: e.message || 'Не удалось загрузить данные аудитории' });
|
||||
}
|
||||
}
|
||||
|
||||
modalEditClassroomClose.addEventListener('click', () => {
|
||||
modalEditClassroom.classList.remove('open');
|
||||
});
|
||||
|
||||
modalEditClassroom.addEventListener('click', (e) => {
|
||||
if (e.target === modalEditClassroom) {
|
||||
modalEditClassroom.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
editClassroomForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('edit-classroom-alert');
|
||||
const id = document.getElementById('edit-classroom-id').value;
|
||||
const name = document.getElementById('edit-classroom-name').value.trim();
|
||||
const capacity = parseInt(document.getElementById('edit-classroom-capacity').value, 10);
|
||||
|
||||
const checkedBoxes = Array.from(editEquipmentCheckboxes.querySelectorAll('input:checked'));
|
||||
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
|
||||
|
||||
if (!name || isNaN(capacity)) { showAlert('edit-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
|
||||
|
||||
try {
|
||||
const data = await api.put('/api/classrooms/' + id, {
|
||||
name,
|
||||
capacity,
|
||||
equipmentIds,
|
||||
isAvailable: editingClassroomData.isAvailable
|
||||
});
|
||||
modalEditClassroom.classList.remove('open');
|
||||
// We show alert on the main create form area or we could use toast
|
||||
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" обновлена`, 'success');
|
||||
loadClassrooms();
|
||||
} catch (e) {
|
||||
showAlert('edit-classroom-alert', e.message || 'Ошибка обновления', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Элементы модалки оборудования
|
||||
const btnManageEquipments = document.getElementById('btn-manage-equipments');
|
||||
const modalManageEquipments = document.getElementById('modal-manage-equipments');
|
||||
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
|
||||
const manageEquipmentForm = document.getElementById('manage-equipment-form');
|
||||
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
|
||||
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
|
||||
|
||||
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
|
||||
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
|
||||
modalManageEquipments.addEventListener('click', (e) => {
|
||||
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
|
||||
});
|
||||
|
||||
async function openManageEquipmentsModal() {
|
||||
hideAlert('manage-equipment-alert');
|
||||
manageEquipmentForm.reset();
|
||||
modalManageEquipments.classList.add('open');
|
||||
await loadManageEquipments();
|
||||
}
|
||||
|
||||
async function loadManageEquipments() {
|
||||
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
allEquipments = await fetchEquipments();
|
||||
renderManageEquipmentsTable();
|
||||
// Обновим чекбоксы на основной странице
|
||||
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
|
||||
} catch (e) {
|
||||
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderManageEquipmentsTable() {
|
||||
if (!allEquipments || !allEquipments.length) {
|
||||
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
|
||||
<tr>
|
||||
<td>${eq.id}</td>
|
||||
<td>${escapeHtml(eq.name)}</td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
manageEquipmentForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('manage-equipment-alert');
|
||||
const name = manageEquipmentNameInput.value.trim();
|
||||
if (!name) {
|
||||
showAlert('manage-equipment-alert', 'Введите название', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api.post('/api/equipments', { name });
|
||||
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
|
||||
manageEquipmentForm.reset();
|
||||
await loadManageEquipments();
|
||||
} catch (err) {
|
||||
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
manageEquipmentTbody.addEventListener('click', async (e) => {
|
||||
const btnDelete = e.target.closest('.btn-delete-equipment');
|
||||
if (!btnDelete) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление оборудования',
|
||||
message: 'Удалить оборудование из каталога?',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/equipments/' + btnDelete.dataset.id);
|
||||
await loadManageEquipments();
|
||||
} catch (err) {
|
||||
await alertAction({ title: 'Ошибка удаления', message: err.message || 'Не удалось удалить оборудование' });
|
||||
}
|
||||
});
|
||||
|
||||
loadInitialData();
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import {
|
||||
CONFLICT_CHECK_STATE,
|
||||
conflictCheckPresentation,
|
||||
currentWeekDateRange,
|
||||
formatLocalDate,
|
||||
loadDepartmentSchedules
|
||||
} from '../dashboard-conflicts.js';
|
||||
|
||||
export async function initDashboard() {
|
||||
// DOM Элементы
|
||||
const metricGroups = document.getElementById('metric-groups');
|
||||
const metricStudents = document.getElementById('metric-students');
|
||||
const metricTeachers = document.getElementById('metric-teachers');
|
||||
const metricClassrooms = document.getElementById('metric-classrooms');
|
||||
|
||||
const currentTimeSlot = document.getElementById('current-time-slot');
|
||||
const currentLessonsTbody = document.getElementById('current-lessons-tbody');
|
||||
const freeClassroomsList = document.getElementById('free-classrooms-list');
|
||||
const conflictsContainer = document.getElementById('conflicts-container');
|
||||
const btnRecheck = document.getElementById('btn-recheck-conflicts');
|
||||
|
||||
let departments = [];
|
||||
let classrooms = [];
|
||||
let groups = [];
|
||||
let teachers = [];
|
||||
let todayLessons = [];
|
||||
let timeSlots = [];
|
||||
let dashboardDataReady = false;
|
||||
|
||||
btnRecheck.addEventListener('click', async () => {
|
||||
btnRecheck.disabled = true;
|
||||
btnRecheck.setAttribute('aria-busy', 'true');
|
||||
btnRecheck.textContent = 'Проверка…';
|
||||
try {
|
||||
if (dashboardDataReady) {
|
||||
await checkConflicts();
|
||||
} else {
|
||||
await loadData();
|
||||
}
|
||||
} finally {
|
||||
btnRecheck.textContent = 'Перепроверить';
|
||||
btnRecheck.disabled = false;
|
||||
btnRecheck.removeAttribute('aria-busy');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
dashboardDataReady = false;
|
||||
try {
|
||||
// Загружаем базовые справочники
|
||||
[departments, classrooms, groups, teachers] = await Promise.all([
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/classrooms?includeArchived=false'),
|
||||
api.get('/api/groups?includeArchived=false'),
|
||||
api.get('/api/users/teachers')
|
||||
]);
|
||||
|
||||
// Выводим метрики
|
||||
metricGroups.textContent = groups.length;
|
||||
const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0);
|
||||
metricStudents.textContent = totalStudents;
|
||||
metricTeachers.textContent = teachers.length;
|
||||
|
||||
const todayStr = formatLocalDate(new Date());
|
||||
|
||||
// Загружаем временные слоты и сегодняшнее расписание
|
||||
const lessonsPromises = departments.map(d =>
|
||||
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => [])
|
||||
);
|
||||
const lessonsArrays = await Promise.all(lessonsPromises);
|
||||
todayLessons = lessonsArrays.flat();
|
||||
|
||||
try {
|
||||
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`);
|
||||
} catch (e) {
|
||||
// Если эндпоинт effective не поддерживается, используем базовые слоты
|
||||
const baseSlots = await api.get('/api/admin/time-slots').catch(() => []);
|
||||
// Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично)
|
||||
timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId);
|
||||
}
|
||||
|
||||
// Сортируем слоты по времени
|
||||
timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||
|
||||
// Посчитаем занятость аудиторий сегодня
|
||||
const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean));
|
||||
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
|
||||
const classPercent = activeClassrooms.length
|
||||
? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100)
|
||||
: 0;
|
||||
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
|
||||
|
||||
updateRealtimeMonitoring();
|
||||
dashboardDataReady = true;
|
||||
await checkConflicts();
|
||||
} catch (e) {
|
||||
dashboardDataReady = false;
|
||||
console.error('Не удалось загрузить данные панели', {
|
||||
errorType: e?.name || 'Error'
|
||||
});
|
||||
conflictsContainer.innerHTML = renderCheckStateCard({
|
||||
tone: 'error',
|
||||
title: 'Проверка не выполнена',
|
||||
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateRealtimeMonitoring() {
|
||||
const now = new Date();
|
||||
const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
|
||||
|
||||
// Находим текущий временной слот
|
||||
let currentSlot = null;
|
||||
for (const slot of timeSlots) {
|
||||
if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) {
|
||||
currentSlot = slot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSlot) {
|
||||
currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} – ${currentSlot.endTime.substring(0, 5)})`;
|
||||
renderCurrentLessons(currentSlot.orderNumber);
|
||||
renderFreeClassrooms(currentSlot.orderNumber);
|
||||
} else {
|
||||
currentTimeSlot.textContent = 'Вне сетки пар';
|
||||
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
|
||||
|
||||
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
|
||||
renderFreeClassrooms(null);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentLessons(slotOrder) {
|
||||
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
|
||||
if (!activeLessons.length) {
|
||||
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentLessonsTbody.innerHTML = activeLessons.map(l => `
|
||||
<tr>
|
||||
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
|
||||
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
|
||||
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
|
||||
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderFreeClassrooms(slotOrder) {
|
||||
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
|
||||
let occupiedIds = new Set();
|
||||
if (slotOrder !== null) {
|
||||
occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId));
|
||||
}
|
||||
|
||||
const free = activeClassrooms.filter(c => !occupiedIds.has(c.id));
|
||||
if (!free.length) {
|
||||
freeClassroomsList.innerHTML = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
freeClassroomsList.innerHTML = free.map(c => `
|
||||
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
|
||||
${escapeHtml(c.name)} (${c.capacity} м.)
|
||||
</span>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// --- Анализ конфликтов (Red Zone) ---
|
||||
async function checkConflicts() {
|
||||
conflictsContainer.setAttribute('aria-busy', 'true');
|
||||
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
||||
|
||||
try {
|
||||
const { startDate, endDate } = currentWeekDateRange(new Date());
|
||||
const scheduleLoad = await loadDepartmentSchedules(
|
||||
departments,
|
||||
department => api.get(
|
||||
`/api/schedule/search?departmentId=${department.id}`
|
||||
+ `&startDate=${startDate}&endDate=${endDate}`
|
||||
)
|
||||
);
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||
renderConflicts([], scheduleLoad, { startDate, endDate });
|
||||
return;
|
||||
}
|
||||
|
||||
const weekLessons = scheduleLoad.lessons;
|
||||
|
||||
const conflicts = [];
|
||||
|
||||
// Мапы для быстрого поиска характеристик групп и аудиторий
|
||||
const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0]));
|
||||
const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0]));
|
||||
|
||||
// 1. Проверка накладок преподавателей
|
||||
// Ключ: date + '_' + timeSlotOrder + '_' + teacherId
|
||||
const teacherSlots = new Map();
|
||||
weekLessons.forEach(l => {
|
||||
if (!l.teacherId) return;
|
||||
const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`;
|
||||
if (!teacherSlots.has(key)) {
|
||||
teacherSlots.set(key, []);
|
||||
}
|
||||
teacherSlots.get(key).push(l);
|
||||
});
|
||||
|
||||
teacherSlots.forEach((lessons, key) => {
|
||||
if (lessons.length > 1) {
|
||||
// Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт
|
||||
const classroomIds = new Set(lessons.map(l => l.classroomId));
|
||||
if (classroomIds.size > 1) {
|
||||
const first = lessons[0];
|
||||
const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', ');
|
||||
conflicts.push({
|
||||
type: 'TEACHER_COLLISION',
|
||||
title: 'Накладка в расписании преподавателя',
|
||||
description: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Проверка накладок аудиторий
|
||||
// Ключ: date + '_' + timeSlotOrder + '_' + classroomId
|
||||
const classroomSlots = new Map();
|
||||
weekLessons.forEach(l => {
|
||||
if (!l.classroomId) return;
|
||||
const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`;
|
||||
if (!classroomSlots.has(key)) {
|
||||
classroomSlots.set(key, []);
|
||||
}
|
||||
classroomSlots.get(key).push(l);
|
||||
});
|
||||
|
||||
classroomSlots.forEach((lessons, key) => {
|
||||
if (lessons.length > 1) {
|
||||
// Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина)
|
||||
const teacherIds = new Set(lessons.map(l => l.teacherId));
|
||||
const subjectIds = new Set(lessons.map(l => l.subjectId));
|
||||
|
||||
if (teacherIds.size > 1 || subjectIds.size > 1) {
|
||||
const first = lessons[0];
|
||||
const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И ');
|
||||
conflicts.push({
|
||||
type: 'CLASSROOM_COLLISION',
|
||||
title: 'Накладка в аудитории',
|
||||
description: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Проверка превышения вместимости аудитории
|
||||
// Считаем сумму студентов для каждого уникального занятия в аудитории
|
||||
classroomSlots.forEach((lessons, key) => {
|
||||
if (!lessons.length) return;
|
||||
const first = lessons[0];
|
||||
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
|
||||
|
||||
// Суммируем численность всех групп, назначенных на этот слот в этой аудитории
|
||||
const groupIds = new Set();
|
||||
lessons.forEach(l => {
|
||||
if (l.groupIds) {
|
||||
l.groupIds.forEach(id => groupIds.add(id));
|
||||
}
|
||||
});
|
||||
|
||||
let totalStudents = 0;
|
||||
groupIds.forEach(id => {
|
||||
totalStudents += groupSizeMap.get(id) || 0;
|
||||
});
|
||||
|
||||
if (totalStudents > capacity) {
|
||||
const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', ');
|
||||
conflicts.push({
|
||||
type: 'CAPACITY_OVERFLOW',
|
||||
title: 'Превышение вместимости аудитории',
|
||||
description: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||
severity: 'warning'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
|
||||
} catch (e) {
|
||||
console.error('Не удалось выполнить проверку конфликтов', {
|
||||
errorType: e?.name || 'Error'
|
||||
});
|
||||
conflictsContainer.innerHTML = renderCheckStateCard({
|
||||
tone: 'error',
|
||||
title: 'Проверка не выполнена',
|
||||
description: 'Во время анализа произошла ошибка. Повторите проверку позже.'
|
||||
});
|
||||
} finally {
|
||||
conflictsContainer.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
function renderConflicts(conflicts, scheduleLoad, range) {
|
||||
const stateCards = [];
|
||||
const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range);
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||
conflictsContainer.innerHTML = renderCheckStateCard(presentation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (presentation) {
|
||||
stateCards.push(renderCheckStateCard(presentation));
|
||||
}
|
||||
|
||||
if (!conflicts.length) {
|
||||
conflictsContainer.innerHTML = stateCards.join('');
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictCards = conflicts.map(c => {
|
||||
const isError = c.severity === 'error';
|
||||
const badgeIcon = isError
|
||||
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
|
||||
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
|
||||
|
||||
return `
|
||||
<div class="dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}">
|
||||
<div class="dashboard-conflict-card__icon">${badgeIcon}</div>
|
||||
<div class="dashboard-conflict-card__body">
|
||||
<h4>${escapeHtml(c.title)}</h4>
|
||||
<p>${escapeHtml(c.description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
conflictsContainer.innerHTML = stateCards.join('') + conflictCards;
|
||||
}
|
||||
|
||||
function renderCheckStateCard({ tone, title, description, meta = '' }) {
|
||||
const icon = tone === 'success'
|
||||
? '<path d="M20 6 9 17l-5-5" />'
|
||||
: tone === 'warning'
|
||||
? '<path d="M12 9v4m0 4h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />'
|
||||
: '<circle cx="12" cy="12" r="9" /><path d="M12 8v5m0 3h.01" />';
|
||||
return `
|
||||
<section class="dashboard-check-state dashboard-check-state--${tone}">
|
||||
<span class="dashboard-check-state__icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
|
||||
</span>
|
||||
<span class="dashboard-check-state__body">
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span>${escapeHtml(description)}</span>
|
||||
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||||
</span>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Загружаем данные при старте
|
||||
await loadData();
|
||||
|
||||
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
|
||||
const realtimeTimer = setInterval(() => {
|
||||
if (document.getElementById('current-time-slot')) {
|
||||
updateRealtimeMonitoring();
|
||||
} else {
|
||||
clearInterval(realtimeTimer);
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||
import { defaultSchedulePeriod } from '../schedule-period.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
|
||||
export async function initDepartmentWorkspace() {
|
||||
const session = getSession();
|
||||
const role = session?.role;
|
||||
const userDepartmentId = session?.departmentId;
|
||||
const departmentField = document.getElementById('department-workspace-department-field');
|
||||
const departmentSelect = document.getElementById('department-workspace-department');
|
||||
const startInput = document.getElementById('department-workspace-start');
|
||||
const endInput = document.getElementById('department-workspace-end');
|
||||
const refreshButton = document.getElementById('department-workspace-refresh');
|
||||
const importForm = document.getElementById('department-subject-import-form');
|
||||
const importRowsContainer = document.getElementById('subject-import-rows');
|
||||
const importAddButton = document.getElementById('subject-import-add');
|
||||
const assignTeacherForm = document.getElementById('department-teacher-assign-form');
|
||||
const assignTeacherSelect = document.getElementById('department-teacher-select');
|
||||
const assignTeacherComment = document.getElementById('department-teacher-assign-comment');
|
||||
const teacherRequestForm = document.getElementById('department-teacher-request-form');
|
||||
const teacherRequestsTbody = document.getElementById('department-teacher-requests-tbody');
|
||||
const teacherRequestCount = document.getElementById('department-teacher-request-count');
|
||||
const subjectsTbody = document.getElementById('department-subjects-tbody');
|
||||
const subjectCount = document.getElementById('department-subject-count');
|
||||
const commentsCard = document.getElementById('department-comments-card');
|
||||
const commentsTitle = document.getElementById('department-comments-title');
|
||||
const commentsList = document.getElementById('department-comment-list');
|
||||
const commentsForm = document.getElementById('department-comment-form');
|
||||
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
|
||||
const commentsTextInput = document.getElementById('department-comment-text');
|
||||
const commentsCloseButton = document.getElementById('department-comments-close');
|
||||
let lastTeachers = null;
|
||||
let lastWorkload = null;
|
||||
let allTeachers = [];
|
||||
let teacherRequests = null;
|
||||
let teachersErrorMessage = '';
|
||||
let workloadErrorMessage = '';
|
||||
|
||||
setDefaultDates(startInput, endInput);
|
||||
refreshButton?.addEventListener('click', loadWorkspace);
|
||||
departmentSelect?.addEventListener('change', loadWorkspace);
|
||||
startInput?.addEventListener('change', handleStartDateChange);
|
||||
endInput?.addEventListener('change', loadWorkload);
|
||||
importForm?.addEventListener('submit', importSubjects);
|
||||
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
|
||||
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
|
||||
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
|
||||
importRowsContainer?.addEventListener('click', (event) => {
|
||||
const removeBtn = event.target.closest('.subject-import-remove');
|
||||
if (removeBtn) {
|
||||
const rows = readImportRows();
|
||||
rows.splice(Number(removeBtn.dataset.index), 1);
|
||||
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
|
||||
}
|
||||
});
|
||||
commentsForm?.addEventListener('submit', saveComment);
|
||||
commentsCloseButton?.addEventListener('click', () => {
|
||||
commentsCard.hidden = true;
|
||||
commentsSubjectInput.value = '';
|
||||
});
|
||||
subjectsTbody?.addEventListener('click', event => {
|
||||
const button = event.target.closest('.department-comments-open');
|
||||
if (button) {
|
||||
openComments(button.dataset.subjectId, button.dataset.subjectName);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await loadDepartments(role, userDepartmentId);
|
||||
if (role === ROLE_DEPARTMENT) {
|
||||
departmentField.hidden = true;
|
||||
departmentSelect.value = userDepartmentId || '';
|
||||
}
|
||||
await selectDefaultWorkloadPeriod(startInput, endInput);
|
||||
await loadAllTeachers();
|
||||
renderImportRows([{ code: '', name: '' }]);
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
hideAlert('department-workspace-alert');
|
||||
lastTeachers = null;
|
||||
lastWorkload = null;
|
||||
teachersErrorMessage = '';
|
||||
workloadErrorMessage = '';
|
||||
renderTeachers();
|
||||
teacherRequests = null;
|
||||
renderTeacherRequests();
|
||||
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
const departmentId = selectedDepartmentId();
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
subjectCount.textContent = 'Загрузка...';
|
||||
try {
|
||||
const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`);
|
||||
subjectCount.textContent = subjectCountLabel(subjects.length);
|
||||
if (!subjects.length) {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Дисциплины не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
subjectsTbody.innerHTML = subjects.map(subject => `
|
||||
<tr>
|
||||
<td>${escapeHtml(subject.code || '-')}</td>
|
||||
<td>${escapeHtml(subject.name || '-')}</td>
|
||||
<td>${escapeHtml(subject.description || '-')}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-secondary department-comments-open"
|
||||
data-subject-id="${escapeHtml(subject.id)}"
|
||||
data-subject-name="${escapeHtml(subject.name || '')}">
|
||||
Открыть
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}</td></tr>`;
|
||||
subjectCount.textContent = '0 дисциплин';
|
||||
}
|
||||
}
|
||||
|
||||
function renderImportRows(rows) {
|
||||
if (!importRowsContainer) return;
|
||||
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
|
||||
<div class="subject-import-row" data-index="${index}">
|
||||
<div class="form-group">
|
||||
<label>Код</label>
|
||||
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function readImportRows() {
|
||||
if (!importRowsContainer) return [];
|
||||
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
|
||||
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
|
||||
name: (row.querySelector('.subject-import-name')?.value || '').trim()
|
||||
}));
|
||||
}
|
||||
|
||||
async function importSubjects(event) {
|
||||
event.preventDefault();
|
||||
const alertId = 'department-subject-import-alert';
|
||||
hideAlert(alertId);
|
||||
const departmentId = selectedDepartmentId();
|
||||
const rows = readImportRows()
|
||||
.filter(item => item.name)
|
||||
.map(item => ({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
departmentId: Number(departmentId)
|
||||
}));
|
||||
|
||||
if (!departmentId) {
|
||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.post('/api/department/subjects/import', rows);
|
||||
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
|
||||
renderImportRows([{ code: '', name: '' }]);
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function assignTeacherToDepartment(event) {
|
||||
event.preventDefault();
|
||||
const alertId = 'department-teacher-assign-alert';
|
||||
hideAlert(alertId);
|
||||
const teacherId = assignTeacherSelect?.value;
|
||||
const departmentId = selectedDepartmentId();
|
||||
if (!departmentId) {
|
||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||
return;
|
||||
}
|
||||
if (!teacherId) {
|
||||
showAlert(alertId, 'Выберите преподавателя', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`, {
|
||||
departmentId: Number(departmentId),
|
||||
comment: assignTeacherComment?.value.trim() || null
|
||||
});
|
||||
showAlert(alertId, 'Преподаватель привязан к кафедре', 'success');
|
||||
if (assignTeacherComment) assignTeacherComment.value = '';
|
||||
await Promise.all([loadTeachers(), loadWorkload()]);
|
||||
} catch (error) {
|
||||
showAlert(alertId, error.message || 'Ошибка привязки преподавателя', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTeacherRequest(event) {
|
||||
event.preventDefault();
|
||||
const alertId = 'department-teacher-request-alert';
|
||||
hideAlert(alertId);
|
||||
const departmentId = selectedDepartmentId();
|
||||
const username = document.getElementById('teacher-request-username')?.value.trim();
|
||||
const fullName = document.getElementById('teacher-request-fullname')?.value.trim();
|
||||
const jobTitle = document.getElementById('teacher-request-jobtitle')?.value.trim();
|
||||
const comment = document.getElementById('teacher-request-comment')?.value.trim();
|
||||
if (!departmentId) {
|
||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||
return;
|
||||
}
|
||||
if (!username || !fullName) {
|
||||
showAlert(alertId, 'Заполните логин и ФИО преподавателя', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/department/teacher-requests', {
|
||||
departmentId: Number(departmentId),
|
||||
username,
|
||||
fullName,
|
||||
jobTitle,
|
||||
comment
|
||||
});
|
||||
showAlert(alertId, 'Заявка отправлена администратору', 'success');
|
||||
teacherRequestForm.reset();
|
||||
await loadTeacherRequests();
|
||||
} catch (error) {
|
||||
showAlert(alertId, error.message || 'Ошибка отправки заявки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function openComments(subjectId, subjectName) {
|
||||
commentsCard.hidden = false;
|
||||
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
|
||||
commentsSubjectInput.value = subjectId;
|
||||
commentsTextInput.value = '';
|
||||
hideAlert('department-comment-alert');
|
||||
commentsList.textContent = 'Загрузка...';
|
||||
|
||||
try {
|
||||
const comments = await api.get(`/api/department/subjects/${subjectId}/comments`);
|
||||
commentsList.innerHTML = comments.length
|
||||
? comments.map(comment => `
|
||||
<article class="department-comment-item">
|
||||
<strong>${escapeHtml(comment.authorName || 'Автор не указан')}</strong>
|
||||
<span>${escapeHtml(formatDateTime(comment.createdAt))}</span>
|
||||
<p>${escapeHtml(comment.comment)}</p>
|
||||
</article>
|
||||
`).join('')
|
||||
: '<div class="loading-row">Комментариев пока нет</div>';
|
||||
} catch (error) {
|
||||
commentsList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки комментариев')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment(event) {
|
||||
event.preventDefault();
|
||||
const subjectId = commentsSubjectInput.value;
|
||||
const comment = commentsTextInput.value.trim();
|
||||
if (!subjectId) {
|
||||
showAlert('department-comment-alert', 'Выберите дисциплину', 'error');
|
||||
return;
|
||||
}
|
||||
if (!comment) {
|
||||
showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/api/department/subjects/${subjectId}/comments`, { comment });
|
||||
showAlert('department-comment-alert', 'Комментарий сохранён', 'success');
|
||||
await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', ''));
|
||||
} catch (error) {
|
||||
showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeachers() {
|
||||
lastTeachers = null;
|
||||
teachersErrorMessage = '';
|
||||
renderTeachers();
|
||||
renderTeacherSelect();
|
||||
try {
|
||||
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
||||
lastTeachers = Array.isArray(teachers) ? teachers : [];
|
||||
renderTeachers();
|
||||
renderTeacherSelect();
|
||||
} catch (error) {
|
||||
lastTeachers = [];
|
||||
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
|
||||
renderTeachers();
|
||||
renderTeacherSelect();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllTeachers() {
|
||||
try {
|
||||
const teachers = await api.get('/api/users/teachers');
|
||||
allTeachers = Array.isArray(teachers) ? teachers : [];
|
||||
renderTeacherSelect();
|
||||
} catch (error) {
|
||||
allTeachers = [];
|
||||
if (assignTeacherSelect) {
|
||||
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeacherRequests() {
|
||||
teacherRequests = null;
|
||||
renderTeacherRequests();
|
||||
try {
|
||||
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
||||
teacherRequests = Array.isArray(requests) ? requests : [];
|
||||
renderTeacherRequests();
|
||||
} catch (error) {
|
||||
teacherRequests = [];
|
||||
if (teacherRequestsTbody) {
|
||||
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
|
||||
}
|
||||
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkload() {
|
||||
if (!startInput.value || !endInput.value) {
|
||||
lastWorkload = [];
|
||||
workloadErrorMessage = 'Выберите начало и окончание периода';
|
||||
renderTeachers();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
startDate: startInput.value,
|
||||
endDate: endInput.value
|
||||
});
|
||||
if (selectedDepartmentId()) {
|
||||
params.set('departmentId', selectedDepartmentId());
|
||||
}
|
||||
lastWorkload = null;
|
||||
workloadErrorMessage = '';
|
||||
renderTeachers();
|
||||
try {
|
||||
const workload = await api.get(`/api/workload/teachers?${params}`);
|
||||
lastWorkload = Array.isArray(workload) ? workload : [];
|
||||
renderTeachers();
|
||||
} catch (error) {
|
||||
lastWorkload = [];
|
||||
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
|
||||
renderTeachers();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartDateChange() {
|
||||
if (startInput.value && endInput.value && startInput.value > endInput.value) {
|
||||
endInput.value = '';
|
||||
}
|
||||
await loadWorkload();
|
||||
}
|
||||
|
||||
function renderTeachers() {
|
||||
const container = document.getElementById('department-teachers-list');
|
||||
if (!container) return;
|
||||
|
||||
if (lastTeachers === null || lastWorkload === null) {
|
||||
container.textContent = 'Загрузка...';
|
||||
return;
|
||||
}
|
||||
|
||||
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
|
||||
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
|
||||
const workloadByTeacher = new Map();
|
||||
const usedWorkloadKeys = new Set();
|
||||
|
||||
workload
|
||||
.filter(item => item && (item.id || item.name))
|
||||
.forEach(item => {
|
||||
workloadKeys(item).forEach(key => {
|
||||
if (!workloadByTeacher.has(key)) {
|
||||
workloadByTeacher.set(key, item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rows = teachers.map(teacher => {
|
||||
const workloadItem = workloadKeys(teacher)
|
||||
.map(key => workloadByTeacher.get(key))
|
||||
.find(Boolean);
|
||||
if (workloadItem) {
|
||||
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
|
||||
}
|
||||
return renderTeacherMetric({
|
||||
name: teacher.fullName || teacher.username || '-',
|
||||
meta: teacher.jobTitle || 'Должность не указана',
|
||||
workload: workloadItem
|
||||
}, workloadErrorMessage);
|
||||
});
|
||||
|
||||
workload
|
||||
.filter(item => item && (item.id || item.name))
|
||||
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
|
||||
.forEach(item => {
|
||||
rows.push(renderTeacherMetric({
|
||||
name: item.name || '-',
|
||||
meta: 'Есть занятия в расписании кафедры',
|
||||
workload: item
|
||||
}, workloadErrorMessage));
|
||||
});
|
||||
|
||||
if (rows.length) {
|
||||
container.innerHTML = rows.join('');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
|
||||
}
|
||||
|
||||
function renderTeacherSelect() {
|
||||
if (!assignTeacherSelect) return;
|
||||
if (lastTeachers === null) {
|
||||
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
|
||||
return;
|
||||
}
|
||||
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
|
||||
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
|
||||
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||
availableTeachers.map(teacher => `
|
||||
<option value="${escapeHtml(teacher.id)}">
|
||||
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
|
||||
</option>
|
||||
`).join('');
|
||||
if (!availableTeachers.length) {
|
||||
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTeacherRequests() {
|
||||
if (!teacherRequestsTbody) return;
|
||||
if (teacherRequests === null) {
|
||||
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
|
||||
return;
|
||||
}
|
||||
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
|
||||
if (!teacherRequests.length) {
|
||||
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
|
||||
return;
|
||||
}
|
||||
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
|
||||
<tr>
|
||||
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
|
||||
<td>${escapeHtml(request.username || '-')}</td>
|
||||
<td>${escapeHtml(request.fullName || '-')}</td>
|
||||
<td>${escapeHtml(request.jobTitle || '-')}</td>
|
||||
<td>${escapeHtml(request.comment || '-')}</td>
|
||||
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectedDepartmentId() {
|
||||
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDefaultWorkloadPeriod(startInput, endInput) {
|
||||
try {
|
||||
const semesters = await api.get('/api/schedule/semesters');
|
||||
const period = defaultSchedulePeriod(
|
||||
semesters,
|
||||
formatLocalDate(new Date())
|
||||
);
|
||||
startInput.value = period.startDate;
|
||||
endInput.value = period.endDate;
|
||||
} catch (_error) {
|
||||
// Текущий диапазон остаётся безопасным fallback, если справочник семестров недоступен.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartments(role, userDepartmentId) {
|
||||
const departments = await api.get('/api/departments');
|
||||
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
|
||||
if (role === ROLE_DEPARTMENT && userDepartmentId) {
|
||||
document.getElementById('department-workspace-department').value = userDepartmentId;
|
||||
} else if (!document.getElementById('department-workspace-department').value && departments.length) {
|
||||
document.getElementById('department-workspace-department').value = departments[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
|
||||
const select = document.getElementById(id);
|
||||
if (!select) return;
|
||||
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
|
||||
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
|
||||
}
|
||||
|
||||
function setDefaultDates(startInput, endInput) {
|
||||
const today = new Date();
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(today.getDate() + 7);
|
||||
startInput.value = formatLocalDate(today);
|
||||
endInput.value = formatLocalDate(nextWeek);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
return String(value).replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function subjectCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
|
||||
if (count % 10 === 1) return `${count} дисциплина`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
|
||||
return `${count} дисциплин`;
|
||||
}
|
||||
|
||||
function requestCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} заявок`;
|
||||
if (count % 10 === 1) return `${count} заявка`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
|
||||
return `${count} заявок`;
|
||||
}
|
||||
|
||||
function requestStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На рассмотрении',
|
||||
APPROVED: 'Одобрена',
|
||||
REJECTED: 'Отклонена'
|
||||
}[status] || status || '-';
|
||||
}
|
||||
|
||||
function requestStatusClass(status) {
|
||||
return {
|
||||
PENDING: 'badge-ef',
|
||||
APPROVED: 'badge-student',
|
||||
REJECTED: 'badge-admin'
|
||||
}[status] || 'badge-ef';
|
||||
}
|
||||
|
||||
function renderTeacherMetric(item, workloadErrorMessage) {
|
||||
return `
|
||||
<article class="metric-card">
|
||||
<strong>${escapeHtml(item.name || '-')}</strong>
|
||||
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
|
||||
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function workloadLabel(item, workloadErrorMessage) {
|
||||
if (workloadErrorMessage) {
|
||||
return workloadErrorMessage;
|
||||
}
|
||||
if (!item) {
|
||||
return '0 ак. ч., 0 занятий за период';
|
||||
}
|
||||
const hours = String(item.academicHours ?? 0);
|
||||
const lessons = String(item.lessonCount ?? 0);
|
||||
return `${hours} ак. ч., ${lessons} занятий за период`;
|
||||
}
|
||||
|
||||
function workloadKeys(item) {
|
||||
return [
|
||||
item.id == null ? null : `id:${item.id}`,
|
||||
normalizeTeacherName(item.fullName || item.name || item.username)
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTeacherName(value) {
|
||||
return value ? `name:${String(value).trim().toLowerCase()}` : null;
|
||||
}
|
||||
@@ -1,990 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { alertAction, confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, readUrlValues, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
async function fetchEducationForms() {
|
||||
try {
|
||||
return await api.get('/api/education-forms');
|
||||
} catch (e) {
|
||||
console.error('Не удалось загрузить формы обучения');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
|
||||
|
||||
export async function initGroups() {
|
||||
const groupsTbody = document.getElementById('groups-tbody');
|
||||
const createGroupForm = document.getElementById('create-group-form');
|
||||
const newGroupEfSelect = document.getElementById('new-group-ef');
|
||||
const newGroupDepartmentSelect = document.getElementById('new-group-department');
|
||||
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
|
||||
const newGroupProfileSelect = document.getElementById('new-group-profile');
|
||||
const filterEfBox = document.getElementById('filter-ef-box');
|
||||
const filterEfMenu = document.getElementById('filter-ef-menu');
|
||||
const filterEfText = document.getElementById('filter-ef-text');
|
||||
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
|
||||
const searchInput = document.getElementById('groups-search');
|
||||
const sortSelect = document.getElementById('groups-sort');
|
||||
const filterResetButton = document.getElementById('groups-filter-reset');
|
||||
const pagination = document.getElementById('groups-pagination');
|
||||
|
||||
// Модалка группы
|
||||
const modalEditGroup = document.getElementById('modal-edit-group');
|
||||
const modalEditGroupClose = document.getElementById('modal-edit-group-close');
|
||||
const editGroupForm = document.getElementById('edit-group-form');
|
||||
const editGroupEfSelect = document.getElementById('edit-group-ef');
|
||||
const editGroupDepartmentSelect = document.getElementById('edit-group-department');
|
||||
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
|
||||
const editGroupProfileSelect = document.getElementById('edit-group-profile');
|
||||
|
||||
// Модалка подгрупп
|
||||
const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
|
||||
const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
|
||||
const subgroupsGroupContext = document.getElementById('subgroups-group-context');
|
||||
const subgroupFormTitle = document.getElementById('subgroup-form-title');
|
||||
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
|
||||
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
|
||||
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
|
||||
const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
|
||||
const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
|
||||
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
|
||||
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
|
||||
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
|
||||
|
||||
// Модалка календарей
|
||||
const modalManageCalendar = document.getElementById('modal-manage-calendar');
|
||||
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
|
||||
const calendarGroupContext = document.getElementById('calendar-group-context');
|
||||
const manageCalendarForm = document.getElementById('manage-calendar-form');
|
||||
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
|
||||
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
|
||||
const manageCalendarSelect = document.getElementById('manage-calendar-id');
|
||||
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
|
||||
|
||||
let allGroups = [];
|
||||
let subgroups = [];
|
||||
let educationForms = [];
|
||||
let departments = [];
|
||||
let specialties = [];
|
||||
let profilesBySpecialty = new Map();
|
||||
let academicYears = [];
|
||||
let currentAssignments = [];
|
||||
let activeSubgroupGroup = null;
|
||||
let calendarCombobox = null;
|
||||
const restored = readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' });
|
||||
const pageState = {
|
||||
query: restored.groupQuery,
|
||||
page: Math.max(Number(restored.groupPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.groupSize)) ? Number(restored.groupSize) : 25,
|
||||
sort: restored.groupSort,
|
||||
educationFormIds: readUrlValues('groupEducationForm')
|
||||
};
|
||||
searchInput.value = pageState.query;
|
||||
sortSelect.value = pageState.sort;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
|
||||
function bindEvents() {
|
||||
initFilterEfDropdown();
|
||||
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
|
||||
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
|
||||
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
|
||||
|
||||
createGroupForm.addEventListener('submit', createGroup);
|
||||
editGroupForm.addEventListener('submit', updateGroup);
|
||||
|
||||
// Модалка подгрупп
|
||||
manageSubgroupForm.addEventListener('submit', saveSubgroup);
|
||||
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
|
||||
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
|
||||
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
||||
|
||||
// Модалка календарей
|
||||
manageCalendarForm.addEventListener('submit', saveAssignment);
|
||||
manageCalendarYearSelect.addEventListener('change', () => {
|
||||
calendarCombobox?.clear();
|
||||
populateCalendarSelect();
|
||||
});
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
pageState.query = searchInput.value.trim();
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
}));
|
||||
sortSelect.addEventListener('change', () => {
|
||||
pageState.sort = sortSelect.value;
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
});
|
||||
filterResetButton.addEventListener('click', () => {
|
||||
pageState.query = '';
|
||||
pageState.page = 0;
|
||||
pageState.sort = 'name,asc';
|
||||
pageState.educationFormIds = [];
|
||||
searchInput.value = '';
|
||||
sortSelect.value = pageState.sort;
|
||||
renderFilterEfCheckboxes(educationForms);
|
||||
loadGroups();
|
||||
});
|
||||
|
||||
groupsTbody.addEventListener('click', handleGroupTableClick);
|
||||
|
||||
// Закрытие модалок
|
||||
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
|
||||
modalEditGroup.addEventListener('click', (event) => {
|
||||
if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
|
||||
});
|
||||
|
||||
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
|
||||
modalManageSubgroups.addEventListener('click', (event) => {
|
||||
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
|
||||
});
|
||||
|
||||
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
|
||||
modalManageCalendar.addEventListener('click', (event) => {
|
||||
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
function initFilterEfDropdown() {
|
||||
if (!filterEfBox || !filterEfMenu) return;
|
||||
|
||||
const toggleDropdown = (event) => {
|
||||
event.stopPropagation();
|
||||
const isOpen = filterEfMenu.classList.contains('open');
|
||||
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
|
||||
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
|
||||
|
||||
if (!isOpen) {
|
||||
filterEfMenu.classList.add('open');
|
||||
filterEfBox.classList.add('active');
|
||||
filterEfBox.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
filterEfBox.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
filterEfBox.addEventListener('click', toggleDropdown);
|
||||
filterEfBox.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggleDropdown(event);
|
||||
}
|
||||
});
|
||||
|
||||
filterEfMenu.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
[educationForms, departments, specialties, academicYears] = await Promise.all([
|
||||
fetchEducationForms(),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/specialties'),
|
||||
api.get('/api/admin/calendar/years')
|
||||
]);
|
||||
await loadProfiles();
|
||||
populateEfSelects(educationForms);
|
||||
populateDepartmentSelects(departments);
|
||||
populateSpecialitySelects(specialties);
|
||||
populateYearSelect();
|
||||
initCalendarCombobox();
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
const pairs = await Promise.all(specialties.map(async (speciality) => {
|
||||
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
|
||||
return [String(speciality.id), profiles];
|
||||
}));
|
||||
profilesBySpecialty = new Map(pairs);
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
renderTableLoading(groupsTbody, 10, 'Загрузка групп...');
|
||||
try {
|
||||
const [sort, direction] = pageState.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: pageState.query,
|
||||
page: String(pageState.page),
|
||||
size: String(pageState.size),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
pageState.educationFormIds.forEach(id => params.append('educationFormId', id));
|
||||
const result = await api.get(`/api/groups/page?${params}`);
|
||||
allGroups = result.items || [];
|
||||
if (!allGroups.length && result.totalItems > 0 && pageState.page > 0) {
|
||||
pageState.page = Math.max(0, result.totalPages - 1);
|
||||
return loadGroups();
|
||||
}
|
||||
renderGroups(allGroups);
|
||||
renderPagination(pagination, result, {
|
||||
label: 'групп',
|
||||
onPage: page => { pageState.page = page; loadGroups(); },
|
||||
onSize: size => { pageState.size = size; pageState.page = 0; loadGroups(); }
|
||||
});
|
||||
persistPageState();
|
||||
trackUxEvent('реестр_групп_загружен', { total: Number(result.totalItems || 0), page: pageState.page });
|
||||
} catch (error) {
|
||||
renderTableError(groupsTbody, 10, error.message || 'Не удалось загрузить группы', loadGroups);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroups(groupId) {
|
||||
try {
|
||||
subgroups = groupId ? await api.get(`/api/groups/${groupId}/subgroups`) : [];
|
||||
} catch (error) {
|
||||
subgroups = [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGroupFilter() {
|
||||
pageState.educationFormIds = selectedFilterEducationFormIds();
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
function handleFilterEfChange(event) {
|
||||
const changedCheckbox = event.target.closest('input[type="checkbox"]');
|
||||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||||
|
||||
if (changedCheckbox?.value === 'all' && changedCheckbox.checked) {
|
||||
filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
} else if (changedCheckbox?.value !== 'all' && changedCheckbox?.checked) {
|
||||
allCheckbox.checked = false;
|
||||
}
|
||||
|
||||
if (!selectedFilterEducationFormIds().length) {
|
||||
allCheckbox.checked = true;
|
||||
}
|
||||
|
||||
updateFilterEfText();
|
||||
applyGroupFilter();
|
||||
}
|
||||
|
||||
function selectedFilterEducationFormIds() {
|
||||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||||
if (allCheckbox?.checked) return [];
|
||||
|
||||
return Array.from(filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'))
|
||||
.map(checkbox => String(checkbox.value));
|
||||
}
|
||||
|
||||
function updateFilterEfText() {
|
||||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||||
const selectedIds = selectedFilterEducationFormIds();
|
||||
if (allCheckbox?.checked) {
|
||||
filterEfText.textContent = 'Все';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedIds.length) {
|
||||
filterEfText.textContent = FILTER_EF_EMPTY_TEXT;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIds.length === 1) {
|
||||
const form = educationForms.find(item => String(item.id) === selectedIds[0]);
|
||||
filterEfText.textContent = form?.name || FILTER_EF_EMPTY_TEXT;
|
||||
return;
|
||||
}
|
||||
|
||||
filterEfText.textContent = `Выбрано: ${selectedIds.length}`;
|
||||
}
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
renderTableEmpty(groupsTbody, 10, pageState.query || pageState.educationFormIds.length
|
||||
? 'По заданным условиям группы не найдены'
|
||||
: 'Групп пока нет');
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(group => `
|
||||
<tr>
|
||||
<td>${group.id}</td>
|
||||
<td><strong>${escapeHtml(group.name)}</strong></td>
|
||||
<td>${escapeHtml(String(group.groupSize))}</td>
|
||||
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
|
||||
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
|
||||
<td>${group.course || '-'}</td>
|
||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||||
<td class="group-actions-cell">
|
||||
<div class="group-actions">
|
||||
<button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
|
||||
${group.status === 'ARCHIVED'
|
||||
? `<button class="btn btn-sm btn-secondary btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(groupsTbody);
|
||||
}
|
||||
|
||||
function populateEfSelects(forms) {
|
||||
populateEfSelect(newGroupEfSelect, forms);
|
||||
populateEfSelect(editGroupEfSelect, forms);
|
||||
renderFilterEfCheckboxes(forms);
|
||||
}
|
||||
|
||||
function renderFilterEfCheckboxes(forms) {
|
||||
const selectedIds = new Set(pageState.educationFormIds);
|
||||
const allChecked = !selectedIds.size;
|
||||
filterEfCheckboxes.innerHTML = `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>Все</span>
|
||||
</label>
|
||||
${forms.map(form => {
|
||||
const id = String(form.id);
|
||||
return `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>${escapeHtml(form.name)}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('')}
|
||||
`;
|
||||
updateFilterEfText();
|
||||
}
|
||||
|
||||
function populateEfSelect(select, forms) {
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||||
if (currentVal && forms.find(form => form.id == currentVal)) {
|
||||
select.value = currentVal;
|
||||
}
|
||||
syncSelects(select);
|
||||
}
|
||||
|
||||
function populateDepartmentSelects(items) {
|
||||
populateDepartmentSelect(newGroupDepartmentSelect, items);
|
||||
populateDepartmentSelect(editGroupDepartmentSelect, items);
|
||||
}
|
||||
|
||||
function populateDepartmentSelect(select, items) {
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||||
items.map(department => {
|
||||
const name = department.departmentName || department.name || 'Без названия';
|
||||
const code = department.departmentCode || department.code || department.id;
|
||||
return `<option value="${department.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
|
||||
}).join('');
|
||||
if (currentVal && items.find(department => department.id == currentVal)) {
|
||||
select.value = currentVal;
|
||||
}
|
||||
syncSelects(select);
|
||||
}
|
||||
|
||||
function populateSpecialitySelects(items) {
|
||||
populateSpecialitySelect(newGroupSpecialitySelect, items);
|
||||
populateSpecialitySelect(editGroupSpecialitySelect, items);
|
||||
populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value);
|
||||
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value);
|
||||
}
|
||||
|
||||
function populateSpecialitySelect(select, items) {
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="">Выберите специальность</option>' +
|
||||
items.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality.id))}</option>`).join('');
|
||||
if (currentVal && items.find(speciality => speciality.id == currentVal)) {
|
||||
select.value = currentVal;
|
||||
}
|
||||
syncSelects(select);
|
||||
}
|
||||
|
||||
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
|
||||
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
|
||||
select.innerHTML = profiles.length
|
||||
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
|
||||
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет профилей</option>';
|
||||
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
|
||||
select.value = selectedProfileId;
|
||||
}
|
||||
syncSelects(select);
|
||||
}
|
||||
|
||||
function populateYearSelect() {
|
||||
manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
if (academicYears.length) {
|
||||
manageCalendarYearSelect.value = String(academicYears[0].id);
|
||||
}
|
||||
syncSelects(manageCalendarYearSelect);
|
||||
}
|
||||
|
||||
function populateCalendarSelect() {
|
||||
calendarCombobox?.search('');
|
||||
}
|
||||
|
||||
function initCalendarCombobox() {
|
||||
calendarCombobox = new AsyncCombobox(manageCalendarSelect, {
|
||||
placeholder: 'Найдите совместимый график',
|
||||
emptyLabel: 'Совместимых графиков не найдено',
|
||||
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`,
|
||||
optionDescription: calendar => `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`,
|
||||
load: async (query, selectedId) => {
|
||||
const group = allGroups.find(item => String(item.id) === String(manageCalendarGroupIdInput.value));
|
||||
if (!group || !manageCalendarYearSelect.value) return [];
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
academicYearId: manageCalendarYearSelect.value,
|
||||
specialtyId: String(group.specialtyId || group.specialityCode),
|
||||
profileId: String(group.specialtyProfileId),
|
||||
studyFormId: String(group.educationFormId),
|
||||
limit: '30'
|
||||
});
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/admin/academic-calendars/options?${params}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistPageState() {
|
||||
writeUrlState({
|
||||
groupQuery: pageState.query,
|
||||
groupPage: pageState.page || '',
|
||||
groupSize: pageState.size === 25 ? '' : pageState.size,
|
||||
groupSort: pageState.sort === 'name,asc' ? '' : pageState.sort,
|
||||
groupEducationForm: pageState.educationFormIds
|
||||
});
|
||||
}
|
||||
|
||||
async function createGroup(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('create-group-alert');
|
||||
const payload = readGroupPayload('new');
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/groups', payload);
|
||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" создана`, 'success');
|
||||
createGroupForm.reset();
|
||||
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGroup(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('edit-group-alert');
|
||||
const id = document.getElementById('edit-group-id').value;
|
||||
const payload = readGroupPayload('edit');
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
const data = await api.put('/api/groups/' + id, payload);
|
||||
modalEditGroup.classList.remove('open');
|
||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function readGroupPayload(prefix) {
|
||||
const isEdit = prefix === 'edit';
|
||||
const alertId = isEdit ? 'edit-group-alert' : 'create-group-alert';
|
||||
const name = document.getElementById(`${prefix}-group-name`).value.trim();
|
||||
const groupSize = document.getElementById(`${prefix}-group-size`).value;
|
||||
const educationFormId = (isEdit ? editGroupEfSelect : newGroupEfSelect).value;
|
||||
const departmentId = (isEdit ? editGroupDepartmentSelect : newGroupDepartmentSelect).value;
|
||||
const yearStartStudy = document.getElementById(`${prefix}-group-yearStartStudy`).value;
|
||||
const specialtyId = (isEdit ? editGroupSpecialitySelect : newGroupSpecialitySelect).value;
|
||||
const specialtyProfileId = (isEdit ? editGroupProfileSelect : newGroupProfileSelect).value;
|
||||
|
||||
if (!name) { showAlert(alertId, 'Введите название группы', 'error'); return null; }
|
||||
if (!groupSize) { showAlert(alertId, 'Введите размер группы', 'error'); return null; }
|
||||
if (!educationFormId) { showAlert(alertId, 'Выберите форму обучения', 'error'); return null; }
|
||||
if (!departmentId) { showAlert(alertId, 'Выберите кафедру', 'error'); return null; }
|
||||
if (!yearStartStudy) { showAlert(alertId, 'Введите год начала обучения', 'error'); return null; }
|
||||
if (!specialtyId) { showAlert(alertId, 'Выберите специальность', 'error'); return null; }
|
||||
if (!specialtyProfileId) { showAlert(alertId, 'Выберите профиль обучения', 'error'); return null; }
|
||||
|
||||
return {
|
||||
name,
|
||||
groupSize: Number(groupSize),
|
||||
educationFormId: Number(educationFormId),
|
||||
departmentId: Number(departmentId),
|
||||
yearStartStudy: Number(yearStartStudy),
|
||||
specialtyId: Number(specialtyId),
|
||||
specialtyProfileId: Number(specialtyProfileId)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGroupTableClick(event) {
|
||||
const btnDelete = event.target.closest('.btn-archive-group');
|
||||
const btnEdit = event.target.closest('.btn-edit-group');
|
||||
const btnRestore = event.target.closest('.btn-restore-group');
|
||||
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
|
||||
const btnCalendar = event.target.closest('.btn-manage-calendar');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать группу?',
|
||||
message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
await alertAction({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' });
|
||||
}
|
||||
}
|
||||
|
||||
if (btnRestore) {
|
||||
try {
|
||||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
await alertAction({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' });
|
||||
}
|
||||
}
|
||||
|
||||
if (btnEdit) {
|
||||
openEditGroupModal(btnEdit.dataset.id);
|
||||
}
|
||||
|
||||
if (btnSubgroups) {
|
||||
const groupId = btnSubgroups.dataset.id;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
if (group) await openSubgroupsModal(group);
|
||||
}
|
||||
|
||||
if (btnCalendar) {
|
||||
const groupId = btnCalendar.dataset.id;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
if (group) openCalendarModal(group);
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditGroupModal(id) {
|
||||
const group = allGroups.find(item => item.id == id);
|
||||
if (!group) {
|
||||
await alertAction({ title: 'Группа не найдена', message: 'Обновите список и повторите действие.' });
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('edit-group-id').value = group.id;
|
||||
document.getElementById('edit-group-name').value = group.name || '';
|
||||
document.getElementById('edit-group-size').value = group.groupSize || '';
|
||||
editGroupEfSelect.value = group.educationFormId || '';
|
||||
editGroupDepartmentSelect.value = group.departmentId || '';
|
||||
document.getElementById('edit-group-yearStartStudy').value = group.yearStartStudy || '';
|
||||
editGroupSpecialitySelect.value = group.specialtyId || group.specialityCode || '';
|
||||
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value, group.specialtyProfileId);
|
||||
syncSelects(editGroupEfSelect, editGroupDepartmentSelect, editGroupSpecialitySelect, editGroupProfileSelect);
|
||||
hideAlert('edit-group-alert');
|
||||
modalEditGroup.classList.add('open');
|
||||
}
|
||||
|
||||
// --- Управление подгруппами ---
|
||||
async function openSubgroupsModal(group) {
|
||||
activeSubgroupGroup = group;
|
||||
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||||
manageSubgroupGroupIdInput.value = group.id;
|
||||
modalManageSubgroups.classList.add('open');
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка подгрупп...</td></tr>';
|
||||
try {
|
||||
await loadSubgroups(group.id);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(group.id);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Не удалось загрузить подгруппы', 'error');
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Не удалось загрузить подгруппы</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubgroupsList(groupId) {
|
||||
const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
|
||||
if (!list.length) {
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
const canDeleteSingleSubgroup = list.length === 1;
|
||||
const deleteTitle = canDeleteSingleSubgroup
|
||||
? 'Удалить подгруппу'
|
||||
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
|
||||
manageSubgroupsTbody.innerHTML = list.map(sub => `
|
||||
<tr>
|
||||
<td>${escapeHtml(sub.name)}</td>
|
||||
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
|
||||
${canDeleteSingleSubgroup ? '' : 'disabled'}
|
||||
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function resetSubgroupForm() {
|
||||
manageSubgroupIdInput.value = '';
|
||||
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
|
||||
const count = [2, 3].includes(current.length) ? current.length : 0;
|
||||
manageSubgroupCountSelect.value = String(count);
|
||||
syncSelects(manageSubgroupCountSelect);
|
||||
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
|
||||
subgroupFormTitle.textContent = 'Настройка подгрупп';
|
||||
btnSaveSubgroup.textContent = 'Применить';
|
||||
btnCancelSubgroupEdit.hidden = true;
|
||||
hideAlert('manage-subgroup-alert');
|
||||
}
|
||||
|
||||
function currentGroupSubgroups(groupId) {
|
||||
return subgroups
|
||||
.filter(sub => String(sub.groupId) === String(groupId))
|
||||
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
|
||||
}
|
||||
|
||||
function naturalSubgroupOrder(name) {
|
||||
const number = String(name || '').match(/\d+/)?.[0];
|
||||
return number ? String(number).padStart(3, '0') : String(name || '');
|
||||
}
|
||||
|
||||
function renderSubgroupCapacityFields(count, capacities = []) {
|
||||
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
|
||||
if (!count) {
|
||||
manageSubgroupCapacities.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
|
||||
const suggested = suggestedSubgroupCapacities(groupSize, count);
|
||||
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
|
||||
const value = capacities[index] ?? suggested[index] ?? '';
|
||||
return `
|
||||
<div class="form-group subgroup-capacity-field">
|
||||
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
|
||||
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
|
||||
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function suggestedSubgroupCapacities(groupSize, count) {
|
||||
if (!groupSize || !count) return [];
|
||||
const base = Math.floor(groupSize / count);
|
||||
const remainder = groupSize % count;
|
||||
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
|
||||
}
|
||||
|
||||
async function saveSubgroup(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('manage-subgroup-alert');
|
||||
const groupId = manageSubgroupGroupIdInput.value;
|
||||
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
|
||||
const count = Number(manageSubgroupCountSelect.value);
|
||||
const existingSubgroups = currentGroupSubgroups(groupId);
|
||||
|
||||
if (!group) {
|
||||
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await applyNoSubgroups(groupId, existingSubgroups);
|
||||
return;
|
||||
}
|
||||
|
||||
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
|
||||
.map(input => Number(input.value));
|
||||
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
|
||||
if (validationError) {
|
||||
showAlert('manage-subgroup-alert', validationError, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await syncSubgroups(groupId, existingSubgroups, capacities);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
|
||||
await loadSubgroups(groupId);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function validateSubgroupCapacities(capacities, groupSize, count) {
|
||||
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
|
||||
return 'Укажите положительную численность для каждой подгруппы';
|
||||
}
|
||||
|
||||
const total = capacities.reduce((sum, value) => sum + value, 0);
|
||||
if (total !== groupSize) {
|
||||
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function applyNoSubgroups(groupId, existingSubgroups) {
|
||||
try {
|
||||
for (const subgroup of existingSubgroups) {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||||
}
|
||||
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
|
||||
await loadSubgroups(groupId);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка отключения подгрупп', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSubgroups(groupId, existingSubgroups, capacities) {
|
||||
const targetCount = capacities.length;
|
||||
const extraSubgroups = existingSubgroups.slice(targetCount);
|
||||
for (const subgroup of extraSubgroups) {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const creates = [];
|
||||
for (let index = 0; index < targetCount; index += 1) {
|
||||
const payload = {
|
||||
name: `Подгруппа ${index + 1}`,
|
||||
studentCapacity: capacities[index]
|
||||
};
|
||||
const existing = existingSubgroups[index];
|
||||
if (existing) {
|
||||
updates.push({ existing, payload });
|
||||
} else {
|
||||
creates.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
updates.sort((left, right) => {
|
||||
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
|
||||
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
|
||||
return Number(leftIncreases) - Number(rightIncreases);
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
await api.put(`/api/groups/${groupId}/subgroups/${update.existing.id}`, update.payload);
|
||||
}
|
||||
|
||||
for (const payload of creates) {
|
||||
await api.post(`/api/groups/${groupId}/subgroups`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubgroupTableClick(event) {
|
||||
const editButton = event.target.closest('.btn-edit-subgroup');
|
||||
const deleteButton = event.target.closest('.btn-delete-subgroup');
|
||||
const groupId = manageSubgroupGroupIdInput.value;
|
||||
|
||||
if (editButton) {
|
||||
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
|
||||
if (!subgroup) return;
|
||||
|
||||
resetSubgroupForm();
|
||||
const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
|
||||
const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
|
||||
input?.focus();
|
||||
hideAlert('manage-subgroup-alert');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (deleteButton.disabled) {
|
||||
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
|
||||
return;
|
||||
}
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить подгруппу?',
|
||||
message: 'Удаление возможно только если подгруппа не используется в расписании.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
|
||||
resetSubgroupForm();
|
||||
await loadSubgroups(groupId);
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Управление календарными графиками ---
|
||||
async function openCalendarModal(group) {
|
||||
calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
|
||||
manageCalendarGroupIdInput.value = group.id;
|
||||
hideAlert('manage-calendar-alert');
|
||||
populateCalendarSelect();
|
||||
modalManageCalendar.classList.add('open');
|
||||
await loadAssignmentsList(group.id);
|
||||
}
|
||||
|
||||
async function loadAssignmentsList(groupId) {
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||||
renderAssignmentsList();
|
||||
} catch (error) {
|
||||
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignmentsList() {
|
||||
if (!currentAssignments.length) {
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
||||
<tr>
|
||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||
<td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
|
||||
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function assignmentCalendarTitle(assignment) {
|
||||
return assignment.calendarTitle || '-';
|
||||
}
|
||||
|
||||
function renderAssignmentSubjects(subjects) {
|
||||
if (!subjects.length) {
|
||||
return '<span class="muted">Не привязаны</span>';
|
||||
}
|
||||
const grouped = subjects.reduce((acc, subject) => {
|
||||
const key = String(subject.semesterNumber || '-');
|
||||
if (!acc.has(key)) acc.set(key, []);
|
||||
acc.get(key).push(subject);
|
||||
return acc;
|
||||
}, new Map());
|
||||
return `<div class="assignment-subjects">${Array.from(grouped.entries())
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([semesterNumber, items]) => `
|
||||
<div class="assignment-subject-semester">
|
||||
<strong>${escapeHtml(semesterNumber)} семестр</strong>
|
||||
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
|
||||
</div>
|
||||
`).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function saveAssignment(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('manage-calendar-alert');
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
const academicYearId = manageCalendarYearSelect.value;
|
||||
const calendarId = manageCalendarSelect.value;
|
||||
|
||||
if (!academicYearId || !calendarId) {
|
||||
showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put(`/api/groups/${groupId}/calendar-assignments`, {
|
||||
academicYearId: Number(academicYearId),
|
||||
calendarId: Number(calendarId)
|
||||
});
|
||||
showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
|
||||
await loadAssignmentsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
manageCalendarTbody.addEventListener('click', async (event) => {
|
||||
const deleteButton = event.target.closest('.btn-delete-assignment');
|
||||
if (!deleteButton) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить назначение графика?',
|
||||
message: 'Группа потеряет связь с этим графиком на выбранный учебный год.',
|
||||
confirmLabel: 'Удалить назначение',
|
||||
danger: true
|
||||
})) return;
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
|
||||
showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
|
||||
await loadAssignmentsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- Вспомогательные функции ---
|
||||
function departmentLabel(departmentId) {
|
||||
const department = departments.find(item => item.id == departmentId);
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
function specialityLabel(specialityId) {
|
||||
const speciality = specialties.find(item => item.id == specialityId);
|
||||
if (!speciality) return specialityId || '-';
|
||||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
|
||||
const name = speciality.specialityName || speciality.name || '';
|
||||
return name ? `${code} — ${name}` : code;
|
||||
}
|
||||
|
||||
function subjectLabel(subject) {
|
||||
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
|
||||
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
|
||||
}
|
||||
|
||||
function statusLabel(group) {
|
||||
if (group.status === 'ARCHIVED') return 'Архив';
|
||||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||
}
|
||||
|
||||
function statusBadgeClass(group) {
|
||||
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||||
return 'badge-unavailable';
|
||||
}
|
||||
if (group.studyState === 'NOT_STARTED') {
|
||||
return 'badge-ef';
|
||||
}
|
||||
return 'badge-available';
|
||||
}
|
||||
|
||||
function syncSelects(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
|
||||
const ACTION_LABELS = {
|
||||
MOVE: 'Перенос',
|
||||
REPLACE: 'Замена',
|
||||
CANCEL: 'Отмена'
|
||||
};
|
||||
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
const MONTH_LABELS = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
|
||||
export function canEditScheduleOverrides(role) {
|
||||
return EDIT_ROLES.has(role);
|
||||
}
|
||||
|
||||
export function overrideActionForChanges(changes) {
|
||||
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
|
||||
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function weekDates(value) {
|
||||
const selected = parseIsoDate(value);
|
||||
const day = selected.getDay() || 7;
|
||||
const monday = addDays(selected, 1 - day);
|
||||
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
|
||||
}
|
||||
|
||||
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
|
||||
const safeSlots = Array.isArray(slots) ? slots : [];
|
||||
const byId = preferredId == null
|
||||
? null
|
||||
: safeSlots.find(slot => String(slot.id) === String(preferredId));
|
||||
if (byId) return byId;
|
||||
const normalizedStart = trimTime(startTime);
|
||||
const normalizedEnd = trimTime(endTime);
|
||||
if (!normalizedStart || !normalizedEnd) return null;
|
||||
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
|
||||
&& trimTime(slot.endTime) === normalizedEnd) || null;
|
||||
}
|
||||
|
||||
export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) {
|
||||
if (!canEditScheduleOverrides(role)) {
|
||||
return {
|
||||
setRegistry() {},
|
||||
openLesson() {},
|
||||
openRegistry() {}
|
||||
};
|
||||
}
|
||||
|
||||
const drawer = document.getElementById('schedule-override-drawer');
|
||||
const form = document.getElementById('schedule-override-form');
|
||||
const empty = document.getElementById('schedule-override-empty');
|
||||
const registryButton = document.getElementById('schedule-view-overrides-open');
|
||||
const registryCount = document.getElementById('schedule-view-overrides-count');
|
||||
const registry = document.getElementById('schedule-override-registry');
|
||||
const registryPeriod = document.getElementById('schedule-override-registry-period');
|
||||
const calendar = document.getElementById('schedule-override-calendar');
|
||||
const calendarToggle = document.getElementById('schedule-override-calendar-toggle');
|
||||
const week = document.getElementById('schedule-override-week');
|
||||
const timeSelect = document.getElementById('schedule-override-time');
|
||||
const teacherSelect = document.getElementById('schedule-override-teacher');
|
||||
const classroomSelect = document.getElementById('schedule-override-classroom');
|
||||
const formatSelect = document.getElementById('schedule-override-format');
|
||||
const commentInput = document.getElementById('schedule-override-comment');
|
||||
const restoreButton = document.getElementById('schedule-override-restore');
|
||||
const cancelButton = document.getElementById('schedule-override-cancel');
|
||||
const saveButton = document.getElementById('schedule-override-save');
|
||||
|
||||
const state = {
|
||||
overrides: [],
|
||||
selectedLesson: null,
|
||||
selectedOverride: null,
|
||||
source: null,
|
||||
availability: null,
|
||||
targetDate: null,
|
||||
timeSlots: [],
|
||||
busy: false
|
||||
};
|
||||
|
||||
registryButton.hidden = false;
|
||||
const teacherCombobox = new AsyncCombobox(teacherSelect, {
|
||||
placeholder: 'Найдите преподавателя',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/users/teachers/options?${params}`);
|
||||
mergeDictionary('teachers', items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
const classroomCombobox = new AsyncCombobox(classroomSelect, {
|
||||
placeholder: 'Найдите аудиторию',
|
||||
optionLabel: classroomOptionLabel,
|
||||
optionDescription: room => [room.building, room.floor ? `${room.floor} этаж` : null, room.capacity ? `${room.capacity} мест` : null].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/classrooms/options?${params}`);
|
||||
mergeDictionary('classrooms', items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
bindEvents();
|
||||
renderRegistry();
|
||||
|
||||
return { setRegistry, openLesson, openRegistry };
|
||||
|
||||
function bindEvents() {
|
||||
drawer.querySelectorAll('[data-override-close]').forEach(button => {
|
||||
button.addEventListener('click', close);
|
||||
});
|
||||
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
|
||||
button.addEventListener('click', () => activateTab(button.dataset.overrideTab));
|
||||
});
|
||||
registryButton.addEventListener('click', openRegistry);
|
||||
calendarToggle.addEventListener('click', () => {
|
||||
calendar.hidden = !calendar.hidden;
|
||||
calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра';
|
||||
});
|
||||
week.addEventListener('click', selectDateFromButton);
|
||||
calendar.addEventListener('click', selectDateFromButton);
|
||||
registry.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-override-registry-id]');
|
||||
if (!button) return;
|
||||
const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId);
|
||||
if (item) openOverride(item);
|
||||
});
|
||||
[timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => {
|
||||
select.addEventListener('change', updateComparison);
|
||||
});
|
||||
form.addEventListener('submit', save);
|
||||
cancelButton.addEventListener('click', cancelLesson);
|
||||
restoreButton.addEventListener('click', restoreRule);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !drawer.hidden) close();
|
||||
});
|
||||
}
|
||||
|
||||
function setRegistry(overrides, range) {
|
||||
state.overrides = Array.isArray(overrides) ? overrides : [];
|
||||
registryCount.textContent = String(state.overrides.length);
|
||||
const activeRange = range || getRange?.();
|
||||
registryPeriod.textContent = activeRange
|
||||
? `${formatDate(activeRange.startDate)} — ${formatDate(activeRange.endDate)}`
|
||||
: 'Период не выбран';
|
||||
renderRegistry();
|
||||
}
|
||||
|
||||
async function openLesson(lesson) {
|
||||
if (!lesson?.scheduleRuleSlotId) return;
|
||||
const originalDate = lesson.originalLessonDate || lesson.date;
|
||||
const existing = state.overrides.find(override =>
|
||||
String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId)
|
||||
&& override.lessonDate === originalDate
|
||||
) || null;
|
||||
await prepareEditor(lesson, existing);
|
||||
}
|
||||
|
||||
async function openOverride(override) {
|
||||
const syntheticLesson = {
|
||||
scheduleRuleSlotId: override.baseRuleSlotId,
|
||||
date: override.targetLessonDate || override.lessonDate,
|
||||
originalLessonDate: override.lessonDate,
|
||||
scheduleOverrideId: override.id,
|
||||
subjectName: override.subjectName,
|
||||
lessonTypeName: override.lessonTypeName,
|
||||
groupNames: override.groupNames,
|
||||
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
|
||||
timeSlotOrder: override.sourceTimeSlotOrder,
|
||||
startTime: override.sourceStartTime,
|
||||
endTime: override.sourceEndTime,
|
||||
teacherId: override.newTeacherId || override.sourceTeacherId,
|
||||
teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName,
|
||||
classroomId: override.newClassroomId || override.sourceClassroomId,
|
||||
classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName,
|
||||
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
|
||||
};
|
||||
await prepareEditor(syntheticLesson, override);
|
||||
}
|
||||
|
||||
function openRegistry() {
|
||||
open();
|
||||
activateTab('registry');
|
||||
renderRegistry();
|
||||
}
|
||||
|
||||
async function prepareEditor(lesson, existing) {
|
||||
open();
|
||||
activateTab('edit');
|
||||
form.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.textContent = 'Загрузка доступных дат и времени...';
|
||||
hideAlert('schedule-override-alert');
|
||||
|
||||
state.selectedLesson = lesson;
|
||||
state.selectedOverride = existing;
|
||||
state.source = sourceFrom(lesson, existing);
|
||||
state.targetDate = existing?.targetLessonDate || state.source.lessonDate;
|
||||
|
||||
try {
|
||||
state.availability = await api.get(
|
||||
`/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}`
|
||||
+ `&lessonDate=${encodeURIComponent(state.source.lessonDate)}`
|
||||
);
|
||||
renderDateChoices();
|
||||
await loadTimeSlots(initialTimePreference(existing));
|
||||
setEditorValues(existing);
|
||||
renderSource();
|
||||
updateComparison();
|
||||
restoreButton.hidden = !existing;
|
||||
form.hidden = false;
|
||||
empty.hidden = true;
|
||||
} catch (error) {
|
||||
empty.textContent = error.message || 'Не удалось загрузить данные занятия';
|
||||
}
|
||||
}
|
||||
|
||||
function sourceFrom(lesson, existing) {
|
||||
return {
|
||||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||||
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
|
||||
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
|
||||
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
|
||||
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
|
||||
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
|
||||
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
|
||||
startTime: existing?.sourceStartTime || lesson.startTime,
|
||||
endTime: existing?.sourceEndTime || lesson.endTime,
|
||||
teacherId: existing?.sourceTeacherId || lesson.teacherId,
|
||||
teacherName: existing?.sourceTeacherName || lesson.teacherName,
|
||||
classroomId: existing?.sourceClassroomId || lesson.classroomId,
|
||||
classroomName: existing?.sourceClassroomName || lesson.classroomName,
|
||||
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
|
||||
};
|
||||
}
|
||||
|
||||
function initialTimePreference(existing) {
|
||||
return {
|
||||
id: existing?.newTimeSlotId || state.source.timeSlotId,
|
||||
startTime: existing?.newTimeSlotId ? null : state.source.startTime,
|
||||
endTime: existing?.newTimeSlotId ? null : state.source.endTime
|
||||
};
|
||||
}
|
||||
|
||||
function setEditorValues(existing) {
|
||||
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
|
||||
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
|
||||
ensureSelectOption(
|
||||
teacherSelect,
|
||||
effectiveTeacherId,
|
||||
teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}`
|
||||
);
|
||||
ensureSelectOption(
|
||||
classroomSelect,
|
||||
effectiveClassroomId,
|
||||
classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}`
|
||||
);
|
||||
teacherSelect.value = String(effectiveTeacherId);
|
||||
classroomSelect.value = String(effectiveClassroomId);
|
||||
teacherCombobox.syncFromSelect();
|
||||
classroomCombobox.syncFromSelect();
|
||||
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
|
||||
commentInput.value = existing?.comment || '';
|
||||
}
|
||||
|
||||
async function selectDateFromButton(event) {
|
||||
const button = event.target.closest('[data-override-date]');
|
||||
if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return;
|
||||
state.targetDate = button.dataset.overrideDate;
|
||||
renderDateChoices();
|
||||
await loadTimeSlots({
|
||||
id: state.source.timeSlotId,
|
||||
startTime: state.source.startTime,
|
||||
endTime: state.source.endTime
|
||||
});
|
||||
updateComparison();
|
||||
}
|
||||
|
||||
function renderDateChoices() {
|
||||
const available = new Set(state.availability?.availableDates || []);
|
||||
week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join('');
|
||||
calendar.innerHTML = renderSemesterCalendar(
|
||||
state.availability.semesterStartDate,
|
||||
state.availability.semesterEndDate,
|
||||
available,
|
||||
state.targetDate
|
||||
);
|
||||
}
|
||||
|
||||
function dateButton(date, label, available) {
|
||||
const selected = date === state.targetDate ? ' active' : '';
|
||||
const disabled = available.has(date) ? '' : ' disabled';
|
||||
return `
|
||||
<button type="button" class="schedule-override-date${selected}" data-override-date="${escapeHtml(date)}"${disabled}>
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(formatDateShort(date))}</strong>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadTimeSlots(preference) {
|
||||
timeSelect.disabled = true;
|
||||
timeSelect.innerHTML = '<option value="">Загрузка...</option>';
|
||||
state.timeSlots = await api.get(
|
||||
`/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}`
|
||||
);
|
||||
const selected = pickMatchingTimeSlot(
|
||||
state.timeSlots,
|
||||
preference?.id,
|
||||
preference?.startTime,
|
||||
preference?.endTime
|
||||
);
|
||||
timeSelect.innerHTML = '<option value="">Выберите время</option>' + state.timeSlots.map(slot =>
|
||||
`<option value="${escapeHtml(slot.id)}">${escapeHtml(timeSlotLabel(slot))}</option>`
|
||||
).join('');
|
||||
timeSelect.value = selected ? String(selected.id) : '';
|
||||
timeSelect.disabled = false;
|
||||
}
|
||||
|
||||
function renderSource() {
|
||||
document.getElementById('schedule-override-before-title').textContent =
|
||||
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
|
||||
document.getElementById('schedule-override-before-meta').textContent = [
|
||||
formatDate(state.source.lessonDate),
|
||||
intervalLabel(state.source.startTime, state.source.endTime),
|
||||
state.source.teacherName,
|
||||
state.source.classroomName,
|
||||
state.source.lessonFormat,
|
||||
state.source.groupNames.join(', ')
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function updateComparison() {
|
||||
if (!state.source) return;
|
||||
const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value);
|
||||
document.getElementById('schedule-override-after-title').textContent =
|
||||
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
|
||||
document.getElementById('schedule-override-after-meta').textContent = [
|
||||
formatDate(state.targetDate),
|
||||
selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
|
||||
teacherName(teacherSelect.value),
|
||||
classroomName(classroomSelect.value),
|
||||
formatSelect.value,
|
||||
state.source.groupNames.join(', ')
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
if (state.busy) return;
|
||||
hideAlert('schedule-override-alert');
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
await mutate(async () => {
|
||||
if (state.selectedOverride) {
|
||||
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
|
||||
} else {
|
||||
await api.post('/api/edu-office/schedule/overrides', payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
if (!timeSelect.value) {
|
||||
showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error');
|
||||
return null;
|
||||
}
|
||||
const dateChanged = state.targetDate !== state.source.lessonDate;
|
||||
const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId);
|
||||
const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId);
|
||||
const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId);
|
||||
const formatChanged = formatSelect.value !== state.source.lessonFormat;
|
||||
const action = overrideActionForChanges({
|
||||
dateChanged,
|
||||
timeChanged,
|
||||
teacherChanged,
|
||||
classroomChanged,
|
||||
formatChanged
|
||||
});
|
||||
if (!action) {
|
||||
showAlert(
|
||||
'schedule-override-alert',
|
||||
state.selectedOverride
|
||||
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
|
||||
: 'Измените дату, время, преподавателя, аудиторию или формат',
|
||||
'error'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
baseRuleSlotId: state.source.baseRuleSlotId,
|
||||
lessonDate: state.source.lessonDate,
|
||||
targetLessonDate: dateChanged ? state.targetDate : null,
|
||||
action,
|
||||
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null,
|
||||
newTeacherId: teacherChanged ? Number(teacherSelect.value) : null,
|
||||
newClassroomId: classroomChanged ? Number(classroomSelect.value) : null,
|
||||
newLessonFormat: formatChanged ? formatSelect.value : null,
|
||||
comment: commentInput.value.trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
async function cancelLesson() {
|
||||
if (state.busy || !state.source) return;
|
||||
const payload = {
|
||||
baseRuleSlotId: state.source.baseRuleSlotId,
|
||||
lessonDate: state.source.lessonDate,
|
||||
targetLessonDate: null,
|
||||
action: 'CANCEL',
|
||||
newTimeSlotId: null,
|
||||
newTeacherId: null,
|
||||
newClassroomId: null,
|
||||
newLessonFormat: null,
|
||||
comment: commentInput.value.trim() || null
|
||||
};
|
||||
await mutate(async () => {
|
||||
if (state.selectedOverride) {
|
||||
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
|
||||
} else {
|
||||
await api.post('/api/edu-office/schedule/overrides', payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreRule() {
|
||||
if (state.busy || !state.selectedOverride) return;
|
||||
await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`));
|
||||
}
|
||||
|
||||
async function mutate(operation) {
|
||||
setBusy(true);
|
||||
hideAlert('schedule-override-alert');
|
||||
try {
|
||||
await operation();
|
||||
await onChanged?.();
|
||||
state.selectedLesson = null;
|
||||
state.selectedOverride = null;
|
||||
state.source = null;
|
||||
form.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.';
|
||||
openRegistry();
|
||||
} catch (error) {
|
||||
showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
state.busy = busy;
|
||||
saveButton.disabled = busy;
|
||||
cancelButton.disabled = busy;
|
||||
restoreButton.disabled = busy;
|
||||
saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить';
|
||||
}
|
||||
|
||||
function renderRegistry() {
|
||||
if (!registry) return;
|
||||
if (!state.overrides.length) {
|
||||
registry.innerHTML = '<div class="schedule-override-empty">За выбранный период разовых изменений нет.</div>';
|
||||
return;
|
||||
}
|
||||
registry.innerHTML = state.overrides.map(override => {
|
||||
const target = override.targetLessonDate
|
||||
? ` → ${formatDate(override.targetLessonDate)}`
|
||||
: '';
|
||||
const result = override.action === 'CANCEL'
|
||||
? 'Занятие отменено'
|
||||
: [
|
||||
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
|
||||
teacherName(override.newTeacherId),
|
||||
classroomName(override.newClassroomId),
|
||||
override.newLessonFormat
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<article class="schedule-override-registry-item">
|
||||
<div class="schedule-override-registry-topline">
|
||||
<span class="schedule-override-action action-${escapeHtml(override.action.toLowerCase())}">${escapeHtml(ACTION_LABELS[override.action] || override.action)}</span>
|
||||
<time>${escapeHtml(formatDate(override.lessonDate))}${escapeHtml(target)}</time>
|
||||
</div>
|
||||
<h4>${escapeHtml(override.subjectName || 'Занятие')}</h4>
|
||||
<p>${escapeHtml([
|
||||
override.lessonTypeName,
|
||||
(override.groupNames || []).join(', '),
|
||||
result || 'Параметры изменены'
|
||||
].filter(Boolean).join(' · '))}</p>
|
||||
${override.comment ? `<blockquote>${escapeHtml(override.comment)}</blockquote>` : ''}
|
||||
<button type="button" class="btn btn-md btn-secondary" data-override-registry-id="${escapeHtml(override.id)}">Открыть</button>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function activateTab(tab) {
|
||||
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
|
||||
button.classList.toggle('active', button.dataset.overrideTab === tab);
|
||||
});
|
||||
drawer.querySelectorAll('[data-override-section]').forEach(section => {
|
||||
section.hidden = section.dataset.overrideSection !== tab;
|
||||
});
|
||||
}
|
||||
|
||||
function open() {
|
||||
drawer.hidden = false;
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('schedule-override-open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
drawer.hidden = true;
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('schedule-override-open');
|
||||
}
|
||||
|
||||
function teacherName(id) {
|
||||
if (id == null || id === '') return '';
|
||||
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
|
||||
return teacher?.fullName || teacher?.username || '';
|
||||
}
|
||||
|
||||
function classroomName(id) {
|
||||
if (id == null || id === '') return '';
|
||||
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
|
||||
return classroom ? classroomOptionLabel(classroom) : '';
|
||||
}
|
||||
|
||||
function mergeDictionary(key, items) {
|
||||
const current = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
|
||||
(items || []).forEach(item => current.set(String(item.id), item));
|
||||
dictionaries[key] = [...current.values()];
|
||||
}
|
||||
}
|
||||
|
||||
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
|
||||
if (!startDate || !endDate) return '';
|
||||
const start = parseIsoDate(startDate);
|
||||
const end = parseIsoDate(endDate);
|
||||
const months = [];
|
||||
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
cursor <= end;
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
|
||||
months.push(renderMonth(cursor, startDate, endDate, available, selectedDate));
|
||||
}
|
||||
return months.join('');
|
||||
}
|
||||
|
||||
function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) {
|
||||
const year = month.getFullYear();
|
||||
const monthIndex = month.getMonth();
|
||||
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
|
||||
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
|
||||
const blanks = Array.from({ length: firstDay - 1 }, () => '<span class="schedule-override-calendar-blank"></span>').join('');
|
||||
const days = Array.from({ length: dayCount }, (_, index) => {
|
||||
const date = toIsoDate(new Date(year, monthIndex, index + 1));
|
||||
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
|
||||
const active = date === selectedDate ? ' active' : '';
|
||||
return `<button type="button" class="schedule-override-calendar-day${active}" data-override-date="${date}"${enabled ? '' : ' disabled'}>${index + 1}</button>`;
|
||||
}).join('');
|
||||
return `
|
||||
<section class="schedule-override-month">
|
||||
<h4>${MONTH_LABELS[monthIndex]} ${year}</h4>
|
||||
<div class="schedule-override-calendar-grid">
|
||||
${DAY_LABELS.map(label => `<strong>${label}</strong>`).join('')}
|
||||
${blanks}${days}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function timeSlotLabel(slot) {
|
||||
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
|
||||
return `${order}${intervalLabel(slot.startTime, slot.endTime)}`;
|
||||
}
|
||||
|
||||
function intervalLabel(start, end) {
|
||||
return `${trimTime(start)}–${trimTime(end)}`;
|
||||
}
|
||||
|
||||
export function classroomOptionLabel(room) {
|
||||
return room?.name || '';
|
||||
}
|
||||
|
||||
function ensureSelectOption(select, value, label) {
|
||||
if (value == null || value === '') return;
|
||||
const exists = Array.from(select.options).some(option => String(option.value) === String(value));
|
||||
if (exists) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = String(value);
|
||||
option.textContent = label;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function formatDateShort(value) {
|
||||
const [, month, day] = String(value).split('-');
|
||||
return `${day}.${month}`;
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function toIsoDate(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
let qualityState = null;
|
||||
|
||||
export async function initScheduleQuality() {
|
||||
const root = document.querySelector('.quality-lab');
|
||||
if (!root) return;
|
||||
qualityState = {
|
||||
root,
|
||||
analysis: null,
|
||||
semesters: [],
|
||||
academicYearId: '',
|
||||
versions: [],
|
||||
visibleProblems: [],
|
||||
selectedProblemId: null,
|
||||
applying: false
|
||||
};
|
||||
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
|
||||
root.querySelector('#quality-year')?.addEventListener('change', handleYearChange);
|
||||
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
|
||||
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
|
||||
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
|
||||
await loadSemesters();
|
||||
}
|
||||
|
||||
async function loadSemesters() {
|
||||
const select = qualityState?.root.querySelector('#quality-semester');
|
||||
if (!select) return;
|
||||
try {
|
||||
qualityState.semesters = await api.get('/api/schedule/semesters');
|
||||
if (!qualityState.semesters?.length) {
|
||||
renderPeriodSelects('', '');
|
||||
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
|
||||
return;
|
||||
}
|
||||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.quality.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.quality.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
await loadVersions();
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearChange() {
|
||||
const yearId = qualityState?.root.querySelector('#quality-year')?.value || '';
|
||||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||||
academicYearId: yearId,
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(yearId, selected.semesterId);
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const yearSelect = qualityState?.root.querySelector('#quality-year');
|
||||
const semesterSelect = qualityState?.root.querySelector('#quality-semester');
|
||||
if (!yearSelect || !semesterSelect) return;
|
||||
const years = academicYearsFromSemesters(qualityState.semesters);
|
||||
qualityState.academicYearId = String(academicYearId || years[0]?.id || '');
|
||||
yearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
yearSelect.value = qualityState.academicYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
|
||||
const select = qualityState?.root.querySelector('#quality-version');
|
||||
if (!select || !semesterId) return;
|
||||
select.innerHTML = '<option value="">Загрузка версий…</option>';
|
||||
try {
|
||||
qualityState.versions = await api.get(
|
||||
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
|
||||
);
|
||||
select.innerHTML = qualityState.versions.map(version => `
|
||||
<option value="${escapeHtml(String(version.id))}">
|
||||
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
|
||||
</option>
|
||||
`).join('');
|
||||
if (!qualityState.versions.length) {
|
||||
select.innerHTML = '<option value="">Версии не созданы</option>';
|
||||
return;
|
||||
}
|
||||
const savedVersionId = localStorage.getItem('magistr.quality.versionId');
|
||||
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
|
||||
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
|
||||
select.value = String((saved || published || qualityState.versions[0]).id);
|
||||
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
|
||||
localStorage.setItem('magistr.quality.semesterId', semesterId);
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function runAnalysis() {
|
||||
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
|
||||
const versionId = qualityState?.root.querySelector('#quality-version')?.value;
|
||||
if (!semesterId) {
|
||||
qualityStatus('Выберите семестр для анализа.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!versionId) {
|
||||
qualityStatus('Выберите версию расписания для анализа.', 'warning');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('magistr.quality.semesterId', semesterId);
|
||||
localStorage.setItem('magistr.quality.versionId', versionId);
|
||||
const button = qualityState.root.querySelector('#quality-run');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Анализируем…';
|
||||
qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading');
|
||||
qualityState.root.querySelector('#quality-results').hidden = true;
|
||||
try {
|
||||
qualityState.analysis = await api.get(
|
||||
`/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`
|
||||
);
|
||||
qualityState.selectedProblemId = null;
|
||||
renderAnalysis();
|
||||
trackUxEvent('качество_расписания_проверено', {
|
||||
semesterId,
|
||||
versionId,
|
||||
score: Number(qualityState.analysis.score || 0)
|
||||
});
|
||||
qualityStatus(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
|
||||
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
|
||||
'success'
|
||||
);
|
||||
} catch (error) {
|
||||
qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Выполнить анализ';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnalysis() {
|
||||
const analysis = qualityState?.analysis;
|
||||
if (!analysis) return;
|
||||
qualityState.root.querySelector('#quality-results').hidden = false;
|
||||
const band = qualityBand(analysis.score);
|
||||
const orbit = qualityState.root.querySelector('#quality-score-orbit');
|
||||
orbit.dataset.band = band.code;
|
||||
qualityState.root.querySelector('#quality-score-ring-value')
|
||||
.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`);
|
||||
qualityState.root.querySelector('#quality-score').textContent = String(analysis.score);
|
||||
qualityState.root.querySelector('#quality-score-band').textContent = band.label;
|
||||
qualityState.root.querySelector('#quality-score-band').dataset.band = band.code;
|
||||
qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel;
|
||||
qualityState.root.querySelector('#quality-score-summary').textContent = band.summary;
|
||||
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
|
||||
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
|
||||
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
|
||||
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
|
||||
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
|
||||
`;
|
||||
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
|
||||
.map((metric, index) => `
|
||||
<article class="quality-metric quality-metric-${index + 1}">
|
||||
<span class="quality-metric-index">${String(index + 1).padStart(2, '0')}</span>
|
||||
<strong>${escapeHtml(formatMetricValue(metric))}</strong>
|
||||
<h4>${escapeHtml(metric.label)}</h4>
|
||||
<p>${escapeHtml(metric.explanation)}</p>
|
||||
<span class="quality-metric-penalty">штраф ${escapeHtml(String(metric.penalty))}</span>
|
||||
</article>
|
||||
`).join('');
|
||||
const totalLabel = analysis.problemsTruncated
|
||||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
|
||||
: `Правил с проблемами: ${analysis.totalProblemCount}`;
|
||||
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
|
||||
renderProblems();
|
||||
renderRulePanelEmpty();
|
||||
}
|
||||
|
||||
function renderProblems() {
|
||||
const container = qualityState?.root.querySelector('#quality-problems');
|
||||
if (!container || !qualityState.analysis) return;
|
||||
const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || '';
|
||||
const category = qualityState.root.querySelector('#quality-type-filter')?.value || '';
|
||||
qualityState.visibleProblems = filterQualityProblems(
|
||||
qualityState.analysis.problems || [], severity, category
|
||||
);
|
||||
if (!qualityState.visibleProblems.length) {
|
||||
container.innerHTML = `
|
||||
<div class="quality-empty-state">
|
||||
<strong>По выбранному фильтру проблем нет</strong>
|
||||
<span>Измените уровень важности или категорию.</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
|
||||
const selected = problem.id === qualityState.selectedProblemId;
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||||
return `
|
||||
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
|
||||
<button type="button" class="quality-problem-main" data-problem-index="${index}">
|
||||
<span class="quality-severity">${escapeHtml(severityLabel(problem.severity))}</span>
|
||||
<span class="quality-problem-copy">
|
||||
<strong>${escapeHtml(problem.title)}</strong>
|
||||
<span>${escapeHtml(problem.description)}</span>
|
||||
<span class="quality-rule-issues">${issueLabels.map(label => `
|
||||
<span>${escapeHtml(label)}</span>
|
||||
`).join('')}</span>
|
||||
<small>
|
||||
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
|
||||
· затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}
|
||||
· суммарный штраф
|
||||
</small>
|
||||
</span>
|
||||
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
|
||||
</button>
|
||||
<div class="quality-problem-action">
|
||||
${canEdit
|
||||
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
|
||||
Изменить правило
|
||||
</button>`
|
||||
: '<span>Только просмотр</span>'}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function handleProblemClick(event) {
|
||||
const button = event.target.closest('[data-problem-index]');
|
||||
if (!button) return;
|
||||
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
|
||||
if (!problem) return;
|
||||
if (button.dataset.action === 'edit-rule') {
|
||||
openRuleInConstructor(problem);
|
||||
return;
|
||||
}
|
||||
qualityState.selectedProblemId = problem.id;
|
||||
renderProblems();
|
||||
renderRuleDetails(problem);
|
||||
}
|
||||
|
||||
function renderRulePanelEmpty() {
|
||||
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
|
||||
renderRulePanelMessage(
|
||||
archived ? 'Архивная версия' : 'Работа с правилом',
|
||||
archived
|
||||
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
|
||||
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
|
||||
);
|
||||
}
|
||||
|
||||
function renderRuleDetails(problem) {
|
||||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||||
if (!panel) return;
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-head">
|
||||
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
|
||||
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
|
||||
<p>Затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
|
||||
</div>
|
||||
<div class="quality-rule-detail-list">
|
||||
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
|
||||
</div>
|
||||
${canEdit ? `
|
||||
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
|
||||
Открыть правило в конструкторе
|
||||
</button>
|
||||
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
|
||||
`;
|
||||
}
|
||||
|
||||
function handleRulePanelClick(event) {
|
||||
const button = event.target.closest('[data-edit-rule-id]');
|
||||
if (!button) return;
|
||||
const problem = (qualityState?.analysis?.problems || [])
|
||||
.find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
|
||||
if (problem) openRuleInConstructor(problem);
|
||||
}
|
||||
|
||||
function openRuleInConstructor(problem) {
|
||||
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
|
||||
localStorage.setItem('magistr.schedule.semesterId', String(qualityState.analysis.semesterId));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
|
||||
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
|
||||
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
|
||||
}
|
||||
|
||||
function renderRulePanelMessage(title, message) {
|
||||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-empty">
|
||||
<span class="quality-recommendations-mark">↗</span>
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function qualityStatus(message, tone = '') {
|
||||
const status = qualityState?.root.querySelector('#quality-status');
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.dataset.tone = tone;
|
||||
}
|
||||
|
||||
export function filterQualityProblems(problems, severity = '', category = '') {
|
||||
return (problems || []).filter(problem => {
|
||||
const severityMatches = !severity || problem.severity === severity;
|
||||
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
||||
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
|
||||
return severityMatches && categoryMatches;
|
||||
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
|
||||
}
|
||||
|
||||
function problemCategory(type = '') {
|
||||
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
|
||||
if (type.includes('LOAD')) return 'LOAD';
|
||||
if (type.includes('ROOM')) return 'ROOM';
|
||||
return 'PREFERENCE';
|
||||
}
|
||||
|
||||
export function qualityBand(score) {
|
||||
if (score >= 90) {
|
||||
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
|
||||
}
|
||||
if (score >= 75) {
|
||||
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
|
||||
}
|
||||
if (score >= 55) {
|
||||
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
|
||||
}
|
||||
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
|
||||
}
|
||||
|
||||
export function formatMetricValue(metric) {
|
||||
const value = Number(metric?.value || 0);
|
||||
const formatted = Number.isInteger(value)
|
||||
? String(value)
|
||||
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
|
||||
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
|
||||
}
|
||||
|
||||
function severityLabel(severity) {
|
||||
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
|
||||
}
|
||||
|
||||
function semesterLabel(semester) {
|
||||
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'дата не указана';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function localIsoDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function lessonWord(count) {
|
||||
const mod100 = count % 100;
|
||||
const mod10 = count % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
|
||||
if (mod10 === 1) return 'занятие';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
|
||||
return 'занятий';
|
||||
}
|
||||
|
||||
export function versionStatusLabel(status) {
|
||||
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
let versionState = null;
|
||||
|
||||
export async function initScheduleVersions() {
|
||||
const root = document.querySelector('.version-studio');
|
||||
if (!root) return;
|
||||
versionState = {
|
||||
root,
|
||||
semesters: [],
|
||||
academicYearId: '',
|
||||
versions: [],
|
||||
history: [],
|
||||
modal: null,
|
||||
loading: false
|
||||
};
|
||||
bindEvents();
|
||||
await loadSemesters();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const root = versionState.root;
|
||||
root.querySelector('#version-year')?.addEventListener('change', handleYearChange);
|
||||
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
|
||||
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
|
||||
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
|
||||
root.querySelector('#version-archive')?.addEventListener('click', handleVersionAction);
|
||||
root.querySelector('#version-current')?.addEventListener('click', handleVersionAction);
|
||||
root.querySelector('#version-modal-close')?.addEventListener('click', closeModal);
|
||||
root.querySelector('#version-modal-cancel')?.addEventListener('click', closeModal);
|
||||
root.querySelector('#version-modal')?.addEventListener('click', event => {
|
||||
if (event.target.id === 'version-modal') closeModal();
|
||||
});
|
||||
root.querySelector('#version-modal-form')?.addEventListener('submit', submitModal);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !root.querySelector('#version-modal')?.hidden) closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSemesters() {
|
||||
try {
|
||||
versionState.semesters = await api.get('/api/schedule/semesters');
|
||||
if (!versionState.semesters.length) {
|
||||
renderPeriodSelects('', '');
|
||||
setStatus('Сначала создайте учебный год и семестр.', 'error');
|
||||
return;
|
||||
}
|
||||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearChange() {
|
||||
const yearId = versionState.root.querySelector('#version-year')?.value || '';
|
||||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||||
academicYearId: yearId,
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(yearId, selected.semesterId);
|
||||
await loadWorkspace();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const yearSelect = versionState.root.querySelector('#version-year');
|
||||
const semesterSelect = versionState.root.querySelector('#version-semester');
|
||||
const years = academicYearsFromSemesters(versionState.semesters);
|
||||
versionState.academicYearId = String(academicYearId || years[0]?.id || '');
|
||||
yearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
yearSelect.value = versionState.academicYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(versionState.semesters, versionState.academicYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
const semesterId = selectedSemesterId();
|
||||
if (!semesterId || versionState.loading) return;
|
||||
versionState.loading = true;
|
||||
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
|
||||
localStorage.setItem('magistr.schedule.semesterId', semesterId);
|
||||
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
|
||||
try {
|
||||
[versionState.versions, versionState.history] = await Promise.all([
|
||||
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`),
|
||||
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
|
||||
]);
|
||||
renderWorkspace();
|
||||
trackUxEvent('версии_расписания_загружены', { semesterId, total: versionState.versions.length });
|
||||
setStatus('Контур версий синхронизирован.', 'success');
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
|
||||
} finally {
|
||||
versionState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWorkspace() {
|
||||
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
|
||||
const drafts = versionState.versions.filter(version => version.status === 'DRAFT');
|
||||
const archive = versionState.versions.filter(version => version.status === 'ARCHIVED');
|
||||
renderCurrent(published);
|
||||
renderDrafts(drafts);
|
||||
renderArchive(archive);
|
||||
renderHistory(versionState.history);
|
||||
}
|
||||
|
||||
function renderCurrent(version) {
|
||||
const container = versionState.root.querySelector('#version-current');
|
||||
if (!version) {
|
||||
container.innerHTML = `
|
||||
<div class="version-live-mark">ПУСТО</div>
|
||||
<div><h3>Опубликованной версии нет</h3><p>Создайте первый черновик и опубликуйте его после проверки.</p></div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="version-live-mark">LIVE</div>
|
||||
<div>
|
||||
<span class="version-eyebrow">Сейчас видят пользователи</span>
|
||||
<h3>${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}</h3>
|
||||
<p>${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}</p>
|
||||
<div class="version-stats">
|
||||
<span>${escapeHtml(String(version.ruleCount))} правил</span>
|
||||
<span>${escapeHtml(String(version.slotCount))} слотов</span>
|
||||
<span>${escapeHtml(formatDateTime(version.publishedAt))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-card-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDrafts(drafts) {
|
||||
const container = versionState.root.querySelector('#version-drafts');
|
||||
versionState.root.querySelector('#version-draft-count').textContent = String(drafts.length);
|
||||
if (!drafts.length) {
|
||||
container.innerHTML = `
|
||||
<div class="version-empty">
|
||||
<span>+</span><h3>Нет активных черновиков</h3>
|
||||
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = drafts.map(version => `
|
||||
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
|
||||
<div class="version-card-top">
|
||||
<div>
|
||||
<span class="version-status-chip">Черновик · v${escapeHtml(String(version.versionNumber))}</span>
|
||||
<h4>${escapeHtml(version.name)}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<p>Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}</p>
|
||||
<small>${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))}</small>
|
||||
<div class="version-card-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="publish" data-id="${version.id}">Опубликовать</button>
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderArchive(versions) {
|
||||
const container = versionState.root.querySelector('#version-archive');
|
||||
if (!versions.length) {
|
||||
container.innerHTML = '<p class="version-empty-note">Предыдущих публикаций пока нет.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = versions.map(version => `
|
||||
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
|
||||
<span class="version-status-chip">Архив · v${escapeHtml(String(version.versionNumber))}</span>
|
||||
<h4>${escapeHtml(version.name)}</h4>
|
||||
<p>${escapeHtml(version.changeReason || 'Причина не указана')}</p>
|
||||
<small>${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))}</small>
|
||||
<div class="version-card-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" data-action="restore" data-id="${version.id}">Восстановить</button>
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderHistory(history) {
|
||||
const container = versionState.root.querySelector('#version-history');
|
||||
if (!history.length) {
|
||||
container.innerHTML = '<p class="version-empty-note">Действия ещё не зафиксированы.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = history.map(item => `
|
||||
<article class="version-history-row">
|
||||
<time>${escapeHtml(formatDateTime(item.createdAt))}</time>
|
||||
<div>
|
||||
<strong>${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)}</strong>
|
||||
<p>${escapeHtml(item.reason || 'Без комментария')}</p>
|
||||
</div>
|
||||
<span class="version-status-chip">${escapeHtml(item.actorName || 'Система')}</span>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleVersionAction(event) {
|
||||
const button = event.target.closest('[data-action][data-id]');
|
||||
if (!button) return;
|
||||
const version = versionState.versions.find(item => String(item.id) === button.dataset.id);
|
||||
if (!version) return;
|
||||
const action = button.dataset.action;
|
||||
if (action === 'edit') {
|
||||
localStorage.setItem('magistr.schedule.semesterId', String(version.semesterId));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
|
||||
navigateTo('schedule');
|
||||
return;
|
||||
}
|
||||
if (action === 'quality') {
|
||||
localStorage.setItem('magistr.quality.semesterId', String(version.semesterId));
|
||||
localStorage.setItem('magistr.quality.versionId', String(version.id));
|
||||
navigateTo('schedule-quality');
|
||||
return;
|
||||
}
|
||||
if (action === 'diff') {
|
||||
await loadDiff(version);
|
||||
return;
|
||||
}
|
||||
if (action === 'publish') {
|
||||
await openPublishModal(version);
|
||||
return;
|
||||
}
|
||||
if (action === 'restore') {
|
||||
openActionModal('restore', version);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiff(version) {
|
||||
const panel = versionState.root.querySelector('#version-diff');
|
||||
panel.innerHTML = '<div class="version-empty"><span>…</span><h3>Сравниваем расписания</h3><p>Генерируем занятия обеих версий по всему семестру.</p></div>';
|
||||
try {
|
||||
const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`);
|
||||
renderDiff(diff);
|
||||
} catch (error) {
|
||||
panel.innerHTML = `<div class="version-empty"><span>!</span><h3>Сравнение недоступно</h3><p>${escapeHtml(error.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiff(diff) {
|
||||
const panel = versionState.root.querySelector('#version-diff');
|
||||
panel.innerHTML = `
|
||||
<span class="version-eyebrow">${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)}</span>
|
||||
<h3>Что изменится после публикации</h3>
|
||||
<div class="version-diff-score">
|
||||
<div><strong>+${escapeHtml(String(diff.addedLessonCount))}</strong><span>занятий</span></div>
|
||||
<div><strong>${escapeHtml(String(diff.changedLessonCount))}</strong><span>изменено</span></div>
|
||||
<div><strong>−${escapeHtml(String(diff.removedLessonCount))}</strong><span>удалено</span></div>
|
||||
</div>
|
||||
<p class="version-diff-caption">Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}</p>
|
||||
<div class="version-change-list">
|
||||
${(diff.ruleChanges || []).length
|
||||
? diff.ruleChanges.map(ruleChangeCard).join('')
|
||||
: '<div class="version-empty"><h3>Правила совпадают</h3><p>Структурных изменений между версиями нет.</p></div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function ruleChangeCard(change) {
|
||||
const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type;
|
||||
return `
|
||||
<article class="version-change" data-type="${escapeHtml(change.type)}">
|
||||
<strong>${escapeHtml(label)} · ${escapeHtml(change.subjectName)}</strong>
|
||||
<span>${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')}</span>
|
||||
${change.beforeSummary ? `<span>Было: ${escapeHtml(change.beforeSummary)}</span>` : ''}
|
||||
${change.afterSummary ? `<span>Стало: ${escapeHtml(change.afterSummary)}</span>` : ''}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
|
||||
openModal({
|
||||
mode: 'create',
|
||||
version: published,
|
||||
kicker: 'Изолированная рабочая копия',
|
||||
title: 'Создать новый черновик',
|
||||
copy: published
|
||||
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
|
||||
: 'Будет создан пустой черновик для первого расписания семестра.',
|
||||
label: 'Название черновика',
|
||||
value: '',
|
||||
submit: 'Создать черновик'
|
||||
});
|
||||
}
|
||||
|
||||
async function openPublishModal(version) {
|
||||
setStatus('Проверяем целостность черновика перед публикацией…', 'loading');
|
||||
try {
|
||||
const [validation, diff] = await Promise.all([
|
||||
api.get(`/api/edu-office/schedule/versions/${version.id}/validate`),
|
||||
api.get(`/api/edu-office/schedule/versions/${version.id}/diff`)
|
||||
]);
|
||||
const summary = validation.valid
|
||||
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
|
||||
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
|
||||
openModal({
|
||||
mode: 'publish',
|
||||
version,
|
||||
valid: validation.valid,
|
||||
kicker: 'Атомарная публикация',
|
||||
title: `Опубликовать версию ${version.versionNumber}`,
|
||||
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
|
||||
label: 'Причина изменений',
|
||||
value: '',
|
||||
submit: 'Опубликовать',
|
||||
summary
|
||||
});
|
||||
setStatus(validation.valid ? 'Черновик готов к публикации.' : 'Черновик требует исправлений.', validation.valid ? 'success' : 'error');
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось проверить черновик.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openActionModal(mode, version) {
|
||||
openModal({
|
||||
mode,
|
||||
version,
|
||||
kicker: 'Контролируемый возврат',
|
||||
title: `Восстановить версию ${version.versionNumber}`,
|
||||
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
|
||||
label: 'Причина восстановления',
|
||||
value: '',
|
||||
submit: 'Восстановить'
|
||||
});
|
||||
}
|
||||
|
||||
function openModal(config) {
|
||||
versionState.modal = config;
|
||||
const root = versionState.root;
|
||||
root.querySelector('#version-modal-kicker').textContent = config.kicker;
|
||||
root.querySelector('#version-modal-title').textContent = config.title;
|
||||
root.querySelector('#version-modal-copy').textContent = config.copy;
|
||||
root.querySelector('#version-modal-label').textContent = config.label;
|
||||
root.querySelector('#version-modal-value').value = config.value || '';
|
||||
const summary = root.querySelector('#version-modal-summary');
|
||||
summary.hidden = !config.summary;
|
||||
summary.textContent = config.summary || '';
|
||||
const submit = root.querySelector('#version-modal-submit');
|
||||
submit.textContent = config.submit;
|
||||
submit.disabled = config.valid === false;
|
||||
root.querySelector('#version-modal').hidden = false;
|
||||
root.querySelector('#version-modal-value').focus();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = versionState?.root.querySelector('#version-modal');
|
||||
if (modal) modal.hidden = true;
|
||||
if (versionState) versionState.modal = null;
|
||||
}
|
||||
|
||||
async function submitModal(event) {
|
||||
event.preventDefault();
|
||||
const config = versionState.modal;
|
||||
if (!config || config.valid === false) return;
|
||||
const value = versionState.root.querySelector('#version-modal-value').value.trim();
|
||||
if (!value) {
|
||||
setStatus(config.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.', 'error');
|
||||
return;
|
||||
}
|
||||
const submit = versionState.root.querySelector('#version-modal-submit');
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Выполняем…';
|
||||
try {
|
||||
if (config.mode === 'create') {
|
||||
const created = await api.post('/api/edu-office/schedule/versions', {
|
||||
semesterId: Number(selectedSemesterId()),
|
||||
name: value,
|
||||
basedOnVersionId: config.version?.id || null
|
||||
});
|
||||
localStorage.setItem('magistr.schedule.versionId', String(created.id));
|
||||
setStatus(`Черновик «${created.name}» создан.`, 'success');
|
||||
} else {
|
||||
await api.post(
|
||||
`/api/edu-office/schedule/versions/${config.version.id}/${config.mode}`,
|
||||
{ reason: value }
|
||||
);
|
||||
setStatus(config.mode === 'publish' ? 'Версия опубликована атомарно.' : 'Предыдущая версия восстановлена.', 'success');
|
||||
}
|
||||
closeModal();
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Операцию не удалось выполнить.', 'error');
|
||||
submit.disabled = false;
|
||||
submit.textContent = config.submit;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(tab) {
|
||||
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.click();
|
||||
}
|
||||
|
||||
function selectedSemesterId() {
|
||||
return versionState?.root.querySelector('#version-semester')?.value || '';
|
||||
}
|
||||
|
||||
function setStatus(message, tone = '') {
|
||||
const status = versionState?.root.querySelector('#version-status');
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.dataset.tone = tone;
|
||||
}
|
||||
|
||||
export function historyActionLabel(action) {
|
||||
return {
|
||||
CREATED: 'Создан черновик',
|
||||
PUBLISHED: 'Опубликовано',
|
||||
ARCHIVED: 'Архивировано',
|
||||
RESTORED: 'Восстановлено'
|
||||
}[action] || action;
|
||||
}
|
||||
|
||||
function semesterLabel(semester) {
|
||||
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
|
||||
return `${semester.academicYearTitle} · ${type} семестр`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return 'дата не указана';
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function localIsoDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,356 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
export async function initSubjects() {
|
||||
const subjectsTbody = document.getElementById('subjects-tbody');
|
||||
const createSubjectForm = document.getElementById('create-subject-form');
|
||||
const newSubjectDeptSelect = document.getElementById('new-subject-department');
|
||||
const departmentFilter = document.getElementById('subjects-department-filter');
|
||||
const searchInput = document.getElementById('subjects-search');
|
||||
const sortSelect = document.getElementById('subjects-sort');
|
||||
const pagination = document.getElementById('subjects-pagination');
|
||||
|
||||
// Модалка управления преподавателями
|
||||
const modalManageTeachers = document.getElementById('modal-manage-teachers');
|
||||
const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close');
|
||||
const subjectTeachersContext = document.getElementById('subject-teachers-context');
|
||||
const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form');
|
||||
const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id');
|
||||
const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select');
|
||||
const manageTsTbody = document.getElementById('manage-ts-tbody');
|
||||
|
||||
let allSubjects = [];
|
||||
let allDepartments = [];
|
||||
let teacherSubjects = [];
|
||||
let teacherCombobox = null;
|
||||
const restored = readUrlState({ subjectQuery: '', subjectDepartment: '', subjectPage: '0', subjectSize: '25', subjectSort: 'name,asc' });
|
||||
const pageState = {
|
||||
query: restored.subjectQuery,
|
||||
departmentId: restored.subjectDepartment,
|
||||
page: Math.max(Number(restored.subjectPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.subjectSize)) ? Number(restored.subjectSize) : 25,
|
||||
sort: restored.subjectSort
|
||||
};
|
||||
searchInput.value = pageState.query;
|
||||
sortSelect.value = pageState.sort;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
|
||||
function bindEvents() {
|
||||
createSubjectForm.addEventListener('submit', createSubject);
|
||||
subjectsTbody.addEventListener('click', handleSubjectsTableClick);
|
||||
|
||||
manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment);
|
||||
manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick);
|
||||
|
||||
modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open'));
|
||||
modalManageTeachers.addEventListener('click', (e) => {
|
||||
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
|
||||
});
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
pageState.query = searchInput.value.trim();
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
}));
|
||||
departmentFilter.addEventListener('change', () => {
|
||||
pageState.departmentId = departmentFilter.value;
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
});
|
||||
sortSelect.addEventListener('change', () => {
|
||||
pageState.sort = sortSelect.value;
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
allDepartments = await api.get('/api/departments');
|
||||
teacherCombobox = new AsyncCombobox(manageTsTeacherSelect, {
|
||||
placeholder: 'Найдите преподавателя по ФИО',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const teachers = await api.get(`/api/users/teachers/options?${params}`);
|
||||
const subjectId = manageTsSubjectIdInput.value;
|
||||
const assigned = new Set(teacherSubjects
|
||||
.filter(item => String(item.subjectId) === String(subjectId))
|
||||
.map(item => String(item.userId)));
|
||||
return teachers.filter(teacher => !assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
|
||||
}
|
||||
});
|
||||
populateDepartmentsSelect();
|
||||
populateDepartmentFilter();
|
||||
await loadSubjects();
|
||||
} catch (e) {
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function populateDepartmentsSelect() {
|
||||
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||||
allDepartments.map(d => {
|
||||
const name = d.departmentName || d.name || 'Без названия';
|
||||
const code = d.departmentCode || d.code || d.id;
|
||||
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateDepartmentFilter() {
|
||||
departmentFilter.innerHTML = '<option value="">Все кафедры</option>' + allDepartments.map(department =>
|
||||
`<option value="${department.id}">${escapeHtml(department.departmentName || department.name || department.id)}</option>`
|
||||
).join('');
|
||||
departmentFilter.value = pageState.departmentId;
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
renderTableLoading(subjectsTbody, 6, 'Загрузка дисциплин...');
|
||||
hideAlert('subjects-alert');
|
||||
try {
|
||||
const [sort, direction] = pageState.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: pageState.query,
|
||||
page: String(pageState.page),
|
||||
size: String(pageState.size),
|
||||
sort,
|
||||
direction
|
||||
});
|
||||
if (pageState.departmentId) params.set('departmentId', pageState.departmentId);
|
||||
const page = await api.get(`/api/subjects/page?${params}`);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && pageState.page > 0) {
|
||||
pageState.page = Math.max(0, page.totalPages - 1);
|
||||
return loadSubjects();
|
||||
}
|
||||
allSubjects = page.items || [];
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjects();
|
||||
renderPagination(pagination, page, {
|
||||
label: 'дисциплин',
|
||||
onPage: nextPage => { pageState.page = nextPage; loadSubjects(); },
|
||||
onSize: size => { pageState.size = size; pageState.page = 0; loadSubjects(); }
|
||||
});
|
||||
persistState();
|
||||
trackUxEvent('реестр_дисциплин_загружен', { total: Number(page.totalItems || 0), page: pageState.page });
|
||||
} catch (e) {
|
||||
renderTableError(subjectsTbody, 6, e.message || 'Не удалось загрузить дисциплины', loadSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeacherAssignments(subjectIds) {
|
||||
if (!subjectIds.length) return [];
|
||||
const params = new URLSearchParams();
|
||||
subjectIds.forEach(subjectId => params.append('subjectId', subjectId));
|
||||
return api.get(`/api/teacher-subjects?${params}`);
|
||||
}
|
||||
|
||||
function renderSubjects() {
|
||||
if (!allSubjects || !allSubjects.length) {
|
||||
renderTableEmpty(subjectsTbody, 6, pageState.query || pageState.departmentId
|
||||
? 'Дисциплины не найдены'
|
||||
: 'Дисциплин пока нет');
|
||||
return;
|
||||
}
|
||||
|
||||
// Группируем преподавателей по дисциплинам
|
||||
const teachersBySubject = new Map();
|
||||
teacherSubjects.forEach(ts => {
|
||||
if (!teachersBySubject.has(ts.subjectId)) {
|
||||
teachersBySubject.set(ts.subjectId, []);
|
||||
}
|
||||
teachersBySubject.get(ts.subjectId).push(ts);
|
||||
});
|
||||
|
||||
subjectsTbody.innerHTML = allSubjects.map(s => {
|
||||
const teachers = teachersBySubject.get(s.id) || [];
|
||||
const teachersHtml = teachers.length
|
||||
? teachers.map(t => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
|
||||
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td><strong>${escapeHtml(s.name)}</strong></td>
|
||||
<td>${escapeHtml(s.code || '-')}</td>
|
||||
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
|
||||
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
markTableReady(subjectsTbody);
|
||||
}
|
||||
|
||||
async function createSubject(e) {
|
||||
e.preventDefault();
|
||||
hideAlert('create-subject-alert');
|
||||
const name = document.getElementById('new-subject-name').value.trim();
|
||||
const code = document.getElementById('new-subject-code').value.trim();
|
||||
const departmentId = newSubjectDeptSelect.value;
|
||||
|
||||
if (!name || !code || !departmentId) {
|
||||
showAlert('create-subject-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/subjects', {
|
||||
name,
|
||||
code,
|
||||
departmentId: Number(departmentId)
|
||||
});
|
||||
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success');
|
||||
createSubjectForm.reset();
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubjectsTableClick(e) {
|
||||
const btnDelete = e.target.closest('.btn-delete-subject');
|
||||
const btnTeachers = e.target.closest('.btn-manage-teachers');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать дисциплину?',
|
||||
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/subjects/' + btnDelete.dataset.id);
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
showAlert('subjects-alert', error.message || 'Ошибка архивирования', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (btnTeachers) {
|
||||
const subjectId = btnTeachers.dataset.id;
|
||||
const subject = allSubjects.find(s => s.id == subjectId);
|
||||
if (subject) openTeachersModal(subject);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Управление привязками преподавателей к дисциплине ---
|
||||
function openTeachersModal(subject) {
|
||||
subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
|
||||
manageTsSubjectIdInput.value = subject.id;
|
||||
hideAlert('manage-ts-alert');
|
||||
modalManageTeachers.classList.add('open');
|
||||
renderSubjectTeachers(subject.id);
|
||||
populateTeachersSelect(subject.id);
|
||||
}
|
||||
|
||||
function renderSubjectTeachers(subjectId) {
|
||||
const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId));
|
||||
if (!list.length) {
|
||||
manageTsTbody.innerHTML = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageTsTbody.innerHTML = list.map(ts => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function populateTeachersSelect(subjectId) {
|
||||
manageTsTeacherSelect.innerHTML = '<option value=""></option>';
|
||||
manageTsTeacherSelect.value = '';
|
||||
teacherCombobox?.syncFromSelect();
|
||||
teacherCombobox?.search('');
|
||||
}
|
||||
|
||||
async function saveTeacherAssignment(e) {
|
||||
e.preventDefault();
|
||||
hideAlert('manage-ts-alert');
|
||||
const subjectId = manageTsSubjectIdInput.value;
|
||||
const userId = manageTsTeacherSelect.value;
|
||||
|
||||
if (!userId) {
|
||||
showAlert('manage-ts-alert', 'Выберите преподавателя', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/teacher-subjects', {
|
||||
userId: Number(userId),
|
||||
subjectId: Number(subjectId)
|
||||
});
|
||||
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
|
||||
|
||||
// Перезагрузим привязки и обновим интерфейс
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjectTeachers(subjectId);
|
||||
populateTeachersSelect(subjectId);
|
||||
|
||||
// Также обновим главную таблицу
|
||||
renderSubjects();
|
||||
} catch (error) {
|
||||
showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTeacherAssignmentTableClick(e) {
|
||||
const btnDelete = e.target.closest('.btn-delete-ts');
|
||||
if (!btnDelete) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить привязку преподавателя?',
|
||||
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
|
||||
confirmLabel: 'Удалить привязку',
|
||||
danger: true
|
||||
})) return;
|
||||
const subjectId = manageTsSubjectIdInput.value;
|
||||
|
||||
try {
|
||||
await api.delete('/api/teacher-subjects', {
|
||||
userId: Number(btnDelete.dataset.userId),
|
||||
subjectId: Number(btnDelete.dataset.subjectId)
|
||||
});
|
||||
|
||||
// Перезагрузим привязки
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjectTeachers(subjectId);
|
||||
populateTeachersSelect(subjectId);
|
||||
|
||||
// Обновим главную таблицу
|
||||
renderSubjects();
|
||||
} catch (error) {
|
||||
showAlert('manage-ts-alert', error.message || 'Ошибка удаления привязки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Вспомогательные функции ---
|
||||
function departmentLabel(departmentId) {
|
||||
const department = allDepartments.find(item => item.id == departmentId);
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
subjectQuery: pageState.query,
|
||||
subjectDepartment: pageState.departmentId,
|
||||
subjectPage: pageState.page || '',
|
||||
subjectSize: pageState.size === 25 ? '' : pageState.size,
|
||||
subjectSort: pageState.sort === 'name,asc' ? '' : pageState.sort
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
import { alertAction } from '../dialog.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
|
||||
|
||||
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/users/teachers/options?${params}`;
|
||||
}
|
||||
|
||||
export async function initTeacherAbsences() {
|
||||
const session = getSession();
|
||||
const role = session?.role;
|
||||
if (!MANAGER_ROLES.has(role)) return;
|
||||
|
||||
const form = document.getElementById('teacher-absence-form');
|
||||
const teacherSelect = document.getElementById('teacher-absence-teacher');
|
||||
const startInput = document.getElementById('teacher-absence-start');
|
||||
const endInput = document.getElementById('teacher-absence-end');
|
||||
const reasonInput = document.getElementById('teacher-absence-reason');
|
||||
const statusFilter = document.getElementById('teacher-absence-status-filter');
|
||||
const registry = document.getElementById('teacher-absence-registry');
|
||||
const overlay = document.getElementById('absence-wizard-overlay');
|
||||
const wizardLessons = document.getElementById('absence-wizard-lessons');
|
||||
const preferenceSemesterFilter = document.getElementById('teacher-preference-semester-filter');
|
||||
const preferenceStatusFilter = document.getElementById('teacher-preference-status-filter');
|
||||
const preferenceReviewList = document.getElementById('teacher-preference-review-list');
|
||||
const changeStatusFilter = document.getElementById('teacher-change-status-filter');
|
||||
const changeReviewList = document.getElementById('teacher-change-review-list');
|
||||
let absences = [];
|
||||
let currentWizard = null;
|
||||
let preferenceItems = [];
|
||||
let changeItems = [];
|
||||
|
||||
new AsyncCombobox(teacherSelect, {
|
||||
placeholder: 'Найдите преподавателя по ФИО',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username || `ID ${teacher.id}`,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
return api.get(teacherAbsenceTeacherOptionsUrl({
|
||||
query,
|
||||
selectedId,
|
||||
role,
|
||||
departmentId: session?.departmentId
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
setDefaultPeriod(startInput, endInput);
|
||||
form?.addEventListener('submit', createAbsence);
|
||||
statusFilter?.addEventListener('change', loadAbsences);
|
||||
document.getElementById('teacher-absence-refresh')?.addEventListener('click', loadAbsences);
|
||||
document.getElementById('absence-wizard-close')?.addEventListener('click', closeWizard);
|
||||
document.getElementById('absence-wizard-cancel')?.addEventListener('click', closeWizard);
|
||||
document.getElementById('absence-wizard-apply')?.addEventListener('click', applyWizard);
|
||||
overlay?.addEventListener('click', event => {
|
||||
if (event.target === overlay) closeWizard();
|
||||
});
|
||||
registry?.addEventListener('click', handleRegistryAction);
|
||||
preferenceSemesterFilter?.addEventListener('change', loadPreferenceReviews);
|
||||
preferenceStatusFilter?.addEventListener('change', loadPreferenceReviews);
|
||||
document.getElementById('teacher-preference-refresh')?.addEventListener('click', loadPreferenceReviews);
|
||||
preferenceReviewList?.addEventListener('click', handlePreferenceReview);
|
||||
changeStatusFilter?.addEventListener('change', loadChangeReviews);
|
||||
document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews);
|
||||
changeReviewList?.addEventListener('click', handleChangeReview);
|
||||
|
||||
await Promise.all([loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
|
||||
|
||||
async function loadAbsences() {
|
||||
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
try {
|
||||
const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
|
||||
absences = await api.get(`/api/teacher-absences${params}`);
|
||||
renderRegistry();
|
||||
updateStats(absences);
|
||||
} catch (error) {
|
||||
registry.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createAbsence(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('teacher-absence-form-alert');
|
||||
if (!teacherSelect.value || !startInput.value || !endInput.value || !reasonInput.value.trim()) {
|
||||
showAlert('teacher-absence-form-alert', 'Заполните преподавателя, период и причину', 'error');
|
||||
return;
|
||||
}
|
||||
if (endInput.value < startInput.value) {
|
||||
showAlert('teacher-absence-form-alert', 'Дата окончания не может быть раньше даты начала', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/api/teacher-absences', {
|
||||
teacherId: Number(teacherSelect.value),
|
||||
startDate: startInput.value,
|
||||
endDate: endInput.value,
|
||||
reason: reasonInput.value.trim()
|
||||
});
|
||||
showAlert('teacher-absence-form-alert', 'Отсутствие зарегистрировано', 'success');
|
||||
reasonInput.value = '';
|
||||
await loadAbsences();
|
||||
} catch (error) {
|
||||
showAlert('teacher-absence-form-alert', error.message || 'Не удалось зарегистрировать отсутствие', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderRegistry() {
|
||||
if (!absences.length) {
|
||||
registry.innerHTML = '<div class="absence-empty"><strong>Инцидентов не найдено</strong><span>Измените фильтр или зарегистрируйте отсутствие.</span></div>';
|
||||
return;
|
||||
}
|
||||
registry.innerHTML = absences.map(item => `
|
||||
<article class="absence-registry-item" data-status="${escapeHtml(item.status)}">
|
||||
<div class="absence-date-rail">
|
||||
<strong>${escapeHtml(dayOfMonth(item.startDate))}</strong>
|
||||
<span>${escapeHtml(monthShort(item.startDate))}</span>
|
||||
<i></i>
|
||||
<strong>${escapeHtml(dayOfMonth(item.endDate))}</strong>
|
||||
<span>${escapeHtml(monthShort(item.endDate))}</span>
|
||||
</div>
|
||||
<div class="absence-registry-main">
|
||||
<div class="absence-registry-title">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.teacherName || 'Преподаватель не указан')}</h3>
|
||||
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
|
||||
</div>
|
||||
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(statusLabel(item.status))}</span>
|
||||
</div>
|
||||
<div class="absence-meta-row">
|
||||
<span>${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))}</span>
|
||||
<span>${escapeHtml(decisionCountLabel(item.decisionCount || 0))}</span>
|
||||
${item.reviewComment ? `<span>${escapeHtml(item.reviewComment)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absence-registry-buttons">
|
||||
${item.status === 'PENDING' ? `
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="approve" data-id="${item.id}">Подтвердить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle" data-action="reject" data-id="${item.id}">Отклонить</button>
|
||||
` : ''}
|
||||
${['APPROVED', 'RESOLVED'].includes(item.status) ? `
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="wizard" data-id="${item.id}">
|
||||
${item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
|
||||
</button>
|
||||
` : ''}
|
||||
${['PENDING', 'APPROVED'].includes(item.status) ? `
|
||||
<button type="button" class="btn btn-sm btn-ghost" data-action="cancel" data-id="${item.id}">Отменить</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleRegistryAction(event) {
|
||||
const button = event.target.closest('[data-action][data-id]');
|
||||
if (!button) return;
|
||||
const id = Number(button.dataset.id);
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (button.dataset.action === 'approve' || button.dataset.action === 'reject') {
|
||||
await api.post(`/api/teacher-absences/${id}/review`, {
|
||||
approved: button.dataset.action === 'approve',
|
||||
comment: button.dataset.action === 'approve'
|
||||
? 'Подтверждено кафедрой или ответственным сотрудником'
|
||||
: 'Заявка отклонена ответственным сотрудником'
|
||||
});
|
||||
await loadAbsences();
|
||||
} else if (button.dataset.action === 'cancel') {
|
||||
await api.delete(`/api/teacher-absences/${id}`);
|
||||
await loadAbsences();
|
||||
} else if (button.dataset.action === 'wizard') {
|
||||
await openWizard(id);
|
||||
}
|
||||
} catch (error) {
|
||||
await alertAction({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openWizard(id) {
|
||||
overlay.hidden = false;
|
||||
document.body.classList.add('absence-wizard-open');
|
||||
wizardLessons.innerHTML = '<div class="loading-row">Подбираем свободные ресурсы...</div>';
|
||||
hideAlert('absence-wizard-alert');
|
||||
try {
|
||||
currentWizard = await api.get(`/api/teacher-absences/${id}/wizard`);
|
||||
renderWizard();
|
||||
} catch (error) {
|
||||
showAlert('absence-wizard-alert', error.message || 'Не удалось открыть мастер замены', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
overlay.hidden = true;
|
||||
document.body.classList.remove('absence-wizard-open');
|
||||
currentWizard = null;
|
||||
}
|
||||
|
||||
function renderWizard() {
|
||||
const absence = currentWizard.absence;
|
||||
document.getElementById('absence-wizard-title').textContent = absence.teacherName || 'Мастер замены';
|
||||
document.getElementById('absence-wizard-summary').textContent =
|
||||
`${formatDate(absence.startDate)} — ${formatDate(absence.endDate)} · ${absence.reason}`;
|
||||
const applyButton = document.getElementById('absence-wizard-apply');
|
||||
applyButton.hidden = absence.status !== 'APPROVED';
|
||||
|
||||
wizardLessons.innerHTML = currentWizard.lessons.length
|
||||
? currentWizard.lessons.map((lesson, index) => renderLessonDecision(lesson, index, absence.status)).join('')
|
||||
: '<div class="absence-empty"><strong>Затронутых занятий нет</strong><span>Инцидент можно завершить без точечных изменений.</span></div>';
|
||||
renderJournal(currentWizard.journal || []);
|
||||
}
|
||||
|
||||
function renderLessonDecision(lesson, index, absenceStatus) {
|
||||
const options = decisionOptions(lesson);
|
||||
const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus);
|
||||
return `
|
||||
<article class="absence-lesson" data-index="${index}" data-slot-id="${lesson.baseRuleSlotId}" data-date="${lesson.lessonDate}">
|
||||
<div class="absence-lesson-time">
|
||||
<strong>${escapeHtml(trimTime(lesson.startTime))}</strong>
|
||||
<span>${escapeHtml(trimTime(lesson.endTime))}</span>
|
||||
<small>${escapeHtml(formatDate(lesson.lessonDate))}</small>
|
||||
</div>
|
||||
<div class="absence-lesson-copy">
|
||||
<span class="absence-lesson-type">${escapeHtml(lesson.lessonTypeName || 'Занятие')}</span>
|
||||
<h3>${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}</h3>
|
||||
<p>${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}</p>
|
||||
${lesson.existingOverrideId ? '<small class="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>' : ''}
|
||||
</div>
|
||||
<div class="absence-lesson-decision">
|
||||
<label for="absence-decision-${index}">Решение</label>
|
||||
<select id="absence-decision-${index}" class="absence-decision-select" ${locked ? 'disabled' : ''}>
|
||||
<option value="">Оставить без изменений</option>
|
||||
${options.map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`).join('')}
|
||||
</select>
|
||||
${lesson.decisionStatus ? `<span class="absence-decision-fixed">${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}</span>` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
async function applyWizard() {
|
||||
if (!currentWizard) return;
|
||||
const decisions = Array.from(wizardLessons.querySelectorAll('.absence-lesson')).map(card => {
|
||||
const value = card.querySelector('.absence-decision-select')?.value;
|
||||
if (!value) return null;
|
||||
return {
|
||||
baseRuleSlotId: Number(card.dataset.slotId),
|
||||
lessonDate: card.dataset.date,
|
||||
...parseDecisionOption(value)
|
||||
};
|
||||
}).filter(Boolean);
|
||||
if (!decisions.length && currentWizard.lessons.length) {
|
||||
showAlert('absence-wizard-alert', 'Выберите хотя бы одно решение', 'error');
|
||||
return;
|
||||
}
|
||||
const button = document.getElementById('absence-wizard-apply');
|
||||
button.disabled = true;
|
||||
hideAlert('absence-wizard-alert');
|
||||
try {
|
||||
currentWizard = await api.post(`/api/teacher-absences/${currentWizard.absence.id}/resolve`, { decisions });
|
||||
renderWizard();
|
||||
showAlert('absence-wizard-alert', 'Выбранные решения применены к расписанию', 'success');
|
||||
await loadAbsences();
|
||||
} catch (error) {
|
||||
showAlert('absence-wizard-alert', error.message || 'Не удалось применить решения', 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreferenceMeta() {
|
||||
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка семестров...</div>';
|
||||
try {
|
||||
const meta = await api.get('/api/teacher-preferences/meta');
|
||||
preferenceSemesterFilter.innerHTML = '<option value="">Все семестры</option>' + (meta.semesters || []).map(item => `
|
||||
<option value="${item.id}">${escapeHtml(semesterLabel(item))}</option>
|
||||
`).join('');
|
||||
const today = isoDate(new Date());
|
||||
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
|
||||
if (current) preferenceSemesterFilter.value = String(current.id);
|
||||
await loadPreferenceReviews();
|
||||
} catch (error) {
|
||||
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить семестры')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreferenceReviews() {
|
||||
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка пожеланий...</div>';
|
||||
const params = new URLSearchParams();
|
||||
if (preferenceSemesterFilter.value) params.set('semesterId', preferenceSemesterFilter.value);
|
||||
if (preferenceStatusFilter.value) params.set('status', preferenceStatusFilter.value);
|
||||
try {
|
||||
preferenceItems = await api.get(`/api/teacher-preferences${params.size ? `?${params}` : ''}`);
|
||||
document.getElementById('teacher-preference-pending-count').textContent = String(
|
||||
preferenceItems.filter(item => item.status === 'PENDING').length
|
||||
);
|
||||
renderPreferenceReviews();
|
||||
} catch (error) {
|
||||
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить пожелания')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreferenceReviews() {
|
||||
preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => `
|
||||
<article class="teacher-dialogue-item">
|
||||
<span class="teacher-dialogue-mark preference-${escapeHtml(item.preferenceType.toLowerCase())}"></span>
|
||||
<div class="teacher-dialogue-copy">
|
||||
<small>${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')}</small>
|
||||
<h3>${escapeHtml(item.teacherName)}</h3>
|
||||
<p>${escapeHtml(preferenceDescription(item))}</p>
|
||||
${item.comment ? `<blockquote>${escapeHtml(item.comment)}</blockquote>` : ''}
|
||||
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
|
||||
</div>
|
||||
<div class="teacher-dialogue-action">
|
||||
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
|
||||
${item.status === 'PENDING' ? `
|
||||
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
|
||||
<div>
|
||||
<button class="btn btn-sm btn-primary" type="button" data-preference-review="approve" data-id="${item.id}">Согласовать</button>
|
||||
<button class="btn btn-sm btn-danger-subtle" type="button" data-preference-review="reject" data-id="${item.id}">Отклонить</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`).join('') : '<div class="absence-empty"><strong>Пожеланий не найдено</strong><span>Измените фильтры или дождитесь новых отметок преподавателей.</span></div>';
|
||||
}
|
||||
|
||||
async function handlePreferenceReview(event) {
|
||||
const button = event.target.closest('[data-preference-review][data-id]');
|
||||
if (!button) return;
|
||||
const card = button.closest('.teacher-dialogue-item');
|
||||
const approved = button.dataset.preferenceReview === 'approve';
|
||||
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
|
||||
if (!approved && !comment) {
|
||||
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api.post(`/api/teacher-preferences/${button.dataset.id}/review`, {
|
||||
approved,
|
||||
comment: comment || 'Согласовано ответственным сотрудником'
|
||||
});
|
||||
await loadPreferenceReviews();
|
||||
} catch (error) {
|
||||
await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChangeReviews() {
|
||||
changeReviewList.innerHTML = '<div class="loading-row">Загрузка заявок...</div>';
|
||||
const params = changeStatusFilter.value ? `?status=${encodeURIComponent(changeStatusFilter.value)}` : '';
|
||||
try {
|
||||
changeItems = await api.get(`/api/teacher-change-requests${params}`);
|
||||
document.getElementById('teacher-change-pending-count').textContent = String(
|
||||
changeItems.filter(item => item.status === 'PENDING').length
|
||||
);
|
||||
renderChangeReviews();
|
||||
} catch (error) {
|
||||
changeReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить заявки')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChangeReviews() {
|
||||
const canApply = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
|
||||
changeReviewList.innerHTML = changeItems.length ? changeItems.map(item => {
|
||||
const target = item.requestType === 'MOVE'
|
||||
? `${formatDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`
|
||||
: item.requestType === 'CHANGE_CLASSROOM' ? item.requestedClassroomName : 'Отмена занятия';
|
||||
return `<article class="teacher-dialogue-item change-item">
|
||||
<span class="teacher-dialogue-mark change-${escapeHtml(item.requestType.toLowerCase())}"></span>
|
||||
<div class="teacher-dialogue-copy">
|
||||
<small>${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)}</small>
|
||||
<h3>${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}</h3>
|
||||
<p>${escapeHtml(changeTypeLabel(item.requestType))} → <strong>${escapeHtml(target)}</strong></p>
|
||||
<blockquote>${escapeHtml(item.reason)}</blockquote>
|
||||
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
|
||||
<details><summary>История · ${(item.history || []).length}</summary>
|
||||
<ol>${(item.history || []).map(entry => `<li><b>${genericStatusLabel(entry.status)}</b> · ${escapeHtml(entry.comment || 'Без комментария')}</li>`).join('')}</ol>
|
||||
</details>
|
||||
</div>
|
||||
<div class="teacher-dialogue-action">
|
||||
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
|
||||
${item.status === 'PENDING' && canApply ? `
|
||||
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
|
||||
<div>
|
||||
<button class="btn btn-sm btn-primary" type="button" data-change-review="approve" data-id="${item.id}">Применить</button>
|
||||
<button class="btn btn-sm btn-danger-subtle" type="button" data-change-review="reject" data-id="${item.id}">Отклонить</button>
|
||||
</div>
|
||||
` : item.status === 'PENDING' ? '<small>Применение доступно учебному отделу</small>' : ''}
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('') : '<div class="absence-empty"><strong>Заявок не найдено</strong><span>Очередь изменений сейчас пуста.</span></div>';
|
||||
}
|
||||
|
||||
async function handleChangeReview(event) {
|
||||
const button = event.target.closest('[data-change-review][data-id]');
|
||||
if (!button) return;
|
||||
const card = button.closest('.teacher-dialogue-item');
|
||||
const approved = button.dataset.changeReview === 'approve';
|
||||
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
|
||||
if (!approved && !comment) {
|
||||
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
|
||||
await Promise.all([loadChangeReviews(), loadAbsences()]);
|
||||
} catch (error) {
|
||||
await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function decisionOptions(lesson) {
|
||||
const options = [];
|
||||
(lesson.teacherCandidates || []).forEach(candidate => options.push({
|
||||
value: `REPLACE_TEACHER|${candidate.id}`,
|
||||
label: `Заменить: ${candidate.name}`
|
||||
}));
|
||||
(lesson.moveCandidates || []).forEach(candidate => options.push({
|
||||
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
|
||||
label: `Перенести: ${formatDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
|
||||
}));
|
||||
(lesson.classroomCandidates || []).forEach(candidate => options.push({
|
||||
value: `CHANGE_CLASSROOM|${candidate.id}`,
|
||||
label: `Другая аудитория: ${candidate.name}`
|
||||
}));
|
||||
if (lesson.cancellationAvailable) {
|
||||
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
|
||||
}
|
||||
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
|
||||
return options;
|
||||
}
|
||||
|
||||
export function parseDecisionOption(value) {
|
||||
const [resolution, first, second] = String(value || '').split('|');
|
||||
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
|
||||
if (resolution === 'MOVE_TIME') {
|
||||
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
|
||||
}
|
||||
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
|
||||
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
|
||||
throw new Error('Неизвестное решение мастера замены');
|
||||
}
|
||||
|
||||
function renderJournal(journal) {
|
||||
document.getElementById('absence-journal-count').textContent = decisionCountLabel(journal.length);
|
||||
document.getElementById('absence-journal-list').innerHTML = journal.length ? journal.map(item => `
|
||||
<article>
|
||||
<span class="absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}"></span>
|
||||
<div><strong>${escapeHtml(resolutionLabel(item.resolution))}</strong><small>${escapeHtml(formatDate(item.lessonDate))}</small></div>
|
||||
<span>${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''}</span>
|
||||
</article>
|
||||
`).join('') : '<div class="loading-row">Решений пока нет</div>';
|
||||
}
|
||||
|
||||
function updateStats(items) {
|
||||
document.getElementById('absence-stat-active').textContent = items.filter(item => item.status === 'APPROVED').length;
|
||||
document.getElementById('absence-stat-pending').textContent = items.filter(item => item.status === 'PENDING').length;
|
||||
document.getElementById('absence-stat-resolved').textContent = items.filter(item => item.status === 'RESOLVED').length;
|
||||
}
|
||||
|
||||
function setDefaultPeriod(startInput, endInput) {
|
||||
const today = new Date();
|
||||
startInput.value = isoDate(today);
|
||||
endInput.value = isoDate(today);
|
||||
}
|
||||
|
||||
function isoDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—';
|
||||
const [year, month, day] = String(value).split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function dayOfMonth(value) {
|
||||
return String(value || '').split('-')[2] || '—';
|
||||
}
|
||||
|
||||
function monthShort(value) {
|
||||
const month = Number(String(value || '').split('-')[1]);
|
||||
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На подтверждении',
|
||||
APPROVED: 'Требует решения',
|
||||
REJECTED: 'Отклонено',
|
||||
RESOLVED: 'Разрешено',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
return `is-${String(status || '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
function genericStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На рассмотрении',
|
||||
APPROVED: 'Согласовано',
|
||||
REJECTED: 'Отклонено',
|
||||
CANCELLED: 'Отменено'
|
||||
}[status] || status || '—';
|
||||
}
|
||||
|
||||
function semesterLabel(semester) {
|
||||
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
|
||||
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}–${formatDate(semester.endDate)}`;
|
||||
}
|
||||
|
||||
function preferenceDescription(item) {
|
||||
const type = {
|
||||
HARD_UNAVAILABLE: 'Строгая недоступность',
|
||||
SOFT_PREFERRED: 'Предпочтительное время',
|
||||
SOFT_UNWANTED: 'Нежелательное время',
|
||||
CONSECUTIVE: 'Пожелание «пары подряд»',
|
||||
NO_GAPS: 'Пожелание «без окон»'
|
||||
}[item.preferenceType] || item.preferenceType;
|
||||
if (item.preferenceDate) return `${type}: ${formatDate(item.preferenceDate)}`;
|
||||
if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`;
|
||||
return type;
|
||||
}
|
||||
|
||||
function changeTypeLabel(type) {
|
||||
return {
|
||||
MOVE: 'Перенос даты или времени',
|
||||
CHANGE_CLASSROOM: 'Смена аудитории',
|
||||
CANCEL: 'Отмена занятия'
|
||||
}[type] || type || 'Изменение';
|
||||
}
|
||||
|
||||
function resolutionLabel(resolution) {
|
||||
return {
|
||||
REPLACE_TEACHER: 'Замена преподавателя',
|
||||
MOVE_TIME: 'Перенос занятия',
|
||||
CHANGE_CLASSROOM: 'Замена аудитории',
|
||||
CANCEL: 'Отмена занятия',
|
||||
REJECT: 'Предложение отклонено'
|
||||
}[resolution] || resolution || 'Решение';
|
||||
}
|
||||
|
||||
function decisionStatusLabel(status) {
|
||||
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
|
||||
}
|
||||
|
||||
function decisionCountLabel(count) {
|
||||
const value = Number(count) || 0;
|
||||
const tail = value % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${value} решений`;
|
||||
if (value % 10 === 1) return `${value} решение`;
|
||||
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
|
||||
return `${value} решений`;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js';
|
||||
import { promptAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
export async function initTeacherRequests() {
|
||||
const requestsTbody = document.getElementById('teacher-requests-tbody');
|
||||
const refreshButton = document.getElementById('teacher-requests-refresh');
|
||||
const requestsCount = document.getElementById('teacher-requests-count');
|
||||
const searchInput = document.getElementById('teacher-requests-search');
|
||||
const statusSelect = document.getElementById('teacher-requests-status');
|
||||
const pagination = document.getElementById('teacher-requests-pagination');
|
||||
let departments = [];
|
||||
const restored = readUrlState({ requestQuery: '', requestStatus: 'PENDING', requestPage: '0', requestSize: '25' });
|
||||
const state = {
|
||||
query: restored.requestQuery,
|
||||
status: restored.requestStatus,
|
||||
page: Math.max(Number(restored.requestPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.requestSize)) ? Number(restored.requestSize) : 25
|
||||
};
|
||||
searchInput.value = state.query;
|
||||
statusSelect.value = state.status;
|
||||
|
||||
async function loadDepartments() {
|
||||
departments = await api.get('/api/departments');
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
renderTableLoading(requestsTbody, 7, 'Загрузка заявок...');
|
||||
hideAlert('teacher-requests-alert');
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
query: state.query,
|
||||
page: String(state.page),
|
||||
size: String(state.size),
|
||||
sort: 'createdAt',
|
||||
direction: 'desc'
|
||||
});
|
||||
if (state.status) params.set('status', state.status);
|
||||
const page = await api.get(`/api/teacher-requests/page?${params}`);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && state.page > 0) {
|
||||
state.page = Math.max(0, page.totalPages - 1);
|
||||
return loadRequests();
|
||||
}
|
||||
const normalizedRequests = page.items || [];
|
||||
renderRequests(normalizedRequests);
|
||||
updateCount(Number(page.totalItems || 0));
|
||||
renderPagination(pagination, page, {
|
||||
label: 'заявок',
|
||||
onPage: nextPage => { state.page = nextPage; loadRequests(); },
|
||||
onSize: size => { state.size = size; state.page = 0; loadRequests(); }
|
||||
});
|
||||
persistState();
|
||||
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
|
||||
detail: { count: state.status === 'PENDING' && !state.query ? Number(page.totalItems || 0) : undefined }
|
||||
}));
|
||||
trackUxEvent('реестр_заявок_загружен', { total: Number(page.totalItems || 0), page: state.page });
|
||||
} catch (error) {
|
||||
renderTableError(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей', loadRequests);
|
||||
updateCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRequests(requests) {
|
||||
if (!requests.length) {
|
||||
renderTableEmpty(requestsTbody, 7, state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет');
|
||||
return;
|
||||
}
|
||||
requestsTbody.innerHTML = requests.map(request => `
|
||||
<tr data-request-id="${escapeHtml(request.id)}">
|
||||
<td>
|
||||
<select class="teacher-request-department" data-native-select="true">
|
||||
${departments.map(department => `
|
||||
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
|
||||
${escapeHtml(department.departmentName || department.name || department.id)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50" autocomplete="username"></td>
|
||||
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
|
||||
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
|
||||
<td><input type="password" class="teacher-request-password" placeholder="Минимум 8 символов" autocomplete="new-password"></td>
|
||||
<td>${escapeHtml(request.comment || '-')}</td>
|
||||
<td>
|
||||
<div class="teacher-request-actions">
|
||||
${request.status === 'PENDING' ? `
|
||||
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
|
||||
` : `<span class="badge">${escapeHtml(request.status === 'APPROVED' ? 'Одобрена' : 'Отклонена')}</span>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(requestsTbody);
|
||||
}
|
||||
|
||||
function updateCount(count) {
|
||||
if (!requestsCount) return;
|
||||
requestsCount.textContent = requestCountLabel(count);
|
||||
}
|
||||
|
||||
function requestCountLabel(count) {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
|
||||
return `${count} заявок`;
|
||||
}
|
||||
|
||||
async function approveRequest(row, requestId) {
|
||||
const payload = {
|
||||
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
|
||||
username: row.querySelector('.teacher-request-username')?.value.trim(),
|
||||
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
|
||||
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
|
||||
password: row.querySelector('.teacher-request-password')?.value
|
||||
};
|
||||
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
|
||||
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
|
||||
return;
|
||||
}
|
||||
if (payload.password.length < 8) {
|
||||
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
|
||||
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
|
||||
await loadRequests();
|
||||
} catch (error) {
|
||||
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectRequest(requestId) {
|
||||
const reviewComment = await promptAction({
|
||||
title: 'Отклонить заявку?',
|
||||
message: 'Комментарий увидит кафедра, отправившая заявку.',
|
||||
confirmLabel: 'Отклонить',
|
||||
danger: true,
|
||||
input: {
|
||||
label: 'Причина отклонения',
|
||||
placeholder: 'Укажите, что нужно исправить',
|
||||
required: true,
|
||||
requiredMessage: 'Укажите причину отклонения'
|
||||
}
|
||||
});
|
||||
if (reviewComment === null) return;
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
|
||||
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
|
||||
await loadRequests();
|
||||
} catch (error) {
|
||||
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
refreshButton?.addEventListener('click', loadRequests);
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
state.query = searchInput.value.trim();
|
||||
state.page = 0;
|
||||
loadRequests();
|
||||
}));
|
||||
statusSelect.addEventListener('change', () => {
|
||||
state.status = statusSelect.value;
|
||||
state.page = 0;
|
||||
loadRequests();
|
||||
});
|
||||
|
||||
requestsTbody.addEventListener('click', async (event) => {
|
||||
const approveButton = event.target.closest('.teacher-request-approve');
|
||||
const rejectButton = event.target.closest('.teacher-request-reject');
|
||||
if (!approveButton && !rejectButton) return;
|
||||
|
||||
const row = event.target.closest('tr[data-request-id]');
|
||||
const requestId = row?.dataset.requestId;
|
||||
if (!requestId) return;
|
||||
|
||||
if (approveButton) {
|
||||
await approveRequest(row, requestId);
|
||||
return;
|
||||
}
|
||||
await rejectRequest(requestId);
|
||||
});
|
||||
|
||||
try {
|
||||
await loadDepartments();
|
||||
await loadRequests();
|
||||
} catch (error) {
|
||||
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
requestQuery: state.query,
|
||||
requestStatus: state.status === 'PENDING' ? '' : state.status,
|
||||
requestPage: state.page || '',
|
||||
requestSize: state.size === 25 ? '' : state.size
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
|
||||
export async function initUniversityStructure() {
|
||||
const deptTbody = document.getElementById('departments-tbody');
|
||||
const specTbody = document.getElementById('specialties-tbody');
|
||||
|
||||
const createDeptForm = document.getElementById('create-department-form');
|
||||
const createSpecForm = document.getElementById('create-specialty-form');
|
||||
const editDeptForm = document.getElementById('edit-department-form');
|
||||
const editSpecForm = document.getElementById('edit-specialty-form');
|
||||
const editDeptModal = document.getElementById('modal-edit-department');
|
||||
const editSpecModal = document.getElementById('modal-edit-specialty');
|
||||
const editDeptClose = document.getElementById('modal-edit-department-close');
|
||||
const editSpecClose = document.getElementById('modal-edit-specialty-close');
|
||||
|
||||
// Элементы вкладок
|
||||
const tabDepartments = document.getElementById('btn-tab-departments');
|
||||
const tabSpecialties = document.getElementById('btn-tab-specialties');
|
||||
const tabProfiles = document.getElementById('btn-tab-profiles');
|
||||
const contentDepartments = document.getElementById('tab-content-departments');
|
||||
const contentSpecialties = document.getElementById('tab-content-specialties');
|
||||
const contentProfiles = document.getElementById('tab-content-profiles');
|
||||
|
||||
// Элементы модалки профилей
|
||||
const profilesModal = document.getElementById('modal-manage-profiles');
|
||||
const profilesModalClose = document.getElementById('modal-manage-profiles-close');
|
||||
const profilesSpecContext = document.getElementById('profiles-spec-context');
|
||||
const profileFormTitle = document.getElementById('profile-form-title');
|
||||
const manageProfileForm = document.getElementById('manage-profile-form');
|
||||
const profileIdInput = document.getElementById('manage-profile-id');
|
||||
const profileSpecIdInput = document.getElementById('manage-profile-spec-id');
|
||||
const profileNameInput = document.getElementById('manage-profile-name');
|
||||
const profileDescInput = document.getElementById('manage-profile-description');
|
||||
const btnSaveProfile = document.getElementById('btn-save-profile');
|
||||
const btnCancelProfileEdit = document.getElementById('btn-cancel-profile-edit');
|
||||
const profilesTbody = document.getElementById('manage-profiles-tbody');
|
||||
|
||||
// Элементы вкладки Профилей
|
||||
const tabProfileTbody = document.getElementById('profiles-tbody');
|
||||
const createProfileTabForm = document.getElementById('create-profile-tab-form');
|
||||
const tabProfileIdInput = document.getElementById('tab-profile-id');
|
||||
const tabProfileSpecSelect = document.getElementById('tab-profile-spec');
|
||||
const tabProfileNameInput = document.getElementById('tab-profile-name');
|
||||
const tabProfileDescInput = document.getElementById('tab-profile-description');
|
||||
const mainProfileFormTitle = document.getElementById('main-profile-form-title');
|
||||
const btnTabSaveProfile = document.getElementById('btn-tab-save-profile');
|
||||
const btnTabCancelProfileEdit = document.getElementById('btn-tab-cancel-profile-edit');
|
||||
|
||||
let departments = [];
|
||||
let specialties = [];
|
||||
let currentProfiles = [];
|
||||
let allProfiles = [];
|
||||
const listStates = Object.fromEntries(['departments', 'specialties', 'profiles'].map(key => [key, { page: 0, size: 25 }]));
|
||||
const listRenderers = { departments: renderDepartments, specialties: renderSpecialties, profiles: renderAllProfiles };
|
||||
Object.keys(listStates).forEach(key => {
|
||||
document.getElementById(`${key}-search`)?.addEventListener('input', debounce(() => {
|
||||
listStates[key].page = 0;
|
||||
listRenderers[key]();
|
||||
}, 250));
|
||||
});
|
||||
|
||||
function listPage(key, items, fields) {
|
||||
const query = document.getElementById(`${key}-search`).value.trim().toLocaleLowerCase('ru');
|
||||
const filtered = items.filter(item => fields(item).join(' ').toLocaleLowerCase('ru').includes(query));
|
||||
const state = listStates[key];
|
||||
const totalPages = Math.ceil(filtered.length / state.size);
|
||||
state.page = Math.min(state.page, Math.max(0, totalPages - 1));
|
||||
renderPagination(document.getElementById(`${key}-pagination`), {
|
||||
...state, totalItems: filtered.length, totalPages
|
||||
}, {
|
||||
onPage: value => { state.page = value; listRenderers[key](); },
|
||||
onSize: value => { state.size = value; state.page = 0; listRenderers[key](); }
|
||||
});
|
||||
return filtered.slice(state.page * state.size, (state.page + 1) * state.size);
|
||||
}
|
||||
|
||||
// --- Логика переключения табов ---
|
||||
const structureTabs = [
|
||||
{ button: tabDepartments, panel: contentDepartments },
|
||||
{ button: tabSpecialties, panel: contentSpecialties },
|
||||
{ button: tabProfiles, panel: contentProfiles },
|
||||
];
|
||||
|
||||
function activateStructureTab(activeButton) {
|
||||
structureTabs.forEach(({ button, panel }) => {
|
||||
const active = button === activeButton;
|
||||
button?.classList.toggle('active', active);
|
||||
button?.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
if (panel) {
|
||||
panel.classList.toggle('active', active);
|
||||
panel.hidden = !active;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
structureTabs.forEach(({ button }) => {
|
||||
button?.addEventListener('click', () => activateStructureTab(button));
|
||||
});
|
||||
|
||||
// --- Загрузка данных ---
|
||||
async function loadData() {
|
||||
// Загрузка кафедр
|
||||
try {
|
||||
departments = await api.get('/api/departments');
|
||||
renderDepartments();
|
||||
} catch (e) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
|
||||
// Загрузка специальностей
|
||||
try {
|
||||
specialties = await api.get('/api/specialties');
|
||||
renderSpecialties();
|
||||
populateSpecialtiesDropdown();
|
||||
} catch (e) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
|
||||
// Загрузка всех профилей
|
||||
try {
|
||||
allProfiles = await api.get('/api/specialties/profiles');
|
||||
renderAllProfiles();
|
||||
} catch (e) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDepartments() {
|
||||
const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
|
||||
if (!visible.length) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
deptTbody.innerHTML = visible.map(d => `
|
||||
<tr>
|
||||
<td>${d.id}</td>
|
||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-department" data-id="${d.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-department" data-id="${d.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderSpecialties() {
|
||||
const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
|
||||
if (!visible.length) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
specTbody.innerHTML = visible.map(s => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-profiles" data-id="${s.id}">Профили</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// --- Кафедры: CRUD ---
|
||||
createDeptForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-dept-alert');
|
||||
const name = document.getElementById('dept-name').value.trim();
|
||||
const code = document.getElementById('dept-code').value.trim();
|
||||
|
||||
if (!name || !code) {
|
||||
showAlert('create-dept-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
|
||||
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
|
||||
createDeptForm.reset();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
deptTbody.addEventListener('click', async (e) => {
|
||||
const editBtn = e.target.closest('.btn-edit-department');
|
||||
const deleteBtn = e.target.closest('.btn-delete-department');
|
||||
|
||||
if (editBtn) {
|
||||
const department = departments.find(item => item.id == editBtn.dataset.id);
|
||||
if (!department) return;
|
||||
|
||||
document.getElementById('edit-dept-id').value = department.id;
|
||||
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
|
||||
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
|
||||
hideAlert('edit-dept-alert');
|
||||
editDeptModal.classList.add('open');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!await confirmAction({ title: 'Удаление кафедры', message: 'Удалить кафедру?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/departments/' + deleteBtn.dataset.id);
|
||||
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
editDeptForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('edit-dept-alert');
|
||||
const id = document.getElementById('edit-dept-id').value;
|
||||
const name = document.getElementById('edit-dept-name').value.trim();
|
||||
const code = document.getElementById('edit-dept-code').value.trim();
|
||||
|
||||
if (!name || !code) {
|
||||
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
|
||||
editDeptModal.classList.remove('open');
|
||||
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Специальности: CRUD ---
|
||||
createSpecForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-spec-alert');
|
||||
const name = document.getElementById('spec-name').value.trim();
|
||||
const code = document.getElementById('spec-code').value.trim();
|
||||
|
||||
if (!name || !code) {
|
||||
showAlert('create-spec-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
|
||||
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
|
||||
createSpecForm.reset();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
specTbody.addEventListener('click', async (e) => {
|
||||
const editBtn = e.target.closest('.btn-edit-specialty');
|
||||
const deleteBtn = e.target.closest('.btn-delete-specialty');
|
||||
const profilesBtn = e.target.closest('.btn-manage-profiles');
|
||||
|
||||
if (editBtn) {
|
||||
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
|
||||
if (!speciality) return;
|
||||
|
||||
document.getElementById('edit-spec-id').value = speciality.id;
|
||||
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
|
||||
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
|
||||
hideAlert('edit-spec-alert');
|
||||
editSpecModal.classList.add('open');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!await confirmAction({ title: 'Удаление специальности', message: 'Удалить специальность?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
|
||||
showAlert('create-spec-alert', 'Специальность удалена', 'success');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (profilesBtn) {
|
||||
const specId = profilesBtn.dataset.id;
|
||||
const speciality = specialties.find(item => item.id == specId);
|
||||
if (!speciality) return;
|
||||
|
||||
openProfilesModal(speciality);
|
||||
}
|
||||
});
|
||||
|
||||
editSpecForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('edit-spec-alert');
|
||||
const id = document.getElementById('edit-spec-id').value;
|
||||
const name = document.getElementById('edit-spec-name').value.trim();
|
||||
const code = document.getElementById('edit-spec-code').value.trim();
|
||||
|
||||
if (!name || !code) {
|
||||
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
|
||||
editSpecModal.classList.remove('open');
|
||||
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Профили обучения: Логика ---
|
||||
async function openProfilesModal(speciality) {
|
||||
profilesSpecContext.textContent = `${speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code} · ${speciality.specialityName || speciality.name}`;
|
||||
profileSpecIdInput.value = speciality.id;
|
||||
resetProfileForm();
|
||||
profilesModal.classList.add('open');
|
||||
await loadProfiles(speciality.id);
|
||||
}
|
||||
|
||||
async function loadProfiles(specId) {
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
currentProfiles = await api.get(`/api/specialties/${specId}/profiles`);
|
||||
renderProfilesTable();
|
||||
} catch (e) {
|
||||
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfilesTable() {
|
||||
if (!currentProfiles || !currentProfiles.length) {
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
profilesTbody.innerHTML = currentProfiles.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${escapeHtml(p.name)}</td>
|
||||
<td>${escapeHtml(p.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-profile" data-id="${p.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function resetProfileForm() {
|
||||
manageProfileForm.reset();
|
||||
profileIdInput.value = '';
|
||||
profileFormTitle.textContent = 'Создание профиля';
|
||||
btnSaveProfile.textContent = 'Создать';
|
||||
btnCancelProfileEdit.hidden = true;
|
||||
hideAlert('manage-profile-alert');
|
||||
}
|
||||
|
||||
manageProfileForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('manage-profile-alert');
|
||||
|
||||
const specId = profileSpecIdInput.value;
|
||||
const profileId = profileIdInput.value;
|
||||
const name = profileNameInput.value.trim();
|
||||
const description = profileDescInput.value.trim();
|
||||
|
||||
if (!name) {
|
||||
showAlert('manage-profile-alert', 'Введите название профиля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (profileId) {
|
||||
// Обновление
|
||||
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
|
||||
showAlert('manage-profile-alert', `Профиль "${name}" обновлен`, 'success');
|
||||
} else {
|
||||
// Создание
|
||||
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
|
||||
showAlert('manage-profile-alert', `Профиль "${name}" создан`, 'success');
|
||||
}
|
||||
resetProfileForm();
|
||||
await loadProfiles(specId);
|
||||
} catch (error) {
|
||||
showAlert('manage-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
profilesTbody.addEventListener('click', async (e) => {
|
||||
const editBtn = e.target.closest('.btn-edit-profile');
|
||||
const deleteBtn = e.target.closest('.btn-delete-profile');
|
||||
const specId = profileSpecIdInput.value;
|
||||
|
||||
if (editBtn) {
|
||||
const profile = currentProfiles.find(item => item.id == editBtn.dataset.id);
|
||||
if (!profile) return;
|
||||
|
||||
profileIdInput.value = profile.id;
|
||||
profileNameInput.value = profile.name || '';
|
||||
profileDescInput.value = profile.description || '';
|
||||
profileFormTitle.textContent = 'Редактирование профиля';
|
||||
btnSaveProfile.textContent = 'Сохранить';
|
||||
btnCancelProfileEdit.hidden = false;
|
||||
hideAlert('manage-profile-alert');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
|
||||
showAlert('manage-profile-alert', 'Профиль удален', 'success');
|
||||
await loadProfiles(specId);
|
||||
} catch (error) {
|
||||
showAlert('manage-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
btnCancelProfileEdit.addEventListener('click', resetProfileForm);
|
||||
|
||||
// Закрытие модалок
|
||||
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
|
||||
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
|
||||
profilesModalClose.addEventListener('click', () => profilesModal.classList.remove('open'));
|
||||
|
||||
editDeptModal.addEventListener('click', (e) => {
|
||||
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
|
||||
});
|
||||
editSpecModal.addEventListener('click', (e) => {
|
||||
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
|
||||
});
|
||||
profilesModal.addEventListener('click', (e) => {
|
||||
if (e.target === profilesModal) profilesModal.classList.remove('open');
|
||||
});
|
||||
|
||||
// --- Дополнительные функции для вкладки Профилей ---
|
||||
function populateSpecialtiesDropdown() {
|
||||
const currentValue = tabProfileSpecSelect.value;
|
||||
tabProfileSpecSelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||||
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode)} · ${escapeHtml(s.specialityName || s.name)}</option>`).join('');
|
||||
tabProfileSpecSelect.value = currentValue;
|
||||
}
|
||||
|
||||
function renderAllProfiles() {
|
||||
const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
|
||||
if (!visible.length) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
tabProfileTbody.innerHTML = visible.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
|
||||
<td>${escapeHtml(p.name)}</td>
|
||||
<td>${escapeHtml(p.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function resetTabProfileForm() {
|
||||
createProfileTabForm.reset();
|
||||
tabProfileIdInput.value = '';
|
||||
tabProfileSpecSelect.disabled = false;
|
||||
mainProfileFormTitle.textContent = 'Создание профиля';
|
||||
btnTabSaveProfile.textContent = 'Создать';
|
||||
btnTabCancelProfileEdit.hidden = true;
|
||||
hideAlert('tab-profile-alert');
|
||||
tabProfileSpecSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
createProfileTabForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('tab-profile-alert');
|
||||
|
||||
const specId = tabProfileSpecSelect.value;
|
||||
const profileId = tabProfileIdInput.value;
|
||||
const name = tabProfileNameInput.value.trim();
|
||||
const description = tabProfileDescInput.value.trim();
|
||||
|
||||
if (!specId) {
|
||||
showAlert('tab-profile-alert', 'Выберите специальность', 'error');
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
showAlert('tab-profile-alert', 'Введите название профиля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (profileId) {
|
||||
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
|
||||
showAlert('tab-profile-alert', `Профиль "${name}" обновлен`, 'success');
|
||||
} else {
|
||||
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
|
||||
showAlert('tab-profile-alert', `Профиль "${name}" создан`, 'success');
|
||||
}
|
||||
resetTabProfileForm();
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showAlert('tab-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
tabProfileTbody.addEventListener('click', async (e) => {
|
||||
const editBtn = e.target.closest('.btn-edit-tab-profile');
|
||||
const deleteBtn = e.target.closest('.btn-delete-tab-profile');
|
||||
|
||||
if (editBtn) {
|
||||
const profile = allProfiles.find(item => item.id == editBtn.dataset.id);
|
||||
if (!profile) return;
|
||||
|
||||
tabProfileIdInput.value = profile.id;
|
||||
tabProfileSpecSelect.value = profile.specialityId;
|
||||
tabProfileSpecSelect.disabled = true;
|
||||
tabProfileSpecSelect.dispatchEvent(new Event('change'));
|
||||
|
||||
tabProfileNameInput.value = profile.name || '';
|
||||
tabProfileDescInput.value = profile.description || '';
|
||||
|
||||
mainProfileFormTitle.textContent = 'Редактирование профиля';
|
||||
btnTabSaveProfile.textContent = 'Сохранить';
|
||||
btnTabCancelProfileEdit.hidden = false;
|
||||
hideAlert('tab-profile-alert');
|
||||
|
||||
createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
|
||||
if (!profile) return;
|
||||
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
|
||||
showAlert('tab-profile-alert', 'Профиль удален', 'success');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showAlert('tab-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
btnTabCancelProfileEdit.addEventListener('click', resetTabProfileForm);
|
||||
|
||||
await loadData();
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
const ROLE_LABELS = {
|
||||
ADMIN: 'Администратор',
|
||||
EDUCATION_OFFICE: 'Учебный отдел',
|
||||
DEPARTMENT: 'Кафедра',
|
||||
SCHEDULE_VIEWER: 'Просмотр расписаний',
|
||||
TEACHER: 'Преподаватель',
|
||||
STUDENT: 'Студент'
|
||||
};
|
||||
const ROLE_BADGE = {
|
||||
ADMIN: 'badge-admin',
|
||||
EDUCATION_OFFICE: 'badge-admin',
|
||||
DEPARTMENT: 'badge-teacher',
|
||||
SCHEDULE_VIEWER: 'badge-student',
|
||||
TEACHER: 'badge-teacher',
|
||||
STUDENT: 'badge-student'
|
||||
};
|
||||
|
||||
export async function initUsers() {
|
||||
const usersTbody = document.getElementById('users-tbody');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const searchInput = document.getElementById('users-search');
|
||||
const roleFilter = document.getElementById('users-role-filter');
|
||||
const sortSelect = document.getElementById('users-sort');
|
||||
const pagination = document.getElementById('users-pagination');
|
||||
const restored = readUrlState({ userQuery: '', userRole: '', userPage: '0', userSize: '25', userSort: 'fullName,asc' });
|
||||
const state = {
|
||||
query: restored.userQuery,
|
||||
role: restored.userRole,
|
||||
page: Math.max(Number(restored.userPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.userSize)) ? Number(restored.userSize) : 25,
|
||||
sort: restored.userSort
|
||||
};
|
||||
searchInput.value = state.query;
|
||||
roleFilter.value = state.role;
|
||||
sortSelect.value = state.sort;
|
||||
|
||||
async function loadUsers() {
|
||||
renderTableLoading(usersTbody, 7, 'Загрузка пользователей...');
|
||||
hideAlert('users-alert');
|
||||
try {
|
||||
const [sort, direction] = state.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: state.query,
|
||||
page: String(state.page),
|
||||
size: String(state.size),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
if (state.role) params.set('role', state.role);
|
||||
const result = await api.get(`/api/users/page?${params}`);
|
||||
renderUsers(result.items || []);
|
||||
renderPagination(pagination, result, {
|
||||
label: 'пользователей',
|
||||
onPage: page => { state.page = page; loadUsers(); },
|
||||
onSize: size => { state.size = size; state.page = 0; loadUsers(); }
|
||||
});
|
||||
persistState();
|
||||
trackUxEvent('реестр_пользователей_загружен', { total: Number(result.totalItems || 0), page: state.page });
|
||||
} catch (e) {
|
||||
renderTableError(usersTbody, 7, e.message || 'Не удалось загрузить пользователей', loadUsers);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
if (!users || !users.length) {
|
||||
renderTableEmpty(usersTbody, 7, state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет');
|
||||
return;
|
||||
}
|
||||
|
||||
usersTbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td>${escapeHtml(u.username)}</td>
|
||||
<td>${escapeHtml(u.fullName || '-')}</td>
|
||||
<td>${escapeHtml(u.jobTitle || '-')}</td>
|
||||
<td>${escapeHtml(u.departmentName || '-')}</td>
|
||||
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
|
||||
<td>
|
||||
${u.status === 'ARCHIVED'
|
||||
? `<button class="btn btn-sm btn-secondary btn-restore-user" data-id="${u.id}">Восстановить</button>`
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(usersTbody);
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
state.query = searchInput.value.trim();
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
}));
|
||||
roleFilter.addEventListener('change', () => {
|
||||
state.role = roleFilter.value;
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
});
|
||||
sortSelect.addEventListener('change', () => {
|
||||
state.sort = sortSelect.value;
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
createForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-alert');
|
||||
|
||||
const username = document.getElementById('new-username').value.trim();
|
||||
const password = document.getElementById('new-password').value;
|
||||
const role = document.getElementById('new-role').value;
|
||||
const fullName = document.getElementById('new-fullname').value.trim();
|
||||
const jobTitle = document.getElementById('new-jobtitle').value.trim();
|
||||
const departmentId = document.getElementById('new-department').value;
|
||||
|
||||
if (!username || !password || !fullName || !jobTitle || !departmentId) {
|
||||
showAlert('create-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/users', {
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
fullName,
|
||||
jobTitle,
|
||||
departmentId: Number(departmentId)
|
||||
});
|
||||
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
|
||||
createForm.reset();
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
showAlert('create-alert', err.message || 'Ошибка соединения', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
usersTbody.addEventListener('click', async (e) => {
|
||||
const deleteBtn = e.target.closest('.btn-archive-user');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать пользователя?',
|
||||
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/users/' + deleteBtn.dataset.id);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
showAlert('users-alert', err.message || 'Ошибка архивирования', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
usersTbody.addEventListener('click', async (e) => {
|
||||
const restoreBtn = e.target.closest('.btn-restore-user');
|
||||
if (!restoreBtn) return;
|
||||
|
||||
try {
|
||||
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
showAlert('users-alert', err.message || 'Ошибка восстановления', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
userQuery: state.query,
|
||||
userRole: state.role,
|
||||
userPage: state.page || '',
|
||||
userSize: state.size === 25 ? '' : state.size,
|
||||
userSort: state.sort === 'fullName,asc' ? '' : state.sort
|
||||
});
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
/* ===== Корневой контейнер ===== */
|
||||
|
||||
/* body — flex-контейнер: без явного растяжения корневой div сжимается
|
||||
до max-content, и узкие страницы не занимают всю ширину монитора.
|
||||
Растягиваем корень ровно на ширину окна (flex-basis 0), а не под контент. */
|
||||
#settings-root {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ===== Sidebar ===== */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
|
||||
@@ -360,6 +360,46 @@ body {
|
||||
padding: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* Карточка статуса текущего подключения к БД (замена inline-стилей legacy) */
|
||||
.db-status-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.db-status-grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.db-status-grid > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.db-status-grid span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.db-status-grid code {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Широкое поле формы и выравнивание кнопок действий в строке формы */
|
||||
.form-group-wide {
|
||||
flex: 3;
|
||||
}
|
||||
|
||||
.form-row-actions {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
|
||||
.scope-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -12,95 +12,9 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Боковая панель -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
<rect width="40" height="40" rx="12" fill="url(#lg)" />
|
||||
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<defs>
|
||||
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
|
||||
<stop stop-color="#6366f1" />
|
||||
<stop offset="1" stop-color="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>Настройки</span>
|
||||
</div>
|
||||
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item" data-tab="general">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
<span>Общие настройки</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="time-slots">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polyline points="12 7 12 12 16 14" />
|
||||
<path d="M4 4l3 3" />
|
||||
<path d="M20 4l-3 3" />
|
||||
</svg>
|
||||
<span>Временные слоты</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="edu-forms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
<line x1="9" y1="7" x2="17" y2="7" />
|
||||
<line x1="9" y1="11" x2="15" y2="11" />
|
||||
</svg>
|
||||
<span>Формы обучения</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="database">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
</svg>
|
||||
<span>База данных</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a href="/admin/" class="btn-back">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
<span>Назад в панель</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="settings-root"></div>
|
||||
|
||||
<!-- Затемнение боковой панели на мобильных -->
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- Основная область -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
</header>
|
||||
|
||||
<section class="content" id="app-content">
|
||||
<!-- Контент загружается через JS -->
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/theme-toggle.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
<script type="module" src="/react/settings.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// Основной модуль страницы настроек
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
||||
import { renderStatusMessage } from '../../js/utils.js';
|
||||
import { SETTINGS_ROLES, capabilitiesForRole } from '../../js/role-capabilities.js';
|
||||
|
||||
import { initializeSession } from '../../js/api.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const session = await initializeSession();
|
||||
if (!session || !SETTINGS_ROLES.includes(session.role)) {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
const role = session.role;
|
||||
|
||||
// Глобальная инициализация кастомных списков
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initAllCustomDropdowns(document.body);
|
||||
startDropdownAutoObserver();
|
||||
});
|
||||
|
||||
// Настройка вкладок
|
||||
const ROUTES = {
|
||||
general: { title: 'Общие настройки', file: 'views/general.html' },
|
||||
'time-slots': {
|
||||
title: 'Временные слоты',
|
||||
file: 'views/time-slots.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/time-slots.js');
|
||||
await module.initTimeSlotsSettings();
|
||||
}
|
||||
},
|
||||
database: {
|
||||
title: 'База данных',
|
||||
file: 'views/database.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/database.js');
|
||||
await module.initDatabase();
|
||||
}
|
||||
},
|
||||
'edu-forms': {
|
||||
title: 'Формы обучения',
|
||||
file: 'views/edu-forms.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/edu-forms.js');
|
||||
await module.initEduForms();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const roleCapabilities = capabilitiesForRole(role);
|
||||
const allowedTabs = new Set(roleCapabilities.settingsTabs);
|
||||
const defaultTab = roleCapabilities.defaultSettingsTab;
|
||||
|
||||
let currentTab = null;
|
||||
|
||||
// DOM-элементы
|
||||
const appContent = document.getElementById('app-content');
|
||||
const pageTitle = document.getElementById('page-title');
|
||||
const navItems = document.querySelectorAll('.nav-item[data-tab]');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
// Состояние боковой панели из localStorage
|
||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
main.classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
// Сворачивание боковой панели
|
||||
sidebarCloseBtn?.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
|
||||
}
|
||||
});
|
||||
|
||||
sidebarOverlay.addEventListener('click', () => {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
});
|
||||
|
||||
// Навигация
|
||||
navItems.forEach(item => {
|
||||
item.hidden = !allowedTabs.has(item.dataset.tab);
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const tab = item.dataset.tab;
|
||||
switchTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
async function switchTab(tab) {
|
||||
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
|
||||
|
||||
navItems.forEach(n => n.classList.remove('active'));
|
||||
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
|
||||
pageTitle.textContent = ROUTES[tab].title;
|
||||
|
||||
try {
|
||||
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
|
||||
const response = await fetch(ROUTES[tab].file);
|
||||
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
|
||||
|
||||
const html = await response.text();
|
||||
appContent.innerHTML = html;
|
||||
initAllCustomDropdowns(appContent);
|
||||
if (ROUTES[tab].init) {
|
||||
await ROUTES[tab].init();
|
||||
}
|
||||
|
||||
currentTab = tab;
|
||||
} catch (e) {
|
||||
renderStatusMessage(appContent, 'Не удалось загрузить раздел настроек');
|
||||
console.error('Не удалось загрузить раздел настроек');
|
||||
}
|
||||
|
||||
// Закрываем мобильное меню после перехода
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
}
|
||||
|
||||
// Вкладка по умолчанию
|
||||
switchTab(defaultTab);
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
@@ -1,163 +0,0 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
|
||||
import { alertAction, confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
export async function initDatabase() {
|
||||
const tenantsTbody = document.getElementById('tenants-tbody');
|
||||
const addTenantForm = document.getElementById('add-tenant-form');
|
||||
const statusInfo = document.getElementById('db-status-info');
|
||||
const btnTest = document.getElementById('btn-test-connection');
|
||||
|
||||
// === Загрузка статуса текущего подключения ===
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const data = await api.get('/api/database/status');
|
||||
const statusBadge = data.connected
|
||||
? '<span class="badge badge-available">Доступно</span>'
|
||||
: '<span class="badge badge-unavailable">Недоступно</span>';
|
||||
|
||||
statusInfo.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">Тенант:</span>
|
||||
<strong>${escapeHtml(data.tenant || '—')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">Название:</span>
|
||||
<strong>${escapeHtml(data.name || '—')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">Статус:</span>
|
||||
${statusBadge}
|
||||
</div>
|
||||
${data.url ? `<div>
|
||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">URL:</span>
|
||||
<code style="font-size: 0.85rem;">${escapeHtml(data.url)}</code>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
renderStatusMessage(statusInfo, 'Не удалось загрузить статус подключения');
|
||||
}
|
||||
}
|
||||
|
||||
// === Загрузка списка тенантов ===
|
||||
async function loadTenants() {
|
||||
try {
|
||||
const tenants = await api.get('/api/database/tenants');
|
||||
renderTenantsTable(tenants);
|
||||
} catch (e) {
|
||||
renderTableMessage(tenantsTbody, 6, 'Не удалось загрузить список тенантов');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTenantsTable(tenants) {
|
||||
if (!tenants || !tenants.length) {
|
||||
tenantsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет подключённых тенантов</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tenantsTbody.innerHTML = tenants.map(t => {
|
||||
const statusBadge = t.connected
|
||||
? '<span class="badge badge-available">Доступно</span>'
|
||||
: '<span class="badge badge-unavailable">Недоступно</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(t.name || '—')}</td>
|
||||
<td><code>${escapeHtml(t.domain)}</code></td>
|
||||
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
|
||||
<td>${escapeHtml(t.username || '—')}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-tenant" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// === Тест подключения ===
|
||||
btnTest.addEventListener('click', async () => {
|
||||
hideAlert('add-tenant-alert');
|
||||
const url = document.getElementById('tenant-url').value.trim();
|
||||
const username = document.getElementById('tenant-username').value.trim();
|
||||
const password = document.getElementById('tenant-password').value;
|
||||
|
||||
if (!url) {
|
||||
showAlert('add-tenant-alert', 'Введите JDBC URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
btnTest.textContent = '...';
|
||||
btnTest.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await api.post('/api/database/test', { url, username, password });
|
||||
if (result.success) {
|
||||
showAlert('add-tenant-alert', '✓ Подключение успешно!', 'success');
|
||||
} else {
|
||||
showAlert('add-tenant-alert', `✗ ${result.message}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('add-tenant-alert', 'Не удалось проверить подключение', 'error');
|
||||
} finally {
|
||||
btnTest.textContent = 'Тест';
|
||||
btnTest.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// === Добавление тенанта ===
|
||||
addTenantForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('add-tenant-alert');
|
||||
|
||||
const name = document.getElementById('tenant-name').value.trim();
|
||||
const domain = document.getElementById('tenant-domain').value.trim().toLowerCase();
|
||||
const url = document.getElementById('tenant-url').value.trim();
|
||||
const username = document.getElementById('tenant-username').value.trim();
|
||||
const password = document.getElementById('tenant-password').value;
|
||||
|
||||
if (!name || !domain || !url) {
|
||||
showAlert('add-tenant-alert', 'Заполните все обязательные поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
|
||||
if (result.success) {
|
||||
showAlert('add-tenant-alert', `Тенант «${domain}» добавлен`, 'success');
|
||||
addTenantForm.reset();
|
||||
loadTenants();
|
||||
loadStatus();
|
||||
} else {
|
||||
showAlert('add-tenant-alert', result.message, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('add-tenant-alert', 'Не удалось добавить тенант', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// === Удаление тенанта ===
|
||||
tenantsTbody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-delete-tenant');
|
||||
if (!btn) return;
|
||||
|
||||
const domain = btn.dataset.domain;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление тенанта',
|
||||
message: `Удалить тенант «${domain}»? Пул соединений будет закрыт.`,
|
||||
confirmLabel: 'Удалить тенант',
|
||||
danger: true
|
||||
})) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/database/tenants/${domain}`);
|
||||
loadTenants();
|
||||
loadStatus();
|
||||
} catch (e) {
|
||||
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить тенант' });
|
||||
}
|
||||
});
|
||||
|
||||
// === Init ===
|
||||
loadStatus();
|
||||
loadTenants();
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
|
||||
import { alertAction, confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
export let allEducationForms = [];
|
||||
|
||||
export async function fetchEducationForms() {
|
||||
try {
|
||||
allEducationForms = await api.get('/api/education-forms');
|
||||
return allEducationForms;
|
||||
} catch (e) {
|
||||
console.error('Не удалось загрузить формы обучения');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEduForms() {
|
||||
const efTbody = document.getElementById('ef-tbody');
|
||||
const createEfForm = document.getElementById('create-ef-form');
|
||||
|
||||
async function loadEF() {
|
||||
try {
|
||||
const forms = await fetchEducationForms();
|
||||
renderEfTable(forms);
|
||||
} catch (e) {
|
||||
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderEfTable(forms) {
|
||||
if (!forms || !forms.length) {
|
||||
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
|
||||
return;
|
||||
}
|
||||
efTbody.innerHTML = forms.map(ef => `
|
||||
<tr>
|
||||
<td>${ef.id}</td>
|
||||
<td>${escapeHtml(ef.name)}</td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-edu-form" data-id="${ef.id}">Удалить</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
createEfForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-ef-alert');
|
||||
const name = document.getElementById('new-ef-name').value.trim();
|
||||
if (!name) { showAlert('create-ef-alert', 'Введите название', 'error'); return; }
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/education-forms', { name });
|
||||
showAlert('create-ef-alert', `Форма "${escapeHtml(data.name)}" создана`, 'success');
|
||||
createEfForm.reset();
|
||||
loadEF();
|
||||
} catch (e) {
|
||||
showAlert('create-ef-alert', e.message || 'Ошибка создания', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
efTbody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-delete-edu-form');
|
||||
if (!btn) return;
|
||||
if (!await confirmAction({ title: 'Удаление формы обучения', message: 'Удалить форму обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/education-forms/' + btn.dataset.id);
|
||||
loadEF();
|
||||
} catch (e) {
|
||||
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить форму обучения' });
|
||||
}
|
||||
});
|
||||
|
||||
loadEF();
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
|
||||
import { confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
const APPLY_MODE_LABELS = {
|
||||
DEFAULT: 'применяется по умолчанию',
|
||||
WEEKDAY: 'применяется автоматически',
|
||||
MANUAL: 'применяется вручную'
|
||||
};
|
||||
|
||||
const DAY_SHORT_LABELS = {
|
||||
1: 'Пн',
|
||||
2: 'Вт',
|
||||
3: 'Ср',
|
||||
4: 'Чт',
|
||||
5: 'Пт',
|
||||
6: 'Сб',
|
||||
7: 'Вс'
|
||||
};
|
||||
|
||||
export async function initTimeSlotsSettings() {
|
||||
const scopeSelect = document.getElementById('time-slot-scope-select');
|
||||
const scopeSummary = document.getElementById('time-slot-scope-summary');
|
||||
const scopeOpenCreateButton = document.getElementById('time-slot-scope-open-create');
|
||||
const scopeModal = document.getElementById('time-slot-scope-modal');
|
||||
const scopeModalCloseButton = document.getElementById('time-slot-scope-modal-close');
|
||||
const scopeModalCancelButton = document.getElementById('time-slot-scope-modal-cancel');
|
||||
const scopeForm = document.getElementById('time-slot-scope-form');
|
||||
const scopeNameInput = document.getElementById('time-slot-scope-name');
|
||||
const scopeDeleteButton = document.getElementById('time-slot-scope-delete');
|
||||
|
||||
const form = document.getElementById('time-slot-form');
|
||||
const title = document.getElementById('time-slot-form-title');
|
||||
const idInput = document.getElementById('time-slot-id');
|
||||
const orderInput = document.getElementById('time-slot-order');
|
||||
const startInput = document.getElementById('time-slot-start');
|
||||
const endInput = document.getElementById('time-slot-end');
|
||||
const durationInput = document.getElementById('time-slot-duration');
|
||||
const resetButton = document.getElementById('time-slot-reset');
|
||||
const refreshButton = document.getElementById('time-slots-refresh');
|
||||
const tbody = document.getElementById('time-slots-tbody');
|
||||
|
||||
let scopes = [];
|
||||
let slots = [];
|
||||
let activeScopeId = null;
|
||||
|
||||
scopeSelect?.addEventListener('change', () => {
|
||||
activeScopeId = Number(scopeSelect.value);
|
||||
resetForm();
|
||||
renderActiveScope();
|
||||
});
|
||||
scopeOpenCreateButton?.addEventListener('click', openScopeModal);
|
||||
scopeModalCloseButton?.addEventListener('click', closeScopeModal);
|
||||
scopeModalCancelButton?.addEventListener('click', closeScopeModal);
|
||||
scopeModal?.addEventListener('click', (event) => {
|
||||
if (event.target === scopeModal) closeScopeModal();
|
||||
});
|
||||
scopeModal?.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeScopeModal();
|
||||
});
|
||||
scopeForm?.addEventListener('submit', createScope);
|
||||
scopeDeleteButton?.addEventListener('click', deleteActiveScope);
|
||||
form?.addEventListener('submit', saveSlot);
|
||||
resetButton?.addEventListener('click', resetForm);
|
||||
refreshButton?.addEventListener('click', loadAll);
|
||||
startInput?.addEventListener('change', calculateDuration);
|
||||
endInput?.addEventListener('change', calculateDuration);
|
||||
tbody?.addEventListener('click', handleSlotTableClick);
|
||||
|
||||
await loadAll();
|
||||
|
||||
async function loadAll() {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
|
||||
hideAlert('time-slot-alert');
|
||||
try {
|
||||
[scopes, slots] = await Promise.all([
|
||||
api.get('/api/admin/time-slots/scopes'),
|
||||
api.get('/api/admin/time-slots')
|
||||
]);
|
||||
if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) {
|
||||
activeScopeId = defaultScope()?.id || scopes[0]?.id || null;
|
||||
}
|
||||
render();
|
||||
} catch (error) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderScopeSelects();
|
||||
renderActiveScope();
|
||||
}
|
||||
|
||||
function renderActiveScope() {
|
||||
updateScopeActions();
|
||||
renderScopeSummary();
|
||||
renderSlots();
|
||||
}
|
||||
|
||||
function renderScopeSelects() {
|
||||
scopeSelect.innerHTML = scopes.map(scope => `
|
||||
<option value="${scope.id}">${escapeHtml(scopeOptionLabel(scope))}</option>
|
||||
`).join('');
|
||||
scopeSelect.value = activeScopeId ? String(activeScopeId) : '';
|
||||
|
||||
updateScopeActions();
|
||||
}
|
||||
|
||||
function updateScopeActions() {
|
||||
const activeScope = selectedScope();
|
||||
scopeDeleteButton.disabled = !activeScope || activeScope.systemScope;
|
||||
}
|
||||
|
||||
function renderScopeSummary() {
|
||||
const scope = selectedScope();
|
||||
if (!scope) {
|
||||
scopeSummary.innerHTML = '<span class="muted">Сетки времени не настроены</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode;
|
||||
const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : '';
|
||||
const slotCount = slots.filter(slot => slot.scopeId === scope.id).length;
|
||||
scopeSummary.innerHTML = `
|
||||
<span class="scope-badge ${scope.applyMode === 'DEFAULT' ? 'scope-badge-base' : ''}">${escapeHtml(scopeBadgeLabel(scope))}</span>
|
||||
<span>${escapeHtml(mode + day)}. Слотов: ${slotCount}.</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSlots() {
|
||||
const scopeSlots = slots
|
||||
.filter(slot => slot.scopeId === activeScopeId)
|
||||
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||
|
||||
if (!scopeSlots.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">В выбранной сетке нет слотов</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = scopeSlots.map(slot => `
|
||||
<tr>
|
||||
<td>${escapeHtml(String(slot.orderNumber ?? '-'))}</td>
|
||||
<td>${escapeHtml(trimTime(slot.startTime))}</td>
|
||||
<td>${escapeHtml(trimTime(slot.endTime))}</td>
|
||||
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn btn-sm btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openScopeModal() {
|
||||
scopeForm.reset();
|
||||
hideAlert('time-slot-scope-alert');
|
||||
scopeModal.classList.add('open');
|
||||
scopeModal.setAttribute('aria-hidden', 'false');
|
||||
window.setTimeout(() => scopeNameInput?.focus(), 0);
|
||||
}
|
||||
|
||||
function closeScopeModal() {
|
||||
scopeModal.classList.remove('open');
|
||||
scopeModal.setAttribute('aria-hidden', 'true');
|
||||
hideAlert('time-slot-scope-alert');
|
||||
}
|
||||
|
||||
async function createScope(event) {
|
||||
event.preventDefault();
|
||||
const name = scopeNameInput.value.trim();
|
||||
if (!name) {
|
||||
showAlert('time-slot-scope-alert', 'Введите название ручной сетки', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = await api.post('/api/admin/time-slots/scopes', { name });
|
||||
activeScopeId = saved.id;
|
||||
closeScopeModal();
|
||||
showAlert('time-slot-alert', 'Сетка времени создана', 'success');
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
showAlert('time-slot-scope-alert', error.message || 'Ошибка создания сетки времени', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteActiveScope() {
|
||||
const scope = selectedScope();
|
||||
if (!scope || scope.systemScope) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление сетки времени',
|
||||
message: `Удалить сетку «${scope.name}» вместе с её слотами и ручными назначениями?`,
|
||||
confirmLabel: 'Удалить сетку',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
|
||||
activeScopeId = defaultScope()?.id || null;
|
||||
resetForm();
|
||||
showAlert('time-slot-alert', 'Сетка времени удалена', 'success');
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
showAlert('time-slot-alert', error.message || 'Ошибка удаления сетки времени', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSlot(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('time-slot-alert');
|
||||
|
||||
const payload = {
|
||||
id: idInput.value ? Number(idInput.value) : null,
|
||||
orderNumber: Number(orderInput.value),
|
||||
scopeId: activeScopeId,
|
||||
startTime: startInput.value,
|
||||
endTime: endInput.value
|
||||
};
|
||||
|
||||
try {
|
||||
validateSlotPayload(payload);
|
||||
const id = idInput.value;
|
||||
await (id
|
||||
? api.put('/api/admin/time-slots/' + id, payload)
|
||||
: api.post('/api/admin/time-slots', payload));
|
||||
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
|
||||
resetForm();
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSlotTableClick(event) {
|
||||
const editButton = event.target.closest('.btn-edit-time-slot');
|
||||
const deleteButton = event.target.closest('.btn-delete-time-slot');
|
||||
|
||||
if (editButton) {
|
||||
const slot = slots.find(item => String(item.id) === String(editButton.dataset.id));
|
||||
if (!slot) return;
|
||||
activeScopeId = slot.scopeId;
|
||||
title.textContent = 'Редактирование слота';
|
||||
idInput.value = slot.id;
|
||||
orderInput.value = slot.orderNumber || '';
|
||||
startInput.value = trimTime(slot.startTime);
|
||||
endInput.value = trimTime(slot.endTime);
|
||||
durationInput.value = slot.durationMinutes || '';
|
||||
hideAlert('time-slot-alert');
|
||||
render();
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!await confirmAction({ title: 'Удаление временного слота', message: 'Удалить временной слот?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
|
||||
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
|
||||
resetForm();
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSlotPayload(payload) {
|
||||
if (!payload.scopeId) {
|
||||
throw new Error('Выберите сетку времени');
|
||||
}
|
||||
if (!payload.orderNumber || payload.orderNumber <= 0) {
|
||||
throw new Error('Укажите номер пары');
|
||||
}
|
||||
if (!payload.startTime || !payload.endTime) {
|
||||
throw new Error('Укажите время начала и окончания');
|
||||
}
|
||||
if (payload.startTime >= payload.endTime) {
|
||||
throw new Error('Время начала должно быть раньше времени окончания');
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDuration() {
|
||||
durationInput.value = '';
|
||||
if (!startInput.value || !endInput.value || startInput.value >= endInput.value) return;
|
||||
const [startHour, startMinute] = startInput.value.split(':').map(Number);
|
||||
const [endHour, endMinute] = endInput.value.split(':').map(Number);
|
||||
durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute));
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
idInput.value = '';
|
||||
title.textContent = 'Новый слот';
|
||||
hideAlert('time-slot-alert');
|
||||
}
|
||||
|
||||
function selectedScope() {
|
||||
return scopes.find(scope => scope.id === activeScopeId) || null;
|
||||
}
|
||||
|
||||
function defaultScope() {
|
||||
return scopes.find(scope => scope.applyMode === 'DEFAULT') || null;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeOptionLabel(scope) {
|
||||
const suffix = scope.applyMode === 'WEEKDAY'
|
||||
? `, ${dayShort(scope.dayOfWeek)}`
|
||||
: '';
|
||||
return `${scope.name} - ${APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode}${suffix}`;
|
||||
}
|
||||
|
||||
function scopeBadgeLabel(scope) {
|
||||
if (scope.applyMode === 'DEFAULT') return 'Базовая';
|
||||
if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek);
|
||||
return 'Ручная';
|
||||
}
|
||||
|
||||
function dayShort(dayOfWeek) {
|
||||
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return value ? String(value).slice(0, 5) : '';
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<!-- ===== Database / Tenants Tab ===== -->
|
||||
|
||||
<!-- Текущее подключение -->
|
||||
<div class="card">
|
||||
<h2>Текущее подключение</h2>
|
||||
<div id="db-status-info" class="db-status-card">
|
||||
<div class="loading-row">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица тенантов -->
|
||||
<div class="card">
|
||||
<h2>Подключённые университеты</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="tenants-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Домен</th>
|
||||
<th>JDBC URL</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tenants-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Добавить тенант -->
|
||||
<div class="card create-card">
|
||||
<h2>Добавить подключение</h2>
|
||||
<form id="add-tenant-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="tenant-name">Название университета</label>
|
||||
<input type="text" id="tenant-name" placeholder="ЮЗГУ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tenant-domain">Поддомен</label>
|
||||
<input type="text" id="tenant-domain" placeholder="swsu" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 0.75rem;">
|
||||
<div class="form-group" style="flex: 3;">
|
||||
<label for="tenant-url">JDBC URL</label>
|
||||
<input type="text" id="tenant-url" placeholder="jdbc:postgresql://192.168.1.50:5432/magistr_db" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 0.75rem;">
|
||||
<div class="form-group">
|
||||
<label for="tenant-username">Пользователь</label>
|
||||
<input type="text" id="tenant-username" placeholder="postgres" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tenant-password">Пароль</label>
|
||||
<input type="password" id="tenant-password" placeholder="••••••••" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
|
||||
Тест
|
||||
</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content;">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-alert" id="add-tenant-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,34 +0,0 @@
|
||||
<!-- ===== Education Forms Tab ===== -->
|
||||
<div class="card create-card">
|
||||
<h2>Новая форма обучения</h2>
|
||||
<form id="create-ef-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-ef-name">Название</label>
|
||||
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-ef-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Все формы обучения</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="ef-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ef-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
<div class="settings-placeholder">
|
||||
<div class="icon-wrap">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Общие настройки</h2>
|
||||
<p>Этот раздел находится в разработке. Здесь будут доступны общие настройки системы.</p>
|
||||
</div>
|
||||
@@ -1,134 +0,0 @@
|
||||
<section class="settings-section">
|
||||
<div class="settings-header-row">
|
||||
<div>
|
||||
<h2>Временные слоты</h2>
|
||||
<p>Настройка базовой, субботней и ручных сеток времени.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="time-slots-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<article class="settings-card">
|
||||
<div class="card-header-row">
|
||||
<h3>Сетка времени</h3>
|
||||
<div class="scope-header-actions">
|
||||
<button type="button" class="btn btn-md btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button>
|
||||
<button type="button" class="btn btn-md btn-danger-subtle" id="time-slot-scope-delete">Удалить сетку</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-form settings-form-single">
|
||||
<label class="form-field" for="time-slot-scope-select">
|
||||
<span>Область действия</span>
|
||||
<select id="time-slot-scope-select" class="native-select-control" data-native-select="true"></select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="scope-summary" id="time-slot-scope-summary"></div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card settings-card-compact">
|
||||
<h3>Как применяется</h3>
|
||||
<div class="scope-summary">
|
||||
<span class="scope-badge scope-badge-base">Базовая</span>
|
||||
<span>Используется по умолчанию для всех дат.</span>
|
||||
</div>
|
||||
<div class="scope-summary">
|
||||
<span class="scope-badge">Сб</span>
|
||||
<span>Субботняя сетка применяется автоматически по субботам.</span>
|
||||
</div>
|
||||
<div class="scope-summary">
|
||||
<span class="scope-badge">Ручная</span>
|
||||
<span>Ручные сетки назначаются в ячейке редактора календарного графика.</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="card-header-row">
|
||||
<h3 id="time-slot-form-title">Новый слот</h3>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="time-slot-reset">Очистить</button>
|
||||
</div>
|
||||
|
||||
<form id="time-slot-form" class="settings-form" novalidate>
|
||||
<input type="hidden" id="time-slot-id">
|
||||
|
||||
<label class="form-field" for="time-slot-order">
|
||||
<span>Пара</span>
|
||||
<input type="number" id="time-slot-order" min="1" step="1" required>
|
||||
</label>
|
||||
|
||||
<label class="form-field" for="time-slot-start">
|
||||
<span>Начало</span>
|
||||
<input type="time" id="time-slot-start" required>
|
||||
</label>
|
||||
|
||||
<label class="form-field" for="time-slot-end">
|
||||
<span>Окончание</span>
|
||||
<input type="time" id="time-slot-end" required>
|
||||
</label>
|
||||
|
||||
<label class="form-field" for="time-slot-duration">
|
||||
<span>Минут</span>
|
||||
<input type="number" id="time-slot-duration" min="1" step="1" readonly
|
||||
aria-describedby="time-slot-duration-hint">
|
||||
<small id="time-slot-duration-hint">Рассчитывается по времени начала и окончания</small>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить слот</button>
|
||||
<div class="form-alert" id="time-slot-alert" role="alert"></div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="table-wrap">
|
||||
<table class="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Пара</th>
|
||||
<th>Начало</th>
|
||||
<th>Окончание</th>
|
||||
<th>Минут</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="time-slots-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="settings-modal-overlay" id="time-slot-scope-modal" aria-hidden="true">
|
||||
<div class="settings-modal-card" role="dialog" aria-modal="true" aria-labelledby="time-slot-scope-modal-title">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
|
||||
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть окно">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="time-slot-scope-form" class="settings-modal-form" novalidate>
|
||||
<label class="form-field" for="time-slot-scope-name">
|
||||
<span>Название сетки</span>
|
||||
<input type="text" id="time-slot-scope-name" placeholder="Например: праздничная" required>
|
||||
</label>
|
||||
|
||||
<div class="settings-modal-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать сетку</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="time-slot-scope-modal-cancel">Отмена</button>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="time-slot-scope-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,316 +0,0 @@
|
||||
<div class="academic-calendar-tabs" role="tablist" aria-label="Разделы календарных учебных графиков">
|
||||
<button type="button" class="academic-calendar-tab active" data-calendar-tab="manage" role="tab" aria-selected="true" aria-controls="academic-calendar-tab-manage">Графики</button>
|
||||
<button type="button" class="academic-calendar-tab" data-calendar-tab="grid" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-grid">Сетки</button>
|
||||
<button type="button" class="academic-calendar-tab" data-calendar-tab="subjects" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-subjects">Дисциплины</button>
|
||||
</div>
|
||||
|
||||
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Учебные годы</h2>
|
||||
<button class="btn btn-md btn-secondary" id="academic-years-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Период</th>
|
||||
<th>Семестры</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="academic-years-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<div class="card-header-row">
|
||||
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-calendar-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-calendar-form" novalidate>
|
||||
<input type="hidden" id="academic-calendar-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-year">Учебный год</label>
|
||||
<select id="academic-calendar-year" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-specialty">Специальность</label>
|
||||
<select id="academic-calendar-specialty" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-profile">Профиль</label>
|
||||
<select id="academic-calendar-profile" required>
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-study-form">Форма обучения</label>
|
||||
<select id="academic-calendar-study-form" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-course-count">Курсов</label>
|
||||
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить график</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Календарные графики</h2>
|
||||
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="academic-calendars-search">Поиск графиков</label>
|
||||
<input type="search" id="academic-calendars-search" placeholder="Направление, профиль, форма или год">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendars-year-filter">Учебный год</label>
|
||||
<select id="academic-calendars-year-filter" data-native-select="true"><option value="">Все учебные годы</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Учебный год</th>
|
||||
<th>Специальность</th>
|
||||
<th>Профиль</th>
|
||||
<th>Форма</th>
|
||||
<th>Курсов</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="academic-calendars-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="academic-calendars-pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card academic-year-builder-card">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
|
||||
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-year-form" novalidate>
|
||||
<input type="hidden" id="academic-year-id">
|
||||
<div class="academic-year-period-grid">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-start">Год начала учебного года</label>
|
||||
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
|
||||
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-end">Год окончания учебного года</label>
|
||||
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
|
||||
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
|
||||
<div class="academic-year-semester-builder-head">
|
||||
<div>
|
||||
<h3 id="academic-year-semesters-title">Семестры</h3>
|
||||
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
|
||||
</div>
|
||||
<span class="academic-year-required-badge">Обязательно</span>
|
||||
</div>
|
||||
<div class="academic-year-semester-grid">
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
|
||||
<input type="hidden" id="academic-year-autumn-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
|
||||
<strong>Осенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-start">Начало</label>
|
||||
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-end">Окончание</label>
|
||||
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="spring">
|
||||
<input type="hidden" id="academic-year-spring-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
|
||||
<strong>Весенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-start">Начало</label>
|
||||
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-end">Окончание</label>
|
||||
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="academic-year-form-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-year-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>
|
||||
<div class="card create-card">
|
||||
<h2>Редактор годового графика</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-editor-calendar">График</label>
|
||||
<select id="calendar-editor-calendar" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-editor-load">Загрузить сетку</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="calendar-editor-save">Сохранить сетку</button>
|
||||
</div>
|
||||
<div class="calendar-fill-panel">
|
||||
<div class="form-group">
|
||||
<label for="calendar-fill-course">Курс</label>
|
||||
<select id="calendar-fill-course">
|
||||
<option value="">Все курсы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-fill-start">С даты</label>
|
||||
<input type="text" id="calendar-fill-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-fill-end">По дату</label>
|
||||
<input type="text" id="calendar-fill-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-fill-activity">Код</label>
|
||||
<select id="calendar-fill-activity">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-fill-apply">Заполнить диапазон</button>
|
||||
</div>
|
||||
<div class="form-alert" id="calendar-editor-alert" role="alert"></div>
|
||||
<div id="calendar-totals" class="calendar-totals"></div>
|
||||
<div id="calendar-grid" class="academic-calendar-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-subjects" data-calendar-tab-panel="subjects" role="tabpanel" hidden>
|
||||
<div class="card create-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Дисциплины графика</h2>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-calendar">График</label>
|
||||
<select id="calendar-subject-calendar" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-semester">Семестр</label>
|
||||
<select id="calendar-subject-semester">
|
||||
<option value="">Выберите график</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-id">Дисциплина</label>
|
||||
<select id="calendar-subject-id">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-add">Добавить</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="calendar-subject-save">Сохранить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="calendar-subject-alert" role="alert"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Привязки по семестрам</h2>
|
||||
<span id="calendar-subject-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="calendar-subject-summary" class="calendar-subject-summary">
|
||||
<div class="loading-row">Выберите календарный график</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-overlay" id="calendar-day-dialog">
|
||||
<div class="modal-content card calendar-day-dialog-card">
|
||||
<h2>Ячейка графика</h2>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="calendar-day-dialog-close" aria-label="Закрыть окно">×</button>
|
||||
<div class="calendar-day-dialog-grid">
|
||||
<div>
|
||||
<span>Дата</span>
|
||||
<strong id="calendar-day-dialog-date">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Курс</span>
|
||||
<strong id="calendar-day-dialog-course">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Неделя</span>
|
||||
<strong id="calendar-day-dialog-week">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>День</span>
|
||||
<strong id="calendar-day-dialog-day">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-day-dialog-activity" id="calendar-day-dialog-activity-summary"></div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-day-dialog-activity">Код активности</label>
|
||||
<select id="calendar-day-dialog-activity">
|
||||
<option value="">Выберите код</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calendar-day-dialog-time-grid">
|
||||
<div class="form-group">
|
||||
<label for="calendar-day-dialog-time-scope">Временная сетка даты</label>
|
||||
<select id="calendar-day-dialog-time-scope">
|
||||
<option value="">По правилу даты</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calendar-day-dialog-actions">
|
||||
<button type="button" class="btn btn-md btn-primary" id="calendar-day-dialog-time-save">Применить</button>
|
||||
<button type="button" class="btn btn-md btn-danger-subtle" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button>
|
||||
</div>
|
||||
<div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,89 +0,0 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row" style="margin-bottom: 1.5rem;">
|
||||
<h2>Загруженность</h2>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="workload-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
|
||||
<div class="form-group workload-target-control">
|
||||
<label for="workload-target-select">Что смотреть</label>
|
||||
<select id="workload-target-select" class="workload-display-select" data-native-select="true">
|
||||
<option value="classrooms">Аудитории</option>
|
||||
<option value="teachers">Преподаватели</option>
|
||||
<option value="departments">Кафедры</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group workload-display-control">
|
||||
<label for="workload-display-select">Отображение</label>
|
||||
<select id="workload-display-select" class="workload-display-select">
|
||||
<option value="all">Все аудитории</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group workload-overview-filter">
|
||||
<label>Корпус</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="building-box">
|
||||
<span class="select-text" id="building-text">Все корпуса</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="building-menu">
|
||||
<div id="building-checkboxes" class="checkbox-group-vertical"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group workload-overview-filter">
|
||||
<label for="workload-min-capacity">Мин. вместимость</label>
|
||||
<input type="number" id="workload-min-capacity" placeholder="0" min="0" style="width: 100px;">
|
||||
</div>
|
||||
|
||||
<div class="form-group workload-overview-filter">
|
||||
<label>Оборудование</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="equipment-box">
|
||||
<span class="select-text" id="equipment-text">Любое оборудование</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="equipment-menu">
|
||||
<div id="equipment-checkboxes" class="checkbox-group-vertical"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="max-width: 200px;">
|
||||
<label>Дата</label>
|
||||
<input type="date" id="workload-date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="workload-alert" role="alert"></div>
|
||||
|
||||
<div id="workload-overview-block">
|
||||
<div class="workload-summary" id="workload-summary"></div>
|
||||
|
||||
<div class="workload-grid-container">
|
||||
<table class="workload-table" id="workload-table">
|
||||
<thead>
|
||||
<tr id="workload-header-row">
|
||||
<th class="top-left-cell">
|
||||
<span class="top-label">Время</span>
|
||||
<span class="bottom-label">Сущность</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workload-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="room-workload-block" id="room-workload-block" hidden>
|
||||
<div class="room-workload-summary" id="room-workload-summary"></div>
|
||||
<div class="room-workload-tables" id="room-workload-tables"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,155 +0,0 @@
|
||||
<!-- ===== Classrooms Tab ===== -->
|
||||
<div class="card create-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h2 style="margin: 0;">Новая аудитория</h2>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="btn-manage-equipments">Справочник оборудования</button>
|
||||
</div>
|
||||
<form id="create-classroom-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-classroom-name">Номер / Название</label>
|
||||
<input type="text" id="new-classroom-name" placeholder="101 Ленинская" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-classroom-capacity">Вместимость (чел.)</label>
|
||||
<input type="number" id="new-classroom-capacity" placeholder="30" min="1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 1rem;">
|
||||
<div class="form-group" style="flex: 2;">
|
||||
<label>Оборудование</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="equipment-select-box">
|
||||
<span class="select-text" id="equipment-select-text">Выберите оборудование...</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="equipment-dropdown-menu">
|
||||
<div id="equipment-checkboxes" class="checkbox-group-vertical">
|
||||
<!-- Подгружается через JS -->
|
||||
<p class="text-muted"><small>Загрузка...</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="create-classroom-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Список аудиторий</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="classrooms-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Вместимость</th>
|
||||
<th>Оборудование</th>
|
||||
<th>Статус</th>
|
||||
<th style="text-align: right;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="classrooms-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Classroom Modal -->
|
||||
<div class="modal-overlay" id="modal-edit-classroom">
|
||||
<div class="modal-content card">
|
||||
<h2>Редактировать аудиторию</h2>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-classroom-close" aria-label="Закрыть окно">×</button>
|
||||
<form id="edit-classroom-form">
|
||||
<input type="hidden" id="edit-classroom-id">
|
||||
<div class="form-row" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="edit-classroom-name">Номер / Название</label>
|
||||
<input type="text" id="edit-classroom-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-classroom-capacity">Вместимость (чел.)</label>
|
||||
<input type="number" id="edit-classroom-capacity" min="1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 1rem;">
|
||||
<div class="form-group" style="flex: 2;">
|
||||
<label>Оборудование</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="edit-equipment-select-box">
|
||||
<span class="select-text" id="edit-equipment-select-text">Выберите оборудование...</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="edit-equipment-dropdown-menu">
|
||||
<div id="edit-equipment-checkboxes" class="checkbox-group-vertical">
|
||||
<!-- Подгружается через JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно управления Оборудованием -->
|
||||
<div class="modal-overlay" id="modal-manage-equipments">
|
||||
<div class="modal-content card" style="max-width: 600px; width: 90%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h2>Справочник оборудования</h2>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-equipments-close" aria-label="Закрыть окно">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Форма добавления -->
|
||||
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||
<h3 style="margin-top: 0;">Добавить оборудование</h3>
|
||||
<form id="manage-equipment-form">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-equipment-name">Название оборудования</label>
|
||||
<input type="text" id="manage-equipment-name" placeholder="Проектор" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-equipment-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Таблица оборудования -->
|
||||
<div class="table-wrap">
|
||||
<table id="manage-equipments-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-equipments-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,145 +0,0 @@
|
||||
<!-- ===== Dashboard Page ===== -->
|
||||
<div class="dashboard-container">
|
||||
<!-- Метрики (Информационные карточки) -->
|
||||
<div class="dashboard-metrics-grid">
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--groups">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Активные группы</div>
|
||||
<h3 id="metric-groups" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--students">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Всего студентов</div>
|
||||
<h3 id="metric-students" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--teachers">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Преподаватели</div>
|
||||
<h3 id="metric-teachers" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--classrooms">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Загрузка аудиторий сегодня</div>
|
||||
<h3 id="metric-classrooms" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Мониторинг в реальном времени и Быстрый переход -->
|
||||
<div class="dashboard-middle">
|
||||
<!-- Идет сейчас -->
|
||||
<div class="card dashboard-monitor-card">
|
||||
<div class="dashboard-card-header">
|
||||
<h2 style="margin: 0;">Мониторинг пар (Идет сейчас)</h2>
|
||||
<div id="current-time-slot" class="dashboard-time-slot">—</div>
|
||||
</div>
|
||||
<div class="table-wrap dashboard-monitor-table">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left; padding: 0.75rem;">Аудитория</th>
|
||||
<th style="text-align: left; padding: 0.75rem;">Дисциплина</th>
|
||||
<th style="text-align: left; padding: 0.75rem;">Преподаватель</th>
|
||||
<th style="text-align: left; padding: 0.75rem;">Группа</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="current-lessons-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row" style="padding: 2rem; text-align: center; color: var(--text-secondary);">Определение текущего времени...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Свободные аудитории и Быстрые действия -->
|
||||
<div class="dashboard-side-stack">
|
||||
<!-- Свободные аудитории -->
|
||||
<div class="card dashboard-compact-card">
|
||||
<h3 class="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
|
||||
<div id="free-classrooms-list" class="dashboard-badge-list" tabindex="0" role="region" aria-label="Свободные аудитории">
|
||||
<div style="color: var(--text-secondary); font-size: 0.9rem;">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Быстрые действия -->
|
||||
<div class="card dashboard-compact-card">
|
||||
<h3 class="dashboard-card-title">Быстрый переход</h3>
|
||||
<div class="dashboard-actions">
|
||||
<a href="#schedule" class="btn btn-md btn-primary dashboard-action-link">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
Конструктор расписания
|
||||
</a>
|
||||
<a href="#schedule-view" class="btn btn-md btn-secondary dashboard-action-link">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Точечные изменения / Просмотр
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Центр предупреждений и конфликтов (Red Zone) -->
|
||||
<div class="card red-zone-card" style="border-top: 4px solid var(--error, #ef4444);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<h2 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
Центр предупреждений (Red Zone)
|
||||
</h2>
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts" type="button">Перепроверить</button>
|
||||
</div>
|
||||
|
||||
<div id="conflicts-container" role="status" aria-live="polite" aria-atomic="true" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
|
||||
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,161 +0,0 @@
|
||||
<div class="card department-workspace-control-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Рабочая область кафедры</h2>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="department-workspace-filter-grid">
|
||||
<div class="form-group" id="department-workspace-department-field">
|
||||
<label for="department-workspace-department">Кафедра</label>
|
||||
<select id="department-workspace-department" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="department-workspace-start">Начало периода</label>
|
||||
<input type="date" id="department-workspace-start">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="department-workspace-end">Окончание периода</label>
|
||||
<input type="date" id="department-workspace-end">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="department-workspace-alert" role="alert"></div>
|
||||
</div>
|
||||
|
||||
<div class="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
|
||||
<div class="card create-card department-subject-import-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Загрузка дисциплин</h2>
|
||||
</div>
|
||||
<form id="department-subject-import-form" class="stack-form">
|
||||
<div class="subject-import-slots-panel">
|
||||
<div class="card-header-row">
|
||||
<label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="subject-import-add">Добавить строку</button>
|
||||
</div>
|
||||
<div id="subject-import-rows" class="subject-import-rows-list"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-secondary">Загрузить</button>
|
||||
<div class="form-alert" id="department-subject-import-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card create-card department-teacher-assign-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Добавить преподавателя</h2>
|
||||
</div>
|
||||
<form id="department-teacher-assign-form" class="stack-form">
|
||||
<div class="form-group">
|
||||
<label for="department-teacher-select">Преподаватель</label>
|
||||
<select id="department-teacher-select" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="department-teacher-assign-comment">Комментарий</label>
|
||||
<input type="text" id="department-teacher-assign-comment" placeholder="Например, ведет лабораторные работы">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-secondary">Привязать к кафедре</button>
|
||||
<div class="form-alert" id="department-teacher-assign-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card create-card department-teacher-request-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявка на преподавателя</h2>
|
||||
</div>
|
||||
<form id="department-teacher-request-form" class="stack-form">
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-username">Логин</label>
|
||||
<input type="text" id="teacher-request-username" placeholder="ivanov_teacher" maxlength="50" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-fullname">ФИО</label>
|
||||
<input type="text" id="teacher-request-fullname" placeholder="Иванов Иван Иванович" maxlength="255" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-jobtitle">Должность</label>
|
||||
<input type="text" id="teacher-request-jobtitle" placeholder="Доцент" maxlength="255">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-comment">Комментарий</label>
|
||||
<input type="text" id="teacher-request-comment" placeholder="Что важно проверить администратору">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Отправить заявку</button>
|
||||
<div class="form-alert" id="department-teacher-request-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card department-teachers-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Преподаватели и нагрузка</h2>
|
||||
</div>
|
||||
<div class="metric-grid" id="department-teachers-list">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card department-workspace-table-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявки кафедры</h2>
|
||||
<span class="muted" id="department-teacher-request-count">0 заявок</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Статус</th>
|
||||
<th>Логин</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Рассмотрение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="department-teacher-requests-tbody">
|
||||
<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card department-workspace-table-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Дисциплины кафедры</h2>
|
||||
<span class="muted" id="department-subject-count">0 дисциплин</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Код</th>
|
||||
<th>Название</th>
|
||||
<th>Описание</th>
|
||||
<th>Комментарии</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="department-subjects-tbody">
|
||||
<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card department-workspace-table-card" id="department-comments-card" hidden>
|
||||
<div class="card-header-row">
|
||||
<h2 id="department-comments-title">Комментарии</h2>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button>
|
||||
</div>
|
||||
|
||||
<form id="department-comment-form" class="form-row">
|
||||
<input type="hidden" id="department-comment-subject-id">
|
||||
<div class="form-group">
|
||||
<label for="department-comment-text">Новый комментарий</label>
|
||||
<input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</form>
|
||||
<div class="form-alert" id="department-comment-alert" role="alert"></div>
|
||||
<div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div>
|
||||
</div>
|
||||
@@ -1,282 +0,0 @@
|
||||
<!-- ===== Вкладка групп ===== -->
|
||||
<div class="card create-card">
|
||||
<h2>Новая группа</h2>
|
||||
<form id="create-group-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-group-name">Название группы</label>
|
||||
<input type="text" id="new-group-name" placeholder="ИВТ-21-1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-size">Численность группы</label>
|
||||
<input type="number" id="new-group-size" min="1" placeholder="20" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-ef">Форма обучения</label>
|
||||
<select id="new-group-ef">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-department">Кафедра</label>
|
||||
<select id="new-group-department">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-yearStartStudy">Год начала обучения</label>
|
||||
<input type="number" id="new-group-yearStartStudy" min="2000" max="2100" placeholder="2026" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-speciality-code">Специальность</label>
|
||||
<select id="new-group-speciality-code">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-profile">Профиль обучения</label>
|
||||
<select id="new-group-profile">
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-group-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Все группы</h2>
|
||||
<div class="filter-row">
|
||||
<label for="filter-ef-box">Фильтр:</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
|
||||
<span class="select-text" id="filter-ef-text">Выберите форму обучения</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="filter-ef-menu">
|
||||
<div id="filter-ef-checkboxes" class="checkbox-group-vertical">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="all" checked>
|
||||
<span class="checkmark"></span>
|
||||
<span>Все</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="groups-search">Поиск</label>
|
||||
<input type="search" id="groups-search" placeholder="Название, код специальности, профиль">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="groups-sort">Сортировка</label>
|
||||
<select id="groups-sort" data-native-select="true">
|
||||
<option value="name,asc">По названию А–Я</option>
|
||||
<option value="name,desc">По названию Я–А</option>
|
||||
<option value="yearStartStudy,desc">Сначала новые наборы</option>
|
||||
<option value="groupSize,desc">Сначала крупные</option>
|
||||
<option value="status,asc">По статусу</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="groups-filter-reset">Сбросить фильтры</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="groups-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Численность (чел.)</th>
|
||||
<th>Форма обучения</th>
|
||||
<th>Кафедра</th>
|
||||
<th>Курс</th>
|
||||
<th>Специальность</th>
|
||||
<th>Профиль</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-tbody">
|
||||
<tr>
|
||||
<td colspan="10" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="groups-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования группы -->
|
||||
<div class="modal-overlay" id="modal-edit-group">
|
||||
<div class="modal-content card edit-group-modal">
|
||||
<h2>Редактировать группу</h2>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-group-close" aria-label="Закрыть окно">×</button>
|
||||
<form id="edit-group-form">
|
||||
<input type="hidden" id="edit-group-id">
|
||||
<div class="form-row edit-group-form-row edit-group-form-row-main">
|
||||
<div class="form-group">
|
||||
<label for="edit-group-name">Название группы</label>
|
||||
<input type="text" id="edit-group-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-size">Численность группы</label>
|
||||
<input type="number" id="edit-group-size" min="1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-ef">Форма обучения</label>
|
||||
<select id="edit-group-ef">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row edit-group-form-row edit-group-form-row-details">
|
||||
<div class="form-group">
|
||||
<label for="edit-group-department">Кафедра</label>
|
||||
<select id="edit-group-department">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-yearStartStudy">Год начала обучения</label>
|
||||
<input type="number" id="edit-group-yearStartStudy" min="2000" max="2100" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-speciality-code">Специальность</label>
|
||||
<select id="edit-group-speciality-code">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-profile">Профиль обучения</label>
|
||||
<select id="edit-group-profile">
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group edit-group-submit">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-group-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно управления Подгруппами -->
|
||||
<div class="modal-overlay" id="modal-manage-subgroups">
|
||||
<div class="modal-content card" style="max-width: 700px; width: 90%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<h2>Подгруппы группы</h2>
|
||||
<div id="subgroups-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-subgroups-close" aria-label="Закрыть окно">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Форма -->
|
||||
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||
<h3 id="subgroup-form-title" style="margin-top: 0;">Создание подгруппы</h3>
|
||||
<form id="manage-subgroup-form">
|
||||
<input type="hidden" id="manage-subgroup-id">
|
||||
<input type="hidden" id="manage-subgroup-group-id">
|
||||
<div class="subgroup-config-grid">
|
||||
<div class="form-group subgroup-count-field">
|
||||
<label for="manage-subgroup-count">Количество подгрупп</label>
|
||||
<select id="manage-subgroup-count">
|
||||
<option value="0">Без разделения на подгруппы</option>
|
||||
<option value="2">Две подгруппы</option>
|
||||
<option value="3">Три подгруппы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
|
||||
<div class="subgroup-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Применить</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-subgroup-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Таблица -->
|
||||
<div class="table-wrap">
|
||||
<table id="manage-subgroups-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Подгруппа</th>
|
||||
<th>Численность</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-subgroups-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно назначения Календарного графика -->
|
||||
<div class="modal-overlay" id="modal-manage-calendar">
|
||||
<div class="modal-content card" style="max-width: 960px; width: 90%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<h2>Календарные графики группы</h2>
|
||||
<div id="calendar-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-calendar-close" aria-label="Закрыть окно">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Форма -->
|
||||
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||
<h3 style="margin-top: 0;">Назначить график</h3>
|
||||
<form id="manage-calendar-form">
|
||||
<input type="hidden" id="manage-calendar-group-id">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-calendar-year">Учебный год</label>
|
||||
<select id="manage-calendar-year" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 2; margin-bottom: 0;">
|
||||
<label for="manage-calendar-id">Календарный график</label>
|
||||
<select id="manage-calendar-id" data-native-select="true" required>
|
||||
<option value="">Выберите учебный год</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-calendar-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Таблица -->
|
||||
<div class="table-wrap">
|
||||
<table id="manage-calendar-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Учебный год</th>
|
||||
<th>График</th>
|
||||
<th>Дисциплины</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-calendar-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,102 +0,0 @@
|
||||
<section class="quality-lab" aria-labelledby="quality-lab-title">
|
||||
<header class="quality-hero">
|
||||
<div class="quality-hero-copy">
|
||||
<span class="quality-kicker">Диагностика · публикация или черновик</span>
|
||||
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
|
||||
<p>
|
||||
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
|
||||
пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы
|
||||
суммируются. Черновик можно оценить до публикации.
|
||||
</p>
|
||||
</div>
|
||||
<div class="quality-run-panel">
|
||||
<label for="quality-year">Учебный год</label>
|
||||
<select id="quality-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
<label for="quality-semester">Семестр</label>
|
||||
<select id="quality-semester" data-native-select="true">
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
<label for="quality-version">Версия</label>
|
||||
<select id="quality-version" data-native-select="true">
|
||||
<option value="">Сначала выберите семестр</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-md btn-primary" id="quality-run">
|
||||
Выполнить анализ
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="quality-status" id="quality-status" role="status" aria-live="polite">
|
||||
Выберите семестр, чтобы получить объяснимую оценку.
|
||||
</div>
|
||||
|
||||
<div class="quality-results" id="quality-results" hidden>
|
||||
<section class="quality-scoreboard" aria-label="Итог анализа">
|
||||
<div class="quality-score-orbit" id="quality-score-orbit">
|
||||
<svg class="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
|
||||
<circle class="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
|
||||
<circle class="quality-score-ring-value" id="quality-score-ring-value"
|
||||
cx="18" cy="18" r="15.9155" pathLength="100"></circle>
|
||||
</svg>
|
||||
<div class="quality-score-inner">
|
||||
<strong id="quality-score">—</strong>
|
||||
<span>из 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quality-score-copy">
|
||||
<span class="quality-score-band" id="quality-score-band">Нет данных</span>
|
||||
<h3 id="quality-semester-label">Семестр</h3>
|
||||
<p id="quality-score-summary"></p>
|
||||
<div class="quality-score-meta" id="quality-score-meta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quality-metrics" id="quality-metrics" aria-label="Метрики качества"></section>
|
||||
|
||||
<section class="quality-workbench">
|
||||
<div class="quality-problems-column">
|
||||
<div class="quality-section-heading">
|
||||
<div>
|
||||
<span class="quality-kicker">Карта правил</span>
|
||||
<h3>Правила, снижающие оценку</h3>
|
||||
</div>
|
||||
<span class="quality-problem-total" id="quality-problem-total"></span>
|
||||
</div>
|
||||
|
||||
<div class="quality-filters">
|
||||
<label>
|
||||
Важность
|
||||
<select id="quality-severity-filter" data-native-select="true">
|
||||
<option value="">Все уровни</option>
|
||||
<option value="CRITICAL">Критические</option>
|
||||
<option value="HIGH">Высокие</option>
|
||||
<option value="MEDIUM">Средние</option>
|
||||
<option value="LOW">Низкие</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Категория
|
||||
<select id="quality-type-filter" data-native-select="true">
|
||||
<option value="">Все категории</option>
|
||||
<option value="GAPS">Окна и компактность</option>
|
||||
<option value="LOAD">Дневная нагрузка</option>
|
||||
<option value="ROOM">Аудитории</option>
|
||||
<option value="PREFERENCE">Пожелания</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="quality-problems" id="quality-problems"></div>
|
||||
</div>
|
||||
|
||||
<aside class="quality-recommendations" id="quality-recommendations" aria-live="polite">
|
||||
<div class="quality-recommendations-empty">
|
||||
<span class="quality-recommendations-mark">↗</span>
|
||||
<h3>Работа с правилом</h3>
|
||||
<p>Выберите правило, чтобы увидеть суммарное влияние и открыть его в конструкторе.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,89 +0,0 @@
|
||||
<section class="version-studio" aria-labelledby="version-studio-title">
|
||||
<header class="version-hero">
|
||||
<div>
|
||||
<span class="version-eyebrow">Контур публикации</span>
|
||||
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
|
||||
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
|
||||
</div>
|
||||
<div class="version-hero-controls">
|
||||
<div class="version-hero-period-grid">
|
||||
<div>
|
||||
<label for="version-year">Учебный год</label>
|
||||
<select id="version-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="version-semester">Рабочий семестр</label>
|
||||
<select id="version-semester" data-native-select="true">
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-primary" id="version-create">Новый черновик</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="version-status" id="version-status" role="status" aria-live="polite">Загружаем контур версий…</div>
|
||||
|
||||
<section class="version-current" id="version-current" aria-label="Опубликованная версия"></section>
|
||||
|
||||
<div class="version-workbench">
|
||||
<section class="version-drafts-panel">
|
||||
<div class="version-section-head">
|
||||
<div>
|
||||
<span class="version-eyebrow">Подготовка</span>
|
||||
<h3>Черновики</h3>
|
||||
</div>
|
||||
<span id="version-draft-count">0</span>
|
||||
</div>
|
||||
<div class="version-draft-list" id="version-drafts"></div>
|
||||
</section>
|
||||
|
||||
<aside class="version-diff-panel" id="version-diff">
|
||||
<div class="version-empty">
|
||||
<span>Δ</span>
|
||||
<h3>Сравнение версий</h3>
|
||||
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="version-archive-panel">
|
||||
<div class="version-section-head">
|
||||
<div>
|
||||
<span class="version-eyebrow">Возврат без паники</span>
|
||||
<h3>Архив публикаций</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-archive-list" id="version-archive"></div>
|
||||
</section>
|
||||
|
||||
<section class="version-history-panel">
|
||||
<div class="version-section-head">
|
||||
<div>
|
||||
<span class="version-eyebrow">Неизменяемый след</span>
|
||||
<h3>Журнал действий</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-history" id="version-history"></div>
|
||||
</section>
|
||||
|
||||
<div class="version-modal" id="version-modal" hidden>
|
||||
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
|
||||
<button type="button" class="btn btn-icon-md btn-ghost version-modal-close" id="version-modal-close" aria-label="Закрыть окно">×</button>
|
||||
<span class="version-eyebrow" id="version-modal-kicker">Новая операция</span>
|
||||
<h3 id="version-modal-title">Версия расписания</h3>
|
||||
<p id="version-modal-copy"></p>
|
||||
<form id="version-modal-form">
|
||||
<label for="version-modal-value" id="version-modal-label">Название</label>
|
||||
<textarea id="version-modal-value" rows="3" maxlength="2000" required></textarea>
|
||||
<div class="version-modal-summary" id="version-modal-summary" hidden></div>
|
||||
<div class="version-modal-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="version-modal-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="version-modal-submit">Продолжить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,233 +0,0 @@
|
||||
<section class="schedule-view-shell">
|
||||
<div class="card schedule-view-control-card">
|
||||
<div class="schedule-view-control-header">
|
||||
<div>
|
||||
<h2>Расписания</h2>
|
||||
<span class="muted" id="schedule-view-period-label">Период не выбран</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="schedule-view-load">Показать</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-view-control-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-target">Что смотреть</label>
|
||||
<select id="schedule-view-target">
|
||||
<option value="groups">Группа</option>
|
||||
<option value="teachers">Преподаватель</option>
|
||||
<option value="classrooms">Аудитория</option>
|
||||
<option value="departments">Кафедра</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
|
||||
<label for="schedule-view-group">Группа</label>
|
||||
<select id="schedule-view-group" data-native-select="true">
|
||||
<option value="">Все группы</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
|
||||
<label for="schedule-view-teacher">Преподаватель</label>
|
||||
<select id="schedule-view-teacher" data-native-select="true">
|
||||
<option value="">Все преподаватели</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
|
||||
<label for="schedule-view-classroom">Аудитория</label>
|
||||
<select id="schedule-view-classroom" data-native-select="true">
|
||||
<option value="">Все аудитории</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
|
||||
<label for="schedule-view-department">Кафедра</label>
|
||||
<select id="schedule-view-department">
|
||||
<option value="">Все кафедры</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="date" id="schedule-view-date" hidden>
|
||||
</div>
|
||||
|
||||
<details class="schedule-view-extra-filters">
|
||||
<summary>Дополнительные фильтры</summary>
|
||||
<div class="schedule-view-extra-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-year">Учебный год</label>
|
||||
<select id="schedule-view-year">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-semester">Семестр</label>
|
||||
<select id="schedule-view-semester">
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-subject">Дисциплина</label>
|
||||
<select id="schedule-view-subject">
|
||||
<option value="">Все дисциплины</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-lesson-type">Тип занятия</label>
|
||||
<select id="schedule-view-lesson-type">
|
||||
<option value="">Все типы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-parity">Неделя</label>
|
||||
<select id="schedule-view-parity">
|
||||
<option value="">Любая</option>
|
||||
<option value="BOTH">Каждая</option>
|
||||
<option value="ODD">Нечётная</option>
|
||||
<option value="EVEN">Чётная</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
|
||||
</div>
|
||||
|
||||
<div class="card schedule-view-results-card">
|
||||
<div class="schedule-view-results-header">
|
||||
<div>
|
||||
<h2>Найденные расписания</h2>
|
||||
<span class="muted" id="schedule-view-count">0 занятий</span>
|
||||
</div>
|
||||
<div class="schedule-view-result-actions">
|
||||
<button type="button" class="btn btn-md btn-secondary" id="schedule-view-overrides-open" hidden>
|
||||
Изменения за период
|
||||
<span class="schedule-override-count" id="schedule-view-overrides-count">0</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="schedule-view-active-label">Нет выбранного расписания</span>
|
||||
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="card schedule-view-table-card">
|
||||
<div class="schedule-view-table-header">
|
||||
<div>
|
||||
<h2 id="schedule-view-table-title">Расписание</h2>
|
||||
<span class="muted" id="schedule-view-table-caption">Выберите параметры и нажмите «Показать»</span>
|
||||
</div>
|
||||
<div class="schedule-view-day-tabs" id="schedule-view-day-tabs" hidden></div>
|
||||
</div>
|
||||
<div class="schedule-view-table-stage" id="schedule-view-tables">
|
||||
<div class="loading-row schedule-view-state">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="schedule-override-drawer" id="schedule-override-drawer" hidden aria-hidden="true">
|
||||
<button type="button" class="schedule-override-backdrop" data-override-close aria-label="Закрыть панель"></button>
|
||||
<aside class="schedule-override-panel" role="dialog" aria-modal="true" aria-labelledby="schedule-override-title">
|
||||
<header class="schedule-override-header">
|
||||
<div>
|
||||
<span class="schedule-override-eyebrow">Точечное изменение</span>
|
||||
<h2 id="schedule-override-title">Конкретное занятие</h2>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-secondary" data-override-close aria-label="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav class="schedule-override-tabs" aria-label="Разовые изменения">
|
||||
<button type="button" class="schedule-override-tab active" data-override-tab="edit">Редактирование</button>
|
||||
<button type="button" class="schedule-override-tab" data-override-tab="registry">Изменения за период</button>
|
||||
</nav>
|
||||
|
||||
<div class="schedule-override-body">
|
||||
<section data-override-section="edit">
|
||||
<div class="schedule-override-empty" id="schedule-override-empty">
|
||||
Выберите занятие в расписании или откройте его из реестра изменений.
|
||||
</div>
|
||||
|
||||
<form id="schedule-override-form" hidden>
|
||||
<div class="schedule-override-comparison">
|
||||
<article>
|
||||
<span>Было по правилу</span>
|
||||
<strong id="schedule-override-before-title">—</strong>
|
||||
<p id="schedule-override-before-meta">—</p>
|
||||
</article>
|
||||
<article class="schedule-override-after">
|
||||
<span>Станет</span>
|
||||
<strong id="schedule-override-after-title">—</strong>
|
||||
<p id="schedule-override-after-meta">—</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<fieldset class="schedule-override-fieldset">
|
||||
<legend>Дата занятия</legend>
|
||||
<div class="schedule-override-week" id="schedule-override-week"></div>
|
||||
<button type="button" class="btn btn-md btn-secondary schedule-override-calendar-toggle" id="schedule-override-calendar-toggle">
|
||||
Выбрать другую дату
|
||||
</button>
|
||||
<div class="schedule-override-calendar" id="schedule-override-calendar" hidden></div>
|
||||
</fieldset>
|
||||
|
||||
<div class="schedule-override-fields">
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-time">Время</label>
|
||||
<select id="schedule-override-time" data-native-select="true" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-teacher">Преподаватель</label>
|
||||
<select id="schedule-override-teacher" data-native-select="true"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-classroom">Аудитория</label>
|
||||
<select id="schedule-override-classroom" data-native-select="true"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-format">Формат</label>
|
||||
<select id="schedule-override-format" data-native-select="true">
|
||||
<option value="Очно">Очно</option>
|
||||
<option value="Онлайн">Онлайн</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-override-comment">Комментарий</label>
|
||||
<textarea id="schedule-override-comment" rows="3" maxlength="1000" placeholder="Например, замена аудитории из-за мероприятия"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="schedule-override-alert" role="alert"></div>
|
||||
|
||||
<footer class="schedule-override-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-override-save">Сохранить</button>
|
||||
<button type="button" class="btn btn-md btn-danger-subtle" id="schedule-override-cancel">Отменить занятие</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="schedule-override-restore" hidden>Вернуть по правилу</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section data-override-section="registry" hidden>
|
||||
<div class="schedule-override-registry-heading">
|
||||
<div>
|
||||
<h3>Изменения за выбранный период</h3>
|
||||
<span class="muted" id="schedule-override-registry-period">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-override-registry" id="schedule-override-registry"></div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -1,313 +0,0 @@
|
||||
<div class="schedule-initial-state" id="schedule-initial-state" role="status" aria-live="polite">
|
||||
<span class="state-spinner" aria-hidden="true"></span>
|
||||
<span>Загружаем конструктор правил…</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-initial-content is-loading" id="schedule-initial-content" aria-busy="true">
|
||||
<section class="version-constructor-bar" id="schedule-version-context" data-status="PUBLISHED">
|
||||
<div>
|
||||
<span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
|
||||
<h2 id="schedule-version-title">Опубликованное расписание</h2>
|
||||
<p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
|
||||
</div>
|
||||
<div class="version-constructor-controls">
|
||||
<div class="version-constructor-field">
|
||||
<label for="schedule-version-year">Учебный год</label>
|
||||
<select id="schedule-version-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="version-constructor-field">
|
||||
<label for="schedule-version-semester">Семестр</label>
|
||||
<select id="schedule-version-semester" data-native-select="true">
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="version-constructor-field version-constructor-field-wide">
|
||||
<label for="schedule-version-select">Версия расписания</label>
|
||||
<select id="schedule-version-select" data-native-select="true">
|
||||
<option value="">Сначала выберите семестр</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="version-constructor-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="version-modal schedule-draft-modal" id="schedule-draft-modal" hidden>
|
||||
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="schedule-draft-title">
|
||||
<button type="button" class="btn btn-icon-md btn-ghost version-modal-close" id="schedule-draft-close" aria-label="Закрыть окно">×</button>
|
||||
<span class="version-eyebrow">Новая рабочая копия</span>
|
||||
<h3 id="schedule-draft-title">Создать черновик</h3>
|
||||
<p id="schedule-draft-copy">Все правила выбранного расписания будут скопированы.</p>
|
||||
<form id="schedule-draft-form">
|
||||
<label for="schedule-draft-name">Название черновика</label>
|
||||
<input id="schedule-draft-name" type="text" maxlength="160" required
|
||||
placeholder="Например, расписание после распределения аудиторий">
|
||||
<div class="version-modal-summary" id="schedule-draft-summary"></div>
|
||||
<div class="version-modal-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="schedule-draft-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-draft-submit">Создать и открыть</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card" id="schedule-rule-editor">
|
||||
<div class="card-header-row">
|
||||
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>
|
||||
</div>
|
||||
<form id="schedule-rule-form" novalidate>
|
||||
<input type="hidden" id="schedule-rule-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-subject">Дисциплина</label>
|
||||
<select id="schedule-rule-subject" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-semester">Семестр</label>
|
||||
<select id="schedule-rule-semester" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-hours-panel">
|
||||
<label class="schedule-section-label">Часы и недели начала</label>
|
||||
<div class="schedule-hours-grid">
|
||||
<div class="schedule-hours-row">
|
||||
<div class="schedule-hours-title">Лекции</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-lecture-hours">Часы</label>
|
||||
<input type="number" id="schedule-rule-lecture-hours" min="0" step="1" value="0" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-lecture-start-week">Неделя начала</label>
|
||||
<input type="number" id="schedule-rule-lecture-start-week" min="1" step="1" value="1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-hours-row">
|
||||
<div class="schedule-hours-title">Лабораторные</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-laboratory-hours">Часы</label>
|
||||
<input type="number" id="schedule-rule-laboratory-hours" min="0" step="1" value="0" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-laboratory-start-week">Неделя начала</label>
|
||||
<input type="number" id="schedule-rule-laboratory-start-week" min="1" step="1" value="1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-hours-row">
|
||||
<div class="schedule-hours-title">Практики</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-practice-hours">Часы</label>
|
||||
<input type="number" id="schedule-rule-practice-hours" min="0" step="1" value="0" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-practice-start-week">Неделя начала</label>
|
||||
<input type="number" id="schedule-rule-practice-start-week" min="1" step="1" value="1" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-groups-panel">
|
||||
<label class="schedule-section-label">Группы</label>
|
||||
<div id="schedule-rule-groups" class="custom-multi-select schedule-group-select">
|
||||
<div class="select-box" id="schedule-rule-groups-box" role="button" tabindex="0" aria-expanded="false">
|
||||
<span class="select-text" id="schedule-rule-groups-text">Выберите группы...</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="schedule-rule-groups-menu">
|
||||
<input type="search" id="schedule-rule-group-search" class="schedule-group-search"
|
||||
placeholder="Найти группу" aria-label="Поиск групп" autocomplete="off">
|
||||
<div id="schedule-rule-group-checkboxes" class="checkbox-group-vertical schedule-group-checkboxes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-slots-panel">
|
||||
<div class="card-header-row">
|
||||
<h2>Слоты правила</h2>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-slot-add">Добавить слот</button>
|
||||
</div>
|
||||
<div id="schedule-rule-slots" class="schedule-slots-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-row schedule-rule-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить правило</button>
|
||||
</div>
|
||||
<div class="form-alert" id="schedule-rule-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-content schedule-conflict-card">
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">×</button>
|
||||
<div class="schedule-conflict-head">
|
||||
<span class="schedule-conflict-kicker">Конфликт слота</span>
|
||||
<h2>Освободите место для нового правила</h2>
|
||||
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
|
||||
<div class="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden></div>
|
||||
</div>
|
||||
<div class="schedule-conflict-rules">
|
||||
<section class="schedule-conflict-rule-box">
|
||||
<span>Новое правило</span>
|
||||
<strong id="schedule-conflict-new-rule">-</strong>
|
||||
<small id="schedule-conflict-new-rule-meta">-</small>
|
||||
</section>
|
||||
<section class="schedule-conflict-rule-box schedule-conflict-rule-box-active">
|
||||
<span>Нужно изменить</span>
|
||||
<strong id="schedule-conflict-existing-rule">-</strong>
|
||||
<small id="schedule-conflict-existing-rule-meta">-</small>
|
||||
</section>
|
||||
</div>
|
||||
<form id="schedule-conflict-form" class="schedule-conflict-form" novalidate>
|
||||
<div class="schedule-conflict-slots" id="schedule-conflict-slots"></div>
|
||||
<div class="form-alert" id="schedule-conflict-alert" role="alert"></div>
|
||||
<div class="schedule-conflict-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="schedule-conflict-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-conflict-submit">Сохранить и повторить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" role="menu" hidden>
|
||||
<button type="button" role="menuitem" data-action="edit-rule">Открыть правило</button>
|
||||
<button type="button" role="menuitem" data-action="move-slot">Изменить день и пару</button>
|
||||
<button type="button" role="menuitem" data-action="delete-rule" class="danger">Удалить правило</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-content schedule-move-slot-card">
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">×</button>
|
||||
<div class="schedule-conflict-head">
|
||||
<span class="schedule-conflict-kicker">Перенос слота</span>
|
||||
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>
|
||||
<p id="schedule-move-slot-summary">Будут изменены только день недели и базовая пара выбранного слота.</p>
|
||||
</div>
|
||||
<form id="schedule-move-slot-form" class="schedule-move-slot-form" novalidate>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="schedule-move-slot-day">День</label>
|
||||
<select id="schedule-move-slot-day" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-move-slot-time">Пара</label>
|
||||
<select id="schedule-move-slot-time" required></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="schedule-move-slot-alert" role="alert"></div>
|
||||
<div class="schedule-conflict-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="schedule-move-slot-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-move-slot-submit">Сохранить перенос</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Правила динамического расписания</h2>
|
||||
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rules-search">Поиск правил</label>
|
||||
<input type="search" id="schedule-rules-search" placeholder="Дисциплина, группа или ID правила">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="schedule-table">
|
||||
<colgroup>
|
||||
<col class="schedule-rule-col-id">
|
||||
<col class="schedule-rule-col-subject">
|
||||
<col class="schedule-rule-col-semester">
|
||||
<col class="schedule-rule-col-groups">
|
||||
<col class="schedule-rule-col-load">
|
||||
<col class="schedule-rule-col-slots">
|
||||
<col class="schedule-rule-col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Дисциплина</th>
|
||||
<th>Семестр</th>
|
||||
<th>Группы</th>
|
||||
<th>Нагрузка</th>
|
||||
<th>Слоты</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="schedule-rules-pagination"></div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-icon-md btn-secondary schedule-visual-handle"
|
||||
id="schedule-visual-toggle"
|
||||
aria-label="Показать визуальное расписание"
|
||||
aria-expanded="false">
|
||||
<svg class="schedule-visual-handle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
|
||||
|
||||
<aside class="schedule-visual-drawer" id="schedule-visual-drawer" aria-hidden="true">
|
||||
<div class="schedule-visual-header">
|
||||
<div class="schedule-visual-header-main">
|
||||
<div>
|
||||
<h2>Визуальное расписание групп</h2>
|
||||
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-close">Свернуть</button>
|
||||
</div>
|
||||
<div class="schedule-visual-toolbar">
|
||||
<div class="schedule-visual-period-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-visual-year">Учебный год</label>
|
||||
<select id="schedule-visual-year">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-visual-semester">Семестр</label>
|
||||
<select id="schedule-visual-semester">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-visual-filter-head">
|
||||
<span>Группы</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-use-form">Из формы</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-select-all">Все</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter">
|
||||
<span class="muted">Группы появятся после загрузки правил</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-visual-stage" id="schedule-visual-stage">
|
||||
<div class="loading-row schedule-visual-state">Загрузка...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -1,116 +0,0 @@
|
||||
<!-- ===== Subjects Tab ===== -->
|
||||
<div class="card create-card">
|
||||
<h2>Новая дисциплина</h2>
|
||||
<form id="create-subject-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-subject-name">Название дисциплины</label>
|
||||
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-subject-code">Код предмета</label>
|
||||
<input type="text" id="new-subject-code" placeholder="Например: MATH101" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-subject-department">Кафедра</label>
|
||||
<select id="new-subject-department" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-subject-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Все дисциплины</h2>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="subjects-search">Поиск</label>
|
||||
<input type="search" id="subjects-search" placeholder="Название или код дисциплины">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subjects-department-filter">Кафедра</label>
|
||||
<select id="subjects-department-filter" data-native-select="true"><option value="">Все кафедры</option></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subjects-sort">Сортировка</label>
|
||||
<select id="subjects-sort" data-native-select="true">
|
||||
<option value="name,asc">По названию А–Я</option>
|
||||
<option value="name,desc">По названию Я–А</option>
|
||||
<option value="code,asc">По коду</option>
|
||||
<option value="departmentId,asc">По кафедре</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="subjects-alert" role="alert"></div>
|
||||
<div class="table-wrap">
|
||||
<table id="subjects-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Код предмета</th>
|
||||
<th>Кафедра</th>
|
||||
<th>Преподаватели</th>
|
||||
<th style="text-align: right;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subjects-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="subjects-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно привязки Преподавателей -->
|
||||
<div class="modal-overlay" id="modal-manage-teachers">
|
||||
<div class="modal-content card" style="max-width: 600px; width: 90%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<h2>Преподаватели дисциплины</h2>
|
||||
<div id="subject-teachers-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-teachers-close" aria-label="Закрыть окно">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Форма -->
|
||||
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||
<h3 style="margin-top: 0;">Добавить привязку</h3>
|
||||
<form id="manage-teacher-subject-form">
|
||||
<input type="hidden" id="manage-ts-subject-id">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-ts-teacher-select">Преподаватель</label>
|
||||
<select id="manage-ts-teacher-select" data-native-select="true" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-ts-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Таблица -->
|
||||
<div class="table-wrap">
|
||||
<table id="manage-ts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Преподаватель</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-ts-tbody">
|
||||
<tr>
|
||||
<td colspan="2" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,146 +0,0 @@
|
||||
<section class="absence-command-card">
|
||||
<div class="absence-command-copy">
|
||||
<span class="absence-kicker">Оперативный контур расписания</span>
|
||||
<h2>От регистрации инцидента до готовой замены</h2>
|
||||
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
|
||||
</div>
|
||||
<div class="absence-command-stats" aria-label="Сводка отсутствий">
|
||||
<article><strong id="absence-stat-active">0</strong><span>требуют внимания</span></article>
|
||||
<article><strong id="absence-stat-pending">0</strong><span>ждут подтверждения</span></article>
|
||||
<article><strong id="absence-stat-resolved">0</strong><span>разрешены</span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card absence-create-card">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<span class="absence-section-number">01</span>
|
||||
<h2>Зарегистрировать отсутствие</h2>
|
||||
</div>
|
||||
<span class="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
|
||||
</div>
|
||||
<form id="teacher-absence-form" novalidate>
|
||||
<div class="absence-form-grid">
|
||||
<div class="form-group absence-teacher-field">
|
||||
<label for="teacher-absence-teacher">Преподаватель</label>
|
||||
<select id="teacher-absence-teacher" data-native-select="true" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-absence-start">Начало</label>
|
||||
<input type="date" id="teacher-absence-start" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-absence-end">Окончание</label>
|
||||
<input type="date" id="teacher-absence-end" required>
|
||||
</div>
|
||||
<div class="form-group absence-reason-field">
|
||||
<label for="teacher-absence-reason">Причина</label>
|
||||
<input type="text" id="teacher-absence-reason" maxlength="2000"
|
||||
placeholder="Болезнь, командировка или другая причина" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary absence-create-submit">Зарегистрировать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="teacher-absence-form-alert" role="alert"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card absence-registry-card">
|
||||
<div class="card-header-row absence-registry-head">
|
||||
<div>
|
||||
<span class="absence-section-number">02</span>
|
||||
<h2>Реестр инцидентов</h2>
|
||||
</div>
|
||||
<div class="absence-registry-actions">
|
||||
<select id="teacher-absence-status-filter" aria-label="Фильтр по статусу">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="PENDING">На подтверждении</option>
|
||||
<option value="APPROVED">Согласованы</option>
|
||||
<option value="RESOLVED">Разрешены</option>
|
||||
<option value="REJECTED">Отклонены</option>
|
||||
<option value="CANCELLED">Отменены</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="teacher-absence-refresh">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absence-registry" id="teacher-absence-registry" aria-live="polite">
|
||||
<div class="loading-row">Загрузка...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="teacher-dialogue-grid">
|
||||
<article class="card teacher-dialogue-card">
|
||||
<header class="teacher-dialogue-head">
|
||||
<div>
|
||||
<span class="absence-section-number">03</span>
|
||||
<h2>Пожелания на семестр</h2>
|
||||
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
|
||||
</div>
|
||||
<div class="teacher-dialogue-counter"><strong id="teacher-preference-pending-count">0</strong><span>ожидают</span></div>
|
||||
</header>
|
||||
<div class="teacher-dialogue-toolbar">
|
||||
<select id="teacher-preference-semester-filter" aria-label="Семестр пожеланий"></select>
|
||||
<select id="teacher-preference-status-filter" aria-label="Статус пожеланий">
|
||||
<option value="PENDING">На рассмотрении</option>
|
||||
<option value="APPROVED">Согласованные</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
<button class="btn btn-md btn-secondary" id="teacher-preference-refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
<div class="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite"></div>
|
||||
</article>
|
||||
|
||||
<article class="card teacher-dialogue-card change-review-card">
|
||||
<header class="teacher-dialogue-head">
|
||||
<div>
|
||||
<span class="absence-section-number">04</span>
|
||||
<h2>Заявки на изменение</h2>
|
||||
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
|
||||
</div>
|
||||
<div class="teacher-dialogue-counter"><strong id="teacher-change-pending-count">0</strong><span>в очереди</span></div>
|
||||
</header>
|
||||
<div class="teacher-dialogue-toolbar">
|
||||
<select id="teacher-change-status-filter" aria-label="Статус заявок">
|
||||
<option value="PENDING">Ожидают решения</option>
|
||||
<option value="APPROVED">Одобренные</option>
|
||||
<option value="REJECTED">Отклонённые</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
<button class="btn btn-md btn-secondary" id="teacher-change-refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
<div class="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="absence-wizard-overlay" id="absence-wizard-overlay" hidden>
|
||||
<section class="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
|
||||
<header class="absence-wizard-header">
|
||||
<div>
|
||||
<span class="absence-kicker">Мастер замены</span>
|
||||
<h2 id="absence-wizard-title">Затронутые занятия</h2>
|
||||
<p id="absence-wizard-summary">Загрузка...</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost" id="absence-wizard-close" aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
<div class="absence-wizard-progress" aria-hidden="true">
|
||||
<span class="done">Инцидент</span><i></i><span class="done">Занятия</span><i></i><span>Решения</span>
|
||||
</div>
|
||||
<div class="form-alert" id="absence-wizard-alert" role="alert"></div>
|
||||
<div class="absence-wizard-lessons" id="absence-wizard-lessons"></div>
|
||||
<section class="absence-journal">
|
||||
<div class="card-header-row">
|
||||
<h3>Журнал решений</h3>
|
||||
<span class="muted" id="absence-journal-count">0 записей</span>
|
||||
</div>
|
||||
<div id="absence-journal-list" class="absence-journal-list"></div>
|
||||
</section>
|
||||
<footer class="absence-wizard-footer">
|
||||
<span class="muted">Пустые строки останутся без изменений</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="absence-wizard-cancel">Закрыть</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="absence-wizard-apply">Применить выбранные</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,44 +0,0 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявки на преподавателей</h2>
|
||||
<div class="teacher-request-toolbar">
|
||||
<span class="muted" id="teacher-requests-count">0 заявок</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="teacher-requests-search">Поиск</label>
|
||||
<input type="search" id="teacher-requests-search" placeholder="ФИО, логин, должность или кафедра">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-requests-status">Статус</label>
|
||||
<select id="teacher-requests-status" data-native-select="true">
|
||||
<option value="PENDING">Ожидают рассмотрения</option>
|
||||
<option value="APPROVED">Одобрены</option>
|
||||
<option value="REJECTED">Отклонены</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Кафедра</th>
|
||||
<th>Логин</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Пароль</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="teacher-requests-tbody">
|
||||
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="teacher-requests-pagination"></div>
|
||||
</div>
|
||||
@@ -1,268 +0,0 @@
|
||||
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
|
||||
<div class="tab-container">
|
||||
<div class="university-structure-tabs" role="tablist" aria-label="Разделы структуры вуза">
|
||||
<button type="button" class="university-structure-tab active" id="btn-tab-departments" role="tab" aria-selected="true" aria-controls="tab-content-departments">Кафедры</button>
|
||||
<button type="button" class="university-structure-tab" id="btn-tab-specialties" role="tab" aria-selected="false" aria-controls="tab-content-specialties">Специальности</button>
|
||||
<button type="button" class="university-structure-tab" id="btn-tab-profiles" role="tab" aria-selected="false" aria-controls="tab-content-profiles">Профили</button>
|
||||
</div>
|
||||
|
||||
<!-- Раздел Кафедр -->
|
||||
<div id="tab-content-departments" class="university-structure-tab-panel active" role="tabpanel" aria-labelledby="btn-tab-departments">
|
||||
<div class="card create-card">
|
||||
<h2>Создание кафедры</h2>
|
||||
<form id="create-department-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="dept-name">Название кафедры</label>
|
||||
<input type="text" id="dept-name" placeholder="Например: Кафедра ИБ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dept-code">Код кафедры</label>
|
||||
<input type="number" id="dept-code" placeholder="Например: 1" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-dept-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Кафедры</h2>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="departments-search">Поиск по названию или коду кафедры</label>
|
||||
<input type="search" id="departments-search" placeholder="Введите название или код">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="departments-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Код</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="departments-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="departments-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Раздел Специальностей -->
|
||||
<div id="tab-content-specialties" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-specialties" hidden>
|
||||
<div class="card create-card">
|
||||
<h2>Создание специальности</h2>
|
||||
<form id="create-specialty-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="spec-name">Название специальности</label>
|
||||
<input type="text" id="spec-name" placeholder="Например: Программная инженерия" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="spec-code">Код специальности</label>
|
||||
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-spec-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Специальности</h2>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="specialties-search">Поиск по названию или коду специальности</label>
|
||||
<input type="search" id="specialties-search" placeholder="Введите название или код">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="specialties-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Код специальности</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="specialties-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="specialties-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Раздел Профилей -->
|
||||
<div id="tab-content-profiles" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-profiles" hidden>
|
||||
<div class="card create-card">
|
||||
<h2 id="main-profile-form-title">Создание профиля</h2>
|
||||
<form id="create-profile-tab-form">
|
||||
<input type="hidden" id="tab-profile-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex: 1; min-width: 200px;">
|
||||
<label for="tab-profile-spec">Специальность</label>
|
||||
<select id="tab-profile-spec" required>
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1.5; min-width: 250px;">
|
||||
<label for="tab-profile-name">Название профиля</label>
|
||||
<input type="text" id="tab-profile-name" placeholder="Например: Искусственный интеллект" required>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 2; min-width: 250px;">
|
||||
<label for="tab-profile-description">Описание</label>
|
||||
<input type="text" id="tab-profile-description" placeholder="Необязательно">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-tab-save-profile">Создать</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="tab-profile-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Профили обучения</h2>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profiles-search">Поиск по профилю, специальности или описанию</label>
|
||||
<input type="search" id="profiles-search" placeholder="Введите название или код">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="profiles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Специальность</th>
|
||||
<th>Название профиля</th>
|
||||
<th>Описание</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="profiles-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="profiles-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования кафедры -->
|
||||
<div class="modal-overlay" id="modal-edit-department">
|
||||
<div class="modal-content card">
|
||||
<h2>Редактирование кафедры</h2>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-department-close" aria-label="Закрыть окно">×</button>
|
||||
<form id="edit-department-form">
|
||||
<input type="hidden" id="edit-dept-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-dept-name">Название кафедры</label>
|
||||
<input type="text" id="edit-dept-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-dept-code">Код кафедры</label>
|
||||
<input type="number" id="edit-dept-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-dept-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования специальности -->
|
||||
<div class="modal-overlay" id="modal-edit-specialty">
|
||||
<div class="modal-content card">
|
||||
<h2>Редактирование специальности</h2>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-specialty-close" aria-label="Закрыть окно">×</button>
|
||||
<form id="edit-specialty-form">
|
||||
<input type="hidden" id="edit-spec-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-spec-name">Название специальности</label>
|
||||
<input type="text" id="edit-spec-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-spec-code">Код специальности</label>
|
||||
<input type="text" id="edit-spec-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-spec-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно управления Профилями специальности -->
|
||||
<div class="modal-overlay" id="modal-manage-profiles">
|
||||
<div class="modal-content card" style="max-width: 800px; width: 90%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<h2>Профили обучения</h2>
|
||||
<div id="profiles-spec-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-profiles-close" aria-label="Закрыть окно">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Форма создания/редактирования профиля -->
|
||||
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||
<h3 id="profile-form-title">Создание профиля</h3>
|
||||
<form id="manage-profile-form">
|
||||
<input type="hidden" id="manage-profile-id">
|
||||
<input type="hidden" id="manage-profile-spec-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="manage-profile-name">Название профиля</label>
|
||||
<input type="text" id="manage-profile-name" placeholder="Например: Искусственный интеллект" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="manage-profile-description">Описание</label>
|
||||
<input type="text" id="manage-profile-description" placeholder="Необязательно">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-save-profile">Создать</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-profile-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Таблица профилей -->
|
||||
<div class="table-wrap">
|
||||
<table id="manage-profiles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Описание</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-profiles-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,95 +0,0 @@
|
||||
<!-- ===== Users Tab ===== -->
|
||||
<div class="card create-card">
|
||||
<h2>Новый пользователь</h2>
|
||||
<form id="create-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-username">Имя пользователя</label>
|
||||
<input type="text" id="new-username" placeholder="Имя пользователя" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">Пароль</label>
|
||||
<input type="password" id="new-password" placeholder="Минимум 8 символов" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-role">Роль</label>
|
||||
<select id="new-role">
|
||||
<option value="STUDENT">Студент</option>
|
||||
<option value="TEACHER">Преподаватель</option>
|
||||
<option value="DEPARTMENT">Кафедра</option>
|
||||
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
||||
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
||||
<option value="ADMIN">Администратор</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-fullname">ФИО пользователя</label>
|
||||
<input type="text" id="new-fullname" placeholder="Иванов Иван Иванович" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-jobtitle">Должность</label>
|
||||
<input type="text" id="new-jobtitle" placeholder="Студент / Доцент" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-department">ID Кафедры</label>
|
||||
<input type="number" id="new-department" placeholder="ID" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Все пользователи</h2>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="users-search">Поиск</label>
|
||||
<input type="search" id="users-search" placeholder="Логин, ФИО или должность">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="users-role-filter">Роль</label>
|
||||
<select id="users-role-filter" data-native-select="true">
|
||||
<option value="">Все роли</option>
|
||||
<option value="ADMIN">Администратор</option>
|
||||
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
||||
<option value="DEPARTMENT">Кафедра</option>
|
||||
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
||||
<option value="TEACHER">Преподаватель</option>
|
||||
<option value="STUDENT">Студент</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="users-sort">Сортировка</label>
|
||||
<select id="users-sort" data-native-select="true">
|
||||
<option value="fullName,asc">По ФИО А–Я</option>
|
||||
<option value="fullName,desc">По ФИО Я–А</option>
|
||||
<option value="username,asc">По логину</option>
|
||||
<option value="role,asc">По роли</option>
|
||||
<option value="status,asc">По статусу</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="users-alert" role="alert"></div>
|
||||
<div class="table-wrap">
|
||||
<table id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Кафедра</th>
|
||||
<th>Роль</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="users-pagination"></div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user