исправил просмотр расписаний

This commit is contained in:
Zuev
2026-05-20 15:02:43 +03:00
parent b1b85c3d17
commit f49fa95d5a
5 changed files with 887 additions and 156 deletions

View File

@@ -9,7 +9,7 @@
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | | **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в совмещённых таблицах чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям. | | **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |

View File

@@ -137,7 +137,7 @@ frontend/
| `classrooms` | Аудитории | `/api/classrooms` | | `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` | | `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон и результат разбивается на отдельные совмещённые таблицы чётной/нечётной недели по группам, преподавателям или аудиториям | `/api/schedule/search` | | `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
@@ -147,7 +147,7 @@ frontend/
### Особенности админских вкладок ### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
- Вкладка `schedule-view` показывает найденные занятия только в табличной матрице. Пользователь выбирает одну дату в периоде; frontend сам запрашивает двухнедельный диапазон от понедельника выбранной недели, чтобы обе половины совмещённой таблицы были заполнены одним периодом. Чтобы ячейка вроде «понедельник, 1 пара» не смешивала занятия разных групп и аудиторий, результат разбивается на отдельные таблицы. Поле «Таблицы по» поддерживает авто-режим, группы, преподавателей и аудитории; авто-режим выбирает преподавателей при фильтре преподавателя, аудитории при фильтре аудитории, иначе группы. Каждая таблица строится двухнедельными блоками: строки пары, столбцы — дни недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.

View File

@@ -569,19 +569,205 @@
background: var(--bg-input); background: var(--bg-input);
} }
.schedule-view-table-stack { .schedule-view-shell {
display: flex; min-height: calc(100vh - 8.5rem);
flex-direction: column; display: grid;
grid-template-rows: auto auto minmax(420px, 1fr);
gap: 1rem; gap: 1rem;
} }
.schedule-view-control-card,
.schedule-view-results-card,
.schedule-view-table-card {
animation-duration: 0.28s;
}
.schedule-view-control-header,
.schedule-view-results-header,
.schedule-view-table-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.schedule-view-control-header h2,
.schedule-view-results-header h2,
.schedule-view-table-header h2 {
margin-bottom: 0.3rem;
}
.schedule-view-control-grid,
.schedule-view-extra-grid {
display: grid;
gap: 1rem;
align-items: end;
}
.schedule-view-control-grid {
grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr) minmax(210px, 0.9fr);
margin-top: 1rem;
}
.schedule-view-extra-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
margin-top: 0.9rem;
}
.schedule-view-period-control {
min-width: 0;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
.schedule-view-period-buttons,
.schedule-view-result-actions,
.schedule-view-day-tabs {
display: flex;
align-items: center;
gap: 0.45rem;
}
.schedule-view-shell .btn-secondary {
min-height: 42px;
padding: 0.65rem 0.9rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.schedule-view-shell .btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--accent-hover);
transform: translateY(-1px);
}
.schedule-view-shell .btn-secondary:disabled {
cursor: default;
opacity: 0.45;
}
.schedule-view-extra-filters {
margin-top: 0.9rem;
border-top: 1px solid var(--bg-card-border);
padding-top: 0.85rem;
}
.schedule-view-extra-filters summary {
width: fit-content;
color: var(--text-secondary);
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
.schedule-view-result-actions {
min-height: 42px;
justify-content: flex-end;
color: var(--text-secondary);
font-size: 0.88rem;
text-align: right;
}
.schedule-view-result-tabs {
display: flex;
gap: 0.55rem;
margin-top: 1rem;
padding-bottom: 0.1rem;
overflow-x: auto;
}
.schedule-view-result-tab {
flex: 0 0 auto;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.18rem;
min-width: 150px;
max-width: 240px;
padding: 0.65rem 0.85rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), transform var(--transition);
}
.schedule-view-result-tab span,
.schedule-view-result-tab small {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-view-result-tab small {
color: var(--text-secondary);
font-size: 0.72rem;
}
.schedule-view-result-tab:hover,
.schedule-view-result-tab.active {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-input));
border-color: var(--accent);
transform: translateY(-1px);
}
.schedule-view-empty-result {
display: inline-flex;
align-items: center;
min-height: 38px;
color: var(--text-secondary);
font-size: 0.88rem;
}
.schedule-view-table-card {
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.schedule-view-table-header {
flex: 0 0 auto;
margin-bottom: 1rem;
}
.schedule-view-table-stage {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.schedule-view-week-panel { .schedule-view-week-panel {
flex: 1 1 auto;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
} }
.schedule-view-grid-container { .schedule-view-grid-container {
flex: 1 1 auto;
min-height: 0;
height: 100%;
max-height: none; max-height: none;
} }
@@ -600,11 +786,80 @@
} }
.schedule-view-state { .schedule-view-state {
min-height: 100%;
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-input); background: var(--bg-input);
} }
.schedule-view-day-tabs {
flex-wrap: wrap;
justify-content: flex-end;
}
.schedule-view-day-tab {
min-width: 42px;
min-height: 34px;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-secondary);
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.schedule-view-day-tab.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, var(--bg-input));
color: var(--text-primary);
}
.schedule-view-mobile-day {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.schedule-view-mobile-day-heading,
.schedule-view-mobile-slot {
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-view-mobile-day-heading {
padding: 0.8rem 0.9rem;
}
.schedule-view-mobile-day-heading strong,
.schedule-view-mobile-day-heading span,
.schedule-view-mobile-slot-time strong,
.schedule-view-mobile-slot-time span {
display: block;
}
.schedule-view-mobile-day-heading span,
.schedule-view-mobile-slot-time span {
margin-top: 0.25rem;
color: var(--text-secondary);
font-size: 0.78rem;
}
.schedule-view-mobile-slot {
overflow: hidden;
}
.schedule-view-mobile-slot-time {
padding: 0.7rem 0.85rem;
border-bottom: 1px solid var(--bg-card-border);
}
.schedule-view-mobile-slot-content .room-week-unified {
min-height: 124px;
}
/* Настройка scrollbar для контейнера сетки */ /* Настройка scrollbar для контейнера сетки */
.workload-grid-container::-webkit-scrollbar { .workload-grid-container::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -621,6 +876,12 @@
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
} }
@media (max-width: 1180px) {
.schedule-view-control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.workload-target-control, .workload-target-control,
.workload-display-control { .workload-display-control {
@@ -638,4 +899,54 @@
.room-grid-container { .room-grid-container {
height: 520px; height: 520px;
} }
.schedule-view-shell {
min-height: auto;
grid-template-rows: auto;
}
.schedule-view-control-header,
.schedule-view-results-header,
.schedule-view-table-header {
display: grid;
grid-template-columns: 1fr;
}
.schedule-view-control-grid,
.schedule-view-extra-grid {
grid-template-columns: 1fr;
}
.schedule-view-period-buttons,
.schedule-view-result-actions,
.schedule-view-day-tabs {
width: 100%;
}
.schedule-view-period-buttons .btn-secondary,
.schedule-view-result-actions .btn-secondary {
flex: 1;
}
.schedule-view-result-actions {
justify-content: stretch;
text-align: center;
}
.schedule-view-result-actions span {
flex: 2;
align-self: center;
}
.schedule-view-result-tab {
min-width: 72%;
}
.schedule-view-table-card {
min-height: 520px;
}
.schedule-view-day-tabs {
justify-content: flex-start;
}
} }

View File

@@ -2,6 +2,10 @@ import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT'; const ROLE_DEPARTMENT = 'DEPARTMENT';
const TARGET_GROUPS = 'groups';
const TARGET_TEACHERS = 'teachers';
const TARGET_CLASSROOMS = 'classrooms';
const TARGET_DEPARTMENTS = 'departments';
const PARITY_LABELS = { const PARITY_LABELS = {
BOTH: 'каждая', BOTH: 'каждая',
ODD: 'нечётная', ODD: 'нечётная',
@@ -30,104 +34,418 @@ const CELL_PARITY_LAYOUT = [
{ parity: 'ODD', label: 'Нечётная' }, { parity: 'ODD', label: 'Нечётная' },
{ parity: 'EVEN', label: 'Чётная' } { parity: 'EVEN', label: 'Чётная' }
]; ];
const SPLIT_AUTO = 'auto'; const MOBILE_QUERY = '(max-width: 760px)';
const SPLIT_GROUPS = 'groups';
const SPLIT_TEACHERS = 'teachers';
const SPLIT_CLASSROOMS = 'classrooms';
export async function initScheduleView() { export async function initScheduleView() {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
const userDepartmentId = localStorage.getItem('departmentId'); const userDepartmentId = normalizeId(localStorage.getItem('departmentId'));
const dateInput = document.getElementById('schedule-view-date'); const dateInput = document.getElementById('schedule-view-date');
const targetSelect = document.getElementById('schedule-view-target');
const loadButton = document.getElementById('schedule-view-load'); const loadButton = document.getElementById('schedule-view-load');
const departmentField = document.getElementById('schedule-view-department-field'); const previousPeriodButton = document.getElementById('schedule-view-prev-period');
const departmentSelect = document.getElementById('schedule-view-department'); const todayButton = document.getElementById('schedule-view-today');
const tables = document.getElementById('schedule-view-tables'); const nextPeriodButton = document.getElementById('schedule-view-next-period');
const resultPreviousButton = document.getElementById('schedule-view-result-prev');
const resultNextButton = document.getElementById('schedule-view-result-next');
const resultTabs = document.getElementById('schedule-view-result-tabs');
const activeLabel = document.getElementById('schedule-view-active-label');
const countLabel = document.getElementById('schedule-view-count'); const countLabel = document.getElementById('schedule-view-count');
const periodLabel = document.getElementById('schedule-view-period-label');
const tableTitle = document.getElementById('schedule-view-table-title');
const tableCaption = document.getElementById('schedule-view-table-caption');
const dayTabs = document.getElementById('schedule-view-day-tabs');
const tables = document.getElementById('schedule-view-tables');
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
const departmentSelect = document.getElementById('schedule-view-department');
const state = {
dictionaries: {
groups: [],
teachers: [],
classrooms: [],
departments: []
},
sections: [],
activeSectionIndex: 0,
mobileDay: 1,
range: null,
totalLessons: 0
};
const mobileMedia = window.matchMedia(MOBILE_QUERY);
setDefaultDate(dateInput); setDefaultDate(dateInput);
configureDepartmentTarget();
updatePeriodLabel();
updateEntityFieldVisibility();
renderResultNavigation();
targetSelect?.addEventListener('change', () => {
clearInactiveEntityValues();
updateEntityFieldVisibility();
resetResults('Нажмите «Показать», чтобы обновить расписание');
});
loadButton?.addEventListener('click', loadData); loadButton?.addEventListener('click', loadData);
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
todayButton?.addEventListener('click', () => {
dateInput.value = toIsoDate(new Date());
loadData();
});
nextPeriodButton?.addEventListener('click', () => movePeriod(1));
dateInput?.addEventListener('change', () => {
updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание');
});
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1));
if (typeof mobileMedia.addEventListener === 'function') {
mobileMedia.addEventListener('change', renderActiveSection);
} else if (typeof mobileMedia.addListener === 'function') {
mobileMedia.addListener(renderActiveSection);
}
try { try {
await loadDictionaries(role, userDepartmentId); state.dictionaries = await loadDictionaries(role, userDepartmentId);
if (role === ROLE_DEPARTMENT) { configureDepartmentAccess();
departmentField.hidden = true; updateEntityFieldVisibility();
departmentSelect.value = userDepartmentId || '';
}
await loadData(); await loadData();
} catch (error) { } catch (error) {
setTableState(error.message || 'Ошибка загрузки справочников'); const message = error.message || 'Ошибка загрузки справочников';
showAlert('schedule-view-alert', message, 'error');
resetResults(message);
}
function configureDepartmentTarget() {
if (role !== ROLE_DEPARTMENT) return;
targetSelect.value = TARGET_DEPARTMENTS;
targetSelect.disabled = true;
}
function configureDepartmentAccess() {
if (role !== ROLE_DEPARTMENT) return;
targetSelect.value = TARGET_DEPARTMENTS;
targetSelect.disabled = true;
if (departmentSelect) {
departmentSelect.value = userDepartmentId || '';
departmentSelect.disabled = true;
if (userDepartmentId && departmentSelect.value !== userDepartmentId) {
departmentSelect.insertAdjacentHTML('afterbegin',
`<option value="${escapeHtml(userDepartmentId)}">Своя кафедра</option>`);
departmentSelect.value = userDepartmentId;
}
}
}
function updateEntityFieldVisibility() {
const target = activeTarget();
entityFields.forEach(field => {
field.hidden = field.dataset.scheduleViewTargetField !== target;
});
}
function clearInactiveEntityValues() {
const target = activeTarget();
[
[TARGET_GROUPS, 'schedule-view-group'],
[TARGET_TEACHERS, 'schedule-view-teacher'],
[TARGET_CLASSROOMS, 'schedule-view-classroom'],
[TARGET_DEPARTMENTS, 'schedule-view-department']
].forEach(([fieldTarget, id]) => {
if (fieldTarget === target) return;
if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return;
const select = document.getElementById(id);
if (select) select.value = '';
});
}
function activeTarget() {
if (role === ROLE_DEPARTMENT) return TARGET_DEPARTMENTS;
return targetSelect?.value || TARGET_GROUPS;
}
async function movePeriod(direction) {
if (!dateInput.value) {
dateInput.value = toIsoDate(new Date());
}
const range = twoWeekRange(dateInput.value);
const nextDate = addDays(parseIsoDate(range.startDate), direction * 14);
dateInput.value = toIsoDate(nextDate);
await loadData();
} }
async function loadData() { async function loadData() {
hideAlert('schedule-view-alert'); hideAlert('schedule-view-alert');
setTableState('Загрузка...');
countLabel.textContent = 'Загрузка...';
if (!dateInput.value) { if (!dateInput.value) {
const message = 'Выберите дату в периоде'; const message = 'Выберите дату в периоде';
showAlert('schedule-view-alert', message, 'error'); showAlert('schedule-view-alert', message, 'error');
setTableState(message); resetResults(message);
countLabel.textContent = '0 занятий';
return; return;
} }
if (role === ROLE_DEPARTMENT && !userDepartmentId) {
const message = 'Не удалось определить кафедру пользователя';
showAlert('schedule-view-alert', message, 'error');
resetResults(message);
return;
}
const target = activeTarget();
const range = twoWeekRange(dateInput.value); const range = twoWeekRange(dateInput.value);
const previousKey = state.sections[state.activeSectionIndex]?.key;
state.range = range;
updatePeriodLabel(range);
setTableState('Загрузка...');
setActiveSummary('Загрузка расписаний...');
countLabel.textContent = 'Загрузка...';
resultTabs.innerHTML = '';
dayTabs.hidden = true;
dayTabs.innerHTML = '';
const params = new URLSearchParams({ const params = new URLSearchParams({
startDate: range.startDate, startDate: range.startDate,
endDate: range.endDate endDate: range.endDate
}); });
addOptional(params, 'groupId', valueOf('schedule-view-group')); applyTargetFilter(params, target);
addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
addOptional(params, 'classroomId', valueOf('schedule-view-classroom'));
addOptional(params, 'departmentId', role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value);
addOptional(params, 'subjectId', valueOf('schedule-view-subject')); addOptional(params, 'subjectId', valueOf('schedule-view-subject'));
addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type')); addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type'));
addOptional(params, 'parity', valueOf('schedule-view-parity')); addOptional(params, 'parity', valueOf('schedule-view-parity'));
try { try {
const lessons = await api.get(`/api/schedule/search?${params}`); const lessons = await api.get(`/api/schedule/search?${params}`);
renderLessons(lessons, range); renderLessons(lessons, range, target, previousKey);
} catch (error) { } catch (error) {
const message = error.message || 'Ошибка загрузки расписания'; const message = error.message || 'Ошибка загрузки расписания';
showAlert('schedule-view-alert', message, 'error'); showAlert('schedule-view-alert', message, 'error');
setTableState(message); resetResults(message);
countLabel.textContent = '0 занятий';
} }
} }
function renderLessons(lessons, range) { function applyTargetFilter(params, target) {
countLabel.textContent = lessonCountLabel(lessons.length); if (role === ROLE_DEPARTMENT) {
params.set('departmentId', userDepartmentId);
return;
}
if (target === TARGET_GROUPS) {
addOptional(params, 'groupId', valueOf('schedule-view-group'));
return;
}
if (target === TARGET_TEACHERS) {
addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
return;
}
if (target === TARGET_CLASSROOMS) {
addOptional(params, 'classroomId', valueOf('schedule-view-classroom'));
return;
}
if (target === TARGET_DEPARTMENTS) {
addOptional(params, 'departmentId', valueOf('schedule-view-department'));
}
}
function renderLessons(lessons, range, target, previousKey) {
state.totalLessons = lessons.length;
if (!lessons.length) { if (!lessons.length) {
state.sections = [];
state.activeSectionIndex = 0;
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = periodCaption(range);
setTableState('Занятия не найдены'); setTableState('Занятия не найдены');
countLabel.textContent = '0 занятий';
return; return;
} }
const sections = buildScheduleSections(lessons);
const sections = buildScheduleSections(lessons, splitTypeForTarget(target), buildSectionContext(target));
if (!sections.length) { if (!sections.length) {
setTableState('Нет данных для выбранного разреза таблиц'); state.sections = [];
state.activeSectionIndex = 0;
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = periodCaption(range);
setTableState('Нет данных для выбранного режима просмотра');
countLabel.textContent = lessonCountLabel(lessons.length);
return; return;
} }
const tableCount = sections.reduce((count, section) =>
count + buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons).length, 0 state.sections = sections;
); state.activeSectionIndex = Math.max(0, sections.findIndex(section => section.key === previousKey));
if (!tableCount) { if (state.activeSectionIndex < 0) {
setTableState('Выберите корректный период'); state.activeSectionIndex = 0;
return;
} }
countLabel.textContent = `${lessonCountLabel(lessons.length)}, ${tableCountLabel(tableCount)}`; state.mobileDay = preferredMobileDay(range);
tables.innerHTML = sections renderResultNavigation();
.map(section => renderScheduleSection(section, range)) renderActiveSection();
.join('');
} }
function renderScheduleSection(section, range) { function buildSectionContext(target) {
const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : '';
const selectedDepartmentId = role === ROLE_DEPARTMENT
? userDepartmentId
: (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : '');
const allowedGroupIds = new Set();
if (selectedGroupId) {
allowedGroupIds.add(selectedGroupId);
} else if (selectedDepartmentId) {
state.dictionaries.groups
.filter(group => String(group.departmentId) === String(selectedDepartmentId))
.forEach(group => allowedGroupIds.add(String(group.id)));
}
return {
selectedGroupId,
selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '',
allowedGroupIds,
groupNamesById: new Map(state.dictionaries.groups.map(group => [String(group.id), group.name]))
};
}
function splitTypeForTarget(target) {
if (target === TARGET_TEACHERS) return TARGET_TEACHERS;
if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS;
return TARGET_GROUPS;
}
function renderResultNavigation() {
const section = activeSection();
const totalSections = state.sections.length;
countLabel.textContent = state.totalLessons
? `${lessonCountLabel(state.totalLessons)}, ${sectionCountLabel(totalSections)}`
: '0 занятий';
resultPreviousButton.disabled = totalSections < 2;
resultNextButton.disabled = totalSections < 2;
if (!section) {
setActiveSummary('Нет выбранного расписания');
resultTabs.innerHTML = '<span class="schedule-view-empty-result">Ничего не найдено</span>';
return;
}
setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`);
resultTabs.innerHTML = state.sections.map((item, index) => `
<button type="button"
class="schedule-view-result-tab${index === state.activeSectionIndex ? ' active' : ''}"
data-schedule-view-section="${index}">
<span>${escapeHtml(item.title)}</span>
<small>${escapeHtml(lessonCountLabel(item.lessons.length))}</small>
</button>
`).join('');
resultTabs.querySelectorAll('[data-schedule-view-section]').forEach(button => {
button.addEventListener('click', () => {
state.activeSectionIndex = Number(button.dataset.scheduleViewSection);
state.mobileDay = preferredMobileDay(state.range);
renderResultNavigation();
renderActiveSection();
});
});
}
function setActiveSummary(text) {
activeLabel.textContent = text;
}
function moveResult(direction) {
if (state.sections.length < 2) return;
state.activeSectionIndex = (state.activeSectionIndex + direction + state.sections.length) % state.sections.length;
state.mobileDay = preferredMobileDay(state.range);
renderResultNavigation();
renderActiveSection();
}
function activeSection() {
return state.sections[state.activeSectionIndex] || null;
}
function renderActiveSection() {
const section = activeSection();
if (!section || !state.range) {
dayTabs.hidden = true;
dayTabs.innerHTML = '';
return;
}
tableTitle.textContent = section.title;
tableCaption.textContent = `${lessonCountLabel(section.lessons.length)} · ${periodCaption(state.range)}`;
if (mobileMedia.matches) {
renderMobileDayTabs();
renderMobileSchedule(section, state.range);
return;
}
dayTabs.hidden = true;
dayTabs.innerHTML = '';
renderDesktopSchedule(section, state.range);
}
function renderDesktopSchedule(section, range) {
const slots = collectSlots(section.lessons); const slots = collectSlots(section.lessons);
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
return blocks if (!blocks.length || !slots.length) {
setTableState('Занятия не найдены');
return;
}
tables.innerHTML = blocks
.map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section))
.join(''); .join('');
} }
function renderMobileDayTabs() {
dayTabs.hidden = false;
dayTabs.innerHTML = DAY_ORDER.map(day => `
<button type="button"
class="schedule-view-day-tab${day === state.mobileDay ? ' active' : ''}"
data-schedule-view-day="${day}">
${escapeHtml(dayShort(day))}
</button>
`).join('');
dayTabs.querySelectorAll('[data-schedule-view-day]').forEach(button => {
button.addEventListener('click', () => {
state.mobileDay = Number(button.dataset.scheduleViewDay);
renderActiveSection();
});
});
}
function renderMobileSchedule(section, range) {
const slots = collectSlots(section.lessons);
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) {
setTableState('Занятия не найдены');
return;
}
tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join('');
}
function renderMobileDayBlock(block, slots, lessonsByDateSlot) {
const dayDates = block.datesByDay[state.mobileDay] || {};
const rows = slots.map(slot => {
const states = slotStatesForDates(dayDates, slot, lessonsByDateSlot);
return `
<article class="schedule-view-mobile-slot">
<div class="schedule-view-mobile-slot-time">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
</div>
<div class="schedule-view-mobile-slot-content">
${renderSlotContent(states)}
</div>
</article>
`;
}).join('');
return `
<section class="schedule-view-mobile-day">
<div class="schedule-view-mobile-day-heading">
<strong>${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))}</strong>
<span>${escapeHtml(mobileDayCaption(dayDates))}</span>
</div>
${rows}
</section>
`;
}
function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) {
const dayHeaders = DAY_ORDER.map(day => ` const dayHeaders = DAY_ORDER.map(day => `
<th class="room-day-cell"> <th class="room-day-cell">
@@ -152,8 +470,8 @@ export async function initScheduleView() {
return ` return `
<section class="schedule-view-week-panel"> <section class="schedule-view-week-panel">
<div class="room-week-heading"> <div class="room-week-heading">
<h3>${escapeHtml(section.title)} · ${escapeHtml(block.label)}</h3> <h3>${escapeHtml(section.title)}</h3>
<span>${escapeHtml(`${lessonCountLabel(section.lessons.length)}; ${combinedWeekCaption(block)}`)}</span> <span>${escapeHtml(combinedWeekCaption(block))}</span>
</div> </div>
<div class="workload-grid-container schedule-view-grid-container"> <div class="workload-grid-container schedule-view-grid-container">
<table class="workload-table room-week-table schedule-view-matrix-table"> <table class="workload-table room-week-table schedule-view-matrix-table">
@@ -175,20 +493,26 @@ export async function initScheduleView() {
} }
function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) {
const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ const states = slotStatesForDates(datesByParity, slot, lessonsByDateSlot);
const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : '';
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
}
function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity, parity,
label, label,
state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot)
})); }));
}
function renderSlotContent(states) {
if (slotStatesEqual(states[0].state, states[1].state)) { if (slotStatesEqual(states[0].state, states[1].state)) {
return `<td class="room-combined-cell room-single-cell">${renderUnifiedSlot(states[0].state)}</td>`; return renderUnifiedSlot(states[0].state);
} }
return states.map(({ parity, label, state }) =>
const halves = states.map(({ parity, label, state }) =>
renderSlotHalf(state, parity, label) renderSlotHalf(state, parity, label)
).join(''); ).join('');
return `<td class="room-combined-cell">${halves}</td>`;
} }
function scheduleSlotState(date, slot, lessonsByDateSlot) { function scheduleSlotState(date, slot, lessonsByDateSlot) {
@@ -298,6 +622,38 @@ export async function initScheduleView() {
if (!tables) return; if (!tables) return;
tables.innerHTML = `<div class="loading-row schedule-view-state">${escapeHtml(message)}</div>`; tables.innerHTML = `<div class="loading-row schedule-view-state">${escapeHtml(message)}</div>`;
} }
function resetResults(message) {
state.sections = [];
state.activeSectionIndex = 0;
state.mobileDay = 1;
state.totalLessons = 0;
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = message;
setTableState(message);
countLabel.textContent = '0 занятий';
dayTabs.hidden = true;
dayTabs.innerHTML = '';
}
function updatePeriodLabel(range) {
if (!periodLabel) return;
if (!dateInput.value) {
periodLabel.textContent = 'Период не выбран';
return;
}
periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value));
}
function groupNameById(id) {
return state.dictionaries.groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
}
function preferredMobileDay(range) {
if (!range) return 1;
return isoDay(dateInput.value || range.startDate);
}
} }
async function loadDictionaries(role, userDepartmentId) { async function loadDictionaries(role, userDepartmentId) {
@@ -310,16 +666,23 @@ async function loadDictionaries(role, userDepartmentId) {
api.get('/api/lesson-types') api.get('/api/lesson-types')
]); ]);
fillSelect('schedule-view-group', groups, item => item.id, item => item.name, 'Все группы'); const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
? groups.filter(group => String(group.departmentId) === String(userDepartmentId))
: groups;
fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы');
fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели'); fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели');
fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории'); fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории');
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры'); fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
if (role === ROLE_DEPARTMENT && userDepartmentId) { return {
document.getElementById('schedule-view-department').value = userDepartmentId; groups,
} teachers,
classrooms,
departments
};
} }
function setDefaultDate(dateInput) { function setDefaultDate(dateInput) {
@@ -342,6 +705,11 @@ function valueOf(id) {
return document.getElementById(id)?.value || ''; return document.getElementById(id)?.value || '';
} }
function normalizeId(value) {
if (!value || value === 'null' || value === 'undefined') return '';
return String(value);
}
function roomLabel(room) { function roomLabel(room) {
return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · ');
} }
@@ -385,12 +753,11 @@ function groupLessonsByDateSlot(lessons) {
return grouped; return grouped;
} }
function buildScheduleSections(lessons) { function buildScheduleSections(lessons, splitType, context) {
const splitType = resolveSplitType();
const sections = new Map(); const sections = new Map();
lessons.forEach(lesson => { lessons.forEach(lesson => {
splitEntitiesForLesson(lesson, splitType).forEach(entity => { splitEntitiesForLesson(lesson, splitType, context).forEach(entity => {
if (!sections.has(entity.key)) { if (!sections.has(entity.key)) {
sections.set(entity.key, { sections.set(entity.key, {
key: entity.key, key: entity.key,
@@ -410,45 +777,42 @@ function buildScheduleSections(lessons) {
.sort((a, b) => naturalCompare(a.title, b.title)); .sort((a, b) => naturalCompare(a.title, b.title));
} }
function resolveSplitType() { function splitEntitiesForLesson(lesson, splitType, context) {
const requested = valueOf('schedule-view-split'); if (splitType === TARGET_TEACHERS) {
if ([SPLIT_GROUPS, SPLIT_TEACHERS, SPLIT_CLASSROOMS].includes(requested)) {
return requested;
}
if (valueOf('schedule-view-teacher')) return SPLIT_TEACHERS;
if (valueOf('schedule-view-classroom')) return SPLIT_CLASSROOMS;
return SPLIT_GROUPS;
}
function splitEntitiesForLesson(lesson, splitType) {
if (splitType === SPLIT_TEACHERS) {
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
} }
if (splitType === SPLIT_CLASSROOMS) { if (splitType === TARGET_CLASSROOMS) {
return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')];
} }
return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана'); if (context.selectedGroupId) {
return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')];
}
return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById);
} }
function singleEntity(prefix, id, name, fallback) { function singleEntity(prefix, id, name, fallback) {
const title = name || fallback; const title = name || fallback;
return { return {
id,
key: `${prefix}:${id ?? title}`, key: `${prefix}:${id ?? title}`,
title title
}; };
} }
function listEntities(prefix, ids = [], names = [], fallback) { function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) {
const safeIds = Array.isArray(ids) ? ids : []; const safeIds = Array.isArray(ids) ? ids : [];
const safeNames = Array.isArray(names) ? names : []; const safeNames = Array.isArray(names) ? names : [];
if (!safeIds.length && !safeNames.length) { if (!safeIds.length && !safeNames.length) {
return [singleEntity(prefix, null, fallback, fallback)]; return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)];
} }
const maxLength = Math.max(safeIds.length, safeNames.length); const maxLength = Math.max(safeIds.length, safeNames.length);
const entities = []; const entities = [];
for (let index = 0; index < maxLength; index += 1) { for (let index = 0; index < maxLength; index += 1) {
const id = safeIds[index] ?? null; const id = safeIds[index] ?? null;
const name = safeNames[index] || (id == null ? fallback : `Группа ${id}`); if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) {
continue;
}
const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`);
entities.push(singleEntity(prefix, id, name, fallback)); entities.push(singleEntity(prefix, id, name, fallback));
} }
return entities; return entities;
@@ -540,6 +904,19 @@ function combinedWeekCaption(block) {
.join('; '); .join('; ');
} }
function mobileDayCaption(datesByParity) {
return CELL_PARITY_LAYOUT
.map(({ parity, label }) => {
const date = datesByParity?.[parity];
return `${label}: ${date ? formatDate(date) : 'даты нет'}`;
})
.join(' · ');
}
function periodCaption(range) {
return `${formatDate(range.startDate)} - ${formatDate(range.endDate)}`;
}
function lessonSlotKey(lesson) { function lessonSlotKey(lesson) {
const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`; const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`;
return value ? String(value) : 'unknown'; return value ? String(value) : 'unknown';
@@ -625,12 +1002,12 @@ function lessonCountLabel(count) {
return `${count} занятий`; return `${count} занятий`;
} }
function tableCountLabel(count) { function sectionCountLabel(count) {
const tail = count % 100; const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} таблиц`; if (tail >= 11 && tail <= 14) return `${count} расписаний`;
if (count % 10 === 1) return `${count} таблица`; if (count % 10 === 1) return `${count} расписание`;
if ([2, 3, 4].includes(count % 10)) return `${count} таблицы`; if ([2, 3, 4].includes(count % 10)) return `${count} расписания`;
return `${count} таблиц`; return `${count} расписаний`;
} }
function compareSlots(a, b) { function compareSlots(a, b) {

View File

@@ -1,79 +1,122 @@
<div class="card"> <section class="schedule-view-shell">
<div class="card-header-row"> <div class="card schedule-view-control-card">
<h2>Фильтры расписания</h2> <div class="schedule-view-control-header">
<button type="button" class="btn-primary" id="schedule-view-load">Показать</button> <div>
<h2>Расписания</h2>
<span class="muted" id="schedule-view-period-label">Период не выбран</span>
</div>
<button type="button" class="btn-primary" 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" data-native-select="true">
<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" data-native-select="true">
<option value="">Все кафедры</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-date">Дата в периоде</label>
<input type="date" id="schedule-view-date">
</div>
<div class="form-group schedule-view-period-control">
<span class="form-label">Переход</span>
<div class="schedule-view-period-buttons">
<button type="button" class="btn-secondary" id="schedule-view-prev-period" aria-label="Предыдущий период"></button>
<button type="button" class="btn-secondary" id="schedule-view-today">Сегодня</button>
<button type="button" class="btn-secondary" id="schedule-view-next-period" aria-label="Следующий период"></button>
</div>
</div>
</div>
<details class="schedule-view-extra-filters">
<summary>Дополнительные фильтры</summary>
<div class="schedule-view-extra-grid">
<div class="form-group">
<label for="schedule-view-subject">Дисциплина</label>
<select id="schedule-view-subject" data-native-select="true">
<option value="">Все дисциплины</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-lesson-type">Тип занятия</label>
<select id="schedule-view-lesson-type" data-native-select="true">
<option value="">Все типы</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-parity">Неделя</label>
<select id="schedule-view-parity" data-native-select="true">
<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>
<div class="schedule-view-filter-grid"> <div class="card schedule-view-results-card">
<div class="form-group"> <div class="schedule-view-results-header">
<label for="schedule-view-date">Дата в периоде</label> <div>
<input type="date" id="schedule-view-date"> <h2>Найденные расписания</h2>
</div> <span class="muted" id="schedule-view-count">0 занятий</span>
<div class="form-group"> </div>
<label for="schedule-view-group">Группа</label> <div class="schedule-view-result-actions">
<select id="schedule-view-group" data-native-select="true"> <button type="button" class="btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание"></button>
<option value="">Все группы</option> <span id="schedule-view-active-label">Нет выбранного расписания</span>
</select> <button type="button" class="btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание"></button>
</div> </div>
<div class="form-group">
<label for="schedule-view-teacher">Преподаватель</label>
<select id="schedule-view-teacher" data-native-select="true">
<option value="">Все преподаватели</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-classroom">Аудитория</label>
<select id="schedule-view-classroom" data-native-select="true">
<option value="">Все аудитории</option>
</select>
</div>
<div class="form-group" id="schedule-view-department-field">
<label for="schedule-view-department">Кафедра</label>
<select id="schedule-view-department" data-native-select="true">
<option value="">Все кафедры</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-subject">Дисциплина</label>
<select id="schedule-view-subject" data-native-select="true">
<option value="">Все дисциплины</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-lesson-type">Тип занятия</label>
<select id="schedule-view-lesson-type" data-native-select="true">
<option value="">Все типы</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-parity">Неделя</label>
<select id="schedule-view-parity" data-native-select="true">
<option value="">Любая</option>
<option value="BOTH">Каждая</option>
<option value="ODD">Нечётная</option>
<option value="EVEN">Чётная</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-split">Таблицы по</label>
<select id="schedule-view-split" data-native-select="true">
<option value="auto">Автоматически</option>
<option value="groups">Группам</option>
<option value="teachers">Преподавателям</option>
<option value="classrooms">Аудиториям</option>
</select>
</div> </div>
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>
</div> </div>
<div class="form-alert" id="schedule-view-alert" role="alert"></div> <div class="card schedule-view-table-card">
</div> <div class="schedule-view-table-header">
<div>
<div class="card"> <h2 id="schedule-view-table-title">Расписание</h2>
<div class="card-header-row"> <span class="muted" id="schedule-view-table-caption">Выберите параметры и нажмите «Показать»</span>
<h2>Таблицы расписания</h2> </div>
<span class="muted" id="schedule-view-count">0 занятий</span> <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> </div>
<div class="schedule-view-table-stack" id="schedule-view-tables"> </section>
<div class="loading-row schedule-view-state">Загрузка...</div>
</div>
</div>