diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md
index e78a6be..2fa695c 100644
--- a/docs/BUSINESS_LOGIC.md
+++ b/docs/BUSINESS_LOGIC.md
@@ -9,7 +9,7 @@
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
-| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в совмещённых таблицах чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям. |
+| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index 8ee0d29..c5f5027 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -137,7 +137,7 @@ frontend/
| `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` |
| `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` |
| `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` |
@@ -147,7 +147,7 @@ frontend/
### Особенности админских вкладок
- Вкладка `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`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css
index ed5fad2..9722ab7 100644
--- a/frontend/admin/css/auditorium-workload.css
+++ b/frontend/admin/css/auditorium-workload.css
@@ -569,19 +569,205 @@
background: var(--bg-input);
}
-.schedule-view-table-stack {
- display: flex;
- flex-direction: column;
+.schedule-view-shell {
+ min-height: calc(100vh - 8.5rem);
+ display: grid;
+ grid-template-rows: auto auto minmax(420px, 1fr);
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 {
+ flex: 1 1 auto;
+ min-height: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.schedule-view-grid-container {
+ flex: 1 1 auto;
+ min-height: 0;
+ height: 100%;
max-height: none;
}
@@ -600,11 +786,80 @@
}
.schedule-view-state {
+ min-height: 100%;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
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 для контейнера сетки */
.workload-grid-container::-webkit-scrollbar {
width: 8px;
@@ -621,6 +876,12 @@
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) {
.workload-target-control,
.workload-display-control {
@@ -638,4 +899,54 @@
.room-grid-container {
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;
+ }
}
diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js
index 787075a..6f64179 100644
--- a/frontend/admin/js/views/schedule-view.js
+++ b/frontend/admin/js/views/schedule-view.js
@@ -2,6 +2,10 @@ import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
+const TARGET_GROUPS = 'groups';
+const TARGET_TEACHERS = 'teachers';
+const TARGET_CLASSROOMS = 'classrooms';
+const TARGET_DEPARTMENTS = 'departments';
const PARITY_LABELS = {
BOTH: 'каждая',
ODD: 'нечётная',
@@ -30,104 +34,418 @@ const CELL_PARITY_LAYOUT = [
{ parity: 'ODD', label: 'Нечётная' },
{ parity: 'EVEN', label: 'Чётная' }
];
-const SPLIT_AUTO = 'auto';
-const SPLIT_GROUPS = 'groups';
-const SPLIT_TEACHERS = 'teachers';
-const SPLIT_CLASSROOMS = 'classrooms';
+const MOBILE_QUERY = '(max-width: 760px)';
export async function initScheduleView() {
const role = localStorage.getItem('role');
- const userDepartmentId = localStorage.getItem('departmentId');
+ const userDepartmentId = normalizeId(localStorage.getItem('departmentId'));
const dateInput = document.getElementById('schedule-view-date');
+ const targetSelect = document.getElementById('schedule-view-target');
const loadButton = document.getElementById('schedule-view-load');
- const departmentField = document.getElementById('schedule-view-department-field');
- const departmentSelect = document.getElementById('schedule-view-department');
- const tables = document.getElementById('schedule-view-tables');
+ const previousPeriodButton = document.getElementById('schedule-view-prev-period');
+ const todayButton = document.getElementById('schedule-view-today');
+ 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 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);
+ configureDepartmentTarget();
+ updatePeriodLabel();
+ updateEntityFieldVisibility();
+ renderResultNavigation();
+
+ targetSelect?.addEventListener('change', () => {
+ clearInactiveEntityValues();
+ updateEntityFieldVisibility();
+ resetResults('Нажмите «Показать», чтобы обновить расписание');
+ });
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 {
- await loadDictionaries(role, userDepartmentId);
- if (role === ROLE_DEPARTMENT) {
- departmentField.hidden = true;
- departmentSelect.value = userDepartmentId || '';
- }
+ state.dictionaries = await loadDictionaries(role, userDepartmentId);
+ configureDepartmentAccess();
+ updateEntityFieldVisibility();
await loadData();
} 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',
+ ``);
+ 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() {
hideAlert('schedule-view-alert');
- setTableState('Загрузка...');
- countLabel.textContent = 'Загрузка...';
if (!dateInput.value) {
const message = 'Выберите дату в периоде';
showAlert('schedule-view-alert', message, 'error');
- setTableState(message);
- countLabel.textContent = '0 занятий';
+ resetResults(message);
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 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({
startDate: range.startDate,
endDate: range.endDate
});
- addOptional(params, 'groupId', valueOf('schedule-view-group'));
- addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
- addOptional(params, 'classroomId', valueOf('schedule-view-classroom'));
- addOptional(params, 'departmentId', role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value);
+ applyTargetFilter(params, target);
addOptional(params, 'subjectId', valueOf('schedule-view-subject'));
addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type'));
addOptional(params, 'parity', valueOf('schedule-view-parity'));
try {
const lessons = await api.get(`/api/schedule/search?${params}`);
- renderLessons(lessons, range);
+ renderLessons(lessons, range, target, previousKey);
} catch (error) {
const message = error.message || 'Ошибка загрузки расписания';
showAlert('schedule-view-alert', message, 'error');
- setTableState(message);
- countLabel.textContent = '0 занятий';
+ resetResults(message);
}
}
- function renderLessons(lessons, range) {
- countLabel.textContent = lessonCountLabel(lessons.length);
+ function applyTargetFilter(params, target) {
+ 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) {
+ state.sections = [];
+ state.activeSectionIndex = 0;
+ renderResultNavigation();
+ tableTitle.textContent = 'Расписание';
+ tableCaption.textContent = periodCaption(range);
setTableState('Занятия не найдены');
+ countLabel.textContent = '0 занятий';
return;
}
- const sections = buildScheduleSections(lessons);
+
+ const sections = buildScheduleSections(lessons, splitTypeForTarget(target), buildSectionContext(target));
if (!sections.length) {
- setTableState('Нет данных для выбранного разреза таблиц');
+ state.sections = [];
+ state.activeSectionIndex = 0;
+ renderResultNavigation();
+ tableTitle.textContent = 'Расписание';
+ tableCaption.textContent = periodCaption(range);
+ setTableState('Нет данных для выбранного режима просмотра');
+ countLabel.textContent = lessonCountLabel(lessons.length);
return;
}
- const tableCount = sections.reduce((count, section) =>
- count + buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons).length, 0
- );
- if (!tableCount) {
- setTableState('Выберите корректный период');
- return;
+
+ state.sections = sections;
+ state.activeSectionIndex = Math.max(0, sections.findIndex(section => section.key === previousKey));
+ if (state.activeSectionIndex < 0) {
+ state.activeSectionIndex = 0;
}
- countLabel.textContent = `${lessonCountLabel(lessons.length)}, ${tableCountLabel(tableCount)}`;
- tables.innerHTML = sections
- .map(section => renderScheduleSection(section, range))
- .join('');
+ state.mobileDay = preferredMobileDay(range);
+ renderResultNavigation();
+ renderActiveSection();
}
- 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 = 'Ничего не найдено';
+ return;
+ }
+
+ setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`);
+ resultTabs.innerHTML = state.sections.map((item, index) => `
+
+ `).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 lessonsByDateSlot = groupLessonsByDateSlot(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))
.join('');
}
+ function renderMobileDayTabs() {
+ dayTabs.hidden = false;
+ dayTabs.innerHTML = DAY_ORDER.map(day => `
+
+ `).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 `
+
| ${renderSlotContent(states)} | `; + } + + function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { + return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ parity, label, state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) })); + } + function renderSlotContent(states) { if (slotStatesEqual(states[0].state, states[1].state)) { - return `${renderUnifiedSlot(states[0].state)} | `; + return renderUnifiedSlot(states[0].state); } - - const halves = states.map(({ parity, label, state }) => + return states.map(({ parity, label, state }) => renderSlotHalf(state, parity, label) ).join(''); - return `${halves} | `; } function scheduleSlotState(date, slot, lessonsByDateSlot) { @@ -298,6 +622,38 @@ export async function initScheduleView() { if (!tables) return; tables.innerHTML = `