diff --git a/UX_IMPROVEMENT_PLAN.md b/UX_IMPROVEMENT_PLAN.md
new file mode 100644
index 0000000..a550daf
--- /dev/null
+++ b/UX_IMPROVEMENT_PLAN.md
@@ -0,0 +1,676 @@
+# План улучшения пользовательского взаимодействия Magistr
+
+## 1. Назначение документа
+
+Этот документ описывает целевое пользовательское взаимодействие для всех ролей Magistr и поэтапный план его улучшения. Главная цель — сделать систему удобной не только на демонстрационных данных, но и в реальной работе университета: при сотнях групп и преподавателей, десятках календарных графиков, большом аудиторном фонде, нескольких версиях расписания и постоянном потоке изменений.
+
+План составлен по текущей реализации frontend, ролевой матрице, API и бизнес-логике проекта. Это экспертный UX-аудит кода и документации, а не замена наблюдению за реальными пользователями. Перед крупными изменениями гипотезы следует проверить на сотрудниках учебного отдела, кафедр, преподавателях и студентах.
+
+## 2. Краткий вывод
+
+В Magistr уже реализована значительная часть предметной логики: ролевой доступ, календарные графики, конструктор и версии расписания, анализ качества, разовые изменения, отсутствия, пожелания преподавателей, нагрузка и отдельные кабинеты конечных пользователей.
+
+Основная UX-проблема — функции организованы преимущественно как набор отдельных форм, таблиц и справочников. Живой пользователь мыслит не сущностями базы данных, а задачами:
+
+- «подготовить новый учебный год»;
+- «проверить готовность данных кафедр»;
+- «составить и опубликовать расписание»;
+- «найти замену отсутствующему преподавателю»;
+- «быстро понять, где у меня следующая пара»;
+- «узнать, приняли ли мою заявку».
+
+При росте данных длинные селекты, таблицы без серверной пагинации, ручная настройка каждой группы и графика, а также потеря контекста между разделами станут главным ограничением. Поэтому приоритет — не визуальный редизайн сам по себе, а переход к интерфейсу задач, очередей, массовых операций и устойчивого рабочего контекста.
+
+## 3. Принципы целевого интерфейса
+
+1. **Сначала текущая задача, затем справочник.** Главный экран роли должен отвечать на вопрос «что требует моего внимания сегодня?».
+2. **Контекст показывается только там, где влияет на результат.** Учебный год, семестр и версия выбираются каскадом внутри профильной вкладки и сохраняются при возврате; общая панель не занимает место в остальных разделах.
+3. **Поиск вместо прокрутки.** Любая коллекция более 20–30 элементов должна иметь поиск, фильтры, сортировку и понятный счётчик результатов.
+4. **Массовые действия — обязательны.** Повторяющиеся операции над группами, графиками, дисциплинами и заявками нельзя заставлять выполнять по одной записи.
+5. **Безопасность изменений.** До сохранения пользователь видит последствия, конфликты и область действия; после сохранения — подтверждение, журнал и возможность отмены там, где это допустимо.
+6. **Прогрессивное раскрытие.** Редкие и сложные параметры скрываются до необходимости, а основной сценарий остаётся коротким.
+7. **Одинаковые паттерны во всех кабинетах.** Поиск, фильтры, статусы, пустые состояния, подтверждения, боковые панели и сообщения работают одинаково.
+8. **Мобильный интерфейс ориентирован на просмотр и оперативные действия.** Большие редакторы могут оставаться desktop-first, но расписание, заявки, статусы и замены должны быть удобны с телефона.
+9. **Доступность является критерием готовности.** Полная клавиатурная навигация, видимый фокус, корректные подписи, контраст, понятные ошибки и отсутствие зависимости только от цвета.
+
+## 4. Текущее состояние и основные риски
+
+### Что уже сделано хорошо
+
+- Роли направляются в подходящие кабинеты, а недоступные разделы скрываются.
+- Учебный отдел и администратор могут пройти путь от правил до версии, анализа качества и публикации.
+- Для точечных изменений есть сравнение «было / станет» и журнал изменений.
+- Кафедра имеет собственную рабочую область, а преподаватель — пожелания, отсутствия и заявки.
+- Студенческое и преподавательское расписание имеют недельную навигацию и сохраняют часть локальных настроек.
+- У календарного графика есть диапазонное заполнение, а не только изменение каждой даты по отдельности.
+- Система использует архивирование и сохраняет исторический контекст.
+
+### Критические UX-риски при больших объёмах
+
+| Риск | Как проявится у пользователя | Последствие |
+|---|---|---|
+| Полная загрузка коллекций | Долгое открытие разделов групп, пользователей, дисциплин и расписания | Ощущение зависания, лишний трафик, тяжёлый DOM |
+| Длинные селекты | Поиск нужной группы, преподавателя или графика среди сотен элементов | Ошибочный выбор и потеря времени |
+| Нет системной пагинации и сортировки | Таблицы становятся длинными, позиция после действия теряется | Невозможность эффективно обрабатывать реестры |
+| Операции по одной записи | Назначение графиков, подготовка групп и привязок повторяется вручную | Большое число кликов и высокий риск пропусков |
+| Контекст хранится фрагментарно | Год, семестр, версия и фильтры приходится выбирать повторно | Ошибки работы «не в том семестре» или «не в той версии» |
+| Разделы отражают модель данных | Пользователь сам собирает бизнес-процесс из нескольких вкладок | Непонятно, что делать дальше и готов ли процесс |
+| Нативные `confirm` и `prompt` | Опасные действия имеют мало контекста, комментарии вводятся в системное окно | Слабая предсказуемость и доступность |
+| Нет общего центра уведомлений | Пользователь узнаёт о новых заявках и решениях только после открытия раздела | Задержка согласований и публикаций |
+| Нет общей модели несохранённых изменений | Переход может привести к потере сложной настройки | Повторная работа и недоверие к системе |
+| Ограниченный экспорт и обмен | Расписание и отчёты сложно передать вне системы | Возврат к скриншотам и ручным таблицам |
+
+## 5. Пользовательские сценарии по ролям
+
+### 5.1. Администратор
+
+#### Реальные цели
+
+- подготовить структуру и справочники университета;
+- создать и сопровождать пользователей;
+- контролировать качество исходных данных;
+- одобрять заявки кафедр на преподавателей;
+- помогать устранять ошибки доступа и конфигурации;
+- видеть состояние системы, а не вручную обходить все разделы.
+
+#### Типичный сценарий сейчас
+
+Администратор входит на дашборд, затем отдельно открывает пользователей, структуру вуза, группы, дисциплины, аудитории, календарные графики и заявки. Формы создания постоянно видимы над реестрами, даже если большую часть времени пользователь только ищет или проверяет записи.
+
+#### Целевой сценарий
+
+1. На главной странице администратор видит очередь внимания: ожидающие заявки, неполные данные, архивные зависимости, ошибки проверки и недавние действия.
+2. Выбирает учебный год внутри мастера подготовки периода.
+3. Открывает мастер «Подготовить учебный год» с шагами и прогрессом.
+4. Исправляет только отмеченные проблемы через глубокие ссылки на конкретную запись.
+5. Выполняет массовое создание или импорт, получает предварительный просмотр и отчёт по строкам.
+6. После завершения видит статус готовности по каждому блоку.
+
+#### Улучшения
+
+- заменить постоянные формы создания кнопкой «Добавить», открывающей боковую панель;
+- добавить глобальный поиск по пользователям, группам, кафедрам, дисциплинам и аудиториям;
+- добавить фильтры по статусу, роли, кафедре и периоду действия;
+- показывать историю записи и зависимые объекты до архивирования;
+- добавить импорт пользователей, групп, аудиторий и дисциплин с dry-run-проверкой;
+- ввести мастер первоначальной настройки тенанта и нового учебного года;
+- показывать «здоровье данных»: группы без графика, дисциплины без преподавателей, аудитории без вместимости/оборудования, незакрытые заявки.
+
+### 5.2. Учебный отдел
+
+#### Реальные цели
+
+- подготовить календарную и временную основу семестра;
+- убедиться, что кафедры предоставили данные;
+- составить расписание без конфликтов;
+- сравнить, проверить и опубликовать версию;
+- быстро обрабатывать отсутствия, переносы и замены;
+- находить свободные аудитории и понимать последствия изменений.
+
+#### Целевой основной путь «Составить и опубликовать расписание»
+
+1. Выбрать учебный год, семестр и версию в локальной панели конструктора расписания.
+2. Открыть чек-лист готовности: календарные графики назначены, дисциплины заполнены, преподаватели привязаны, пожелания рассмотрены, временная сетка настроена.
+3. Создать черновик из опубликованной версии или с нуля.
+4. Работать в конструкторе с поиском, пакетным добавлением и видимой матрицей ресурсов.
+5. Получать конфликт сразу в месте редактирования, с предложенными вариантами решения.
+6. Запустить анализ качества, перейти из проблемы прямо к правилу, вернуться с сохранёнными фильтрами.
+7. Сравнить версию, проверить число изменений и затронутых групп/преподавателей.
+8. Опубликовать с причиной и получить подтверждение доставки изменений конечным пользователям.
+
+#### Целевой оперативный путь «Изменение в течение семестра»
+
+1. Открыть единую очередь инцидентов: отсутствия, заявки преподавателей, конфликты и ручные изменения.
+2. Увидеть приоритет, срок, автора и затронутые занятия.
+3. Выбрать предложенную замену или найти ресурс через контекстный поиск.
+4. До подтверждения увидеть «было / станет», новые конфликты и список уведомляемых людей.
+5. Применить решение пакетом и получить запись в журнале.
+
+#### Улучшения
+
+- объединить версии, качество и конструктор общим пошаговым контуром, не удаляя отдельные экспертные экраны;
+- добавить глобальную панель «Учебный год · Семестр · Версия»;
+- ввести автосохранение черновика или явный индикатор «Сохранено / Есть изменения / Ошибка»;
+- добавить undo для локально обратимых действий и журнал последних операций;
+- сделать массовое назначение правил и слотов нескольким группам;
+- добавить боковую инспекцию группы, преподавателя и аудитории без ухода со страницы;
+- показывать конфликты и пожелания непосредственно в визуальной сетке;
+- добавить командную палитру или быстрый поиск для опытных диспетчеров;
+- сохранить представления фильтров: «1 курс ИТ», «вечерние аудитории корпуса Б», «кафедра ВТ».
+
+### 5.3. Кафедра
+
+#### Реальные цели
+
+- поддерживать список дисциплин и преподавателей своей кафедры;
+- назначать преподавателей на дисциплины;
+- передавать учебному отделу корректные исходные данные;
+- контролировать нагрузку;
+- согласовывать пожелания и отсутствия;
+- отслеживать результат заявок.
+
+#### Проблема текущей компоновки
+
+В одной рабочей области одновременно находятся период, импорт дисциплин, добавление преподавателя, заявка на преподавателя, нагрузка, заявки, дисциплины и комментарии. При повседневной работе это создаёт длинную страницу и смешивает редкие настройки с ежедневными задачами.
+
+#### Целевой сценарий
+
+1. Главная кафедры показывает готовность к семестру и очередь действий.
+2. Раздел «Подготовка семестра» группирует дисциплины, преподавателей, привязки и пожелания.
+3. Раздел «Нагрузка» показывает отклонения, недогруз и перегруз, а не только список метрик.
+4. Раздел «Заявки» объединяет создание преподавателя, отсутствия и изменения занятий со статусами и сроками.
+5. После отправки данных кафедра видит, что принято, отклонено или требует уточнения.
+
+#### Улучшения
+
+- разделить рабочую область на «Обзор», «Дисциплины», «Преподаватели и нагрузка», «Заявки»;
+- добавить поиск и фильтры по дисциплинам и преподавателям;
+- поддержать вставку строк из Excel/буфера и загрузку файла вместо ручного добавления строк импорта;
+- добавить массовую привязку преподавателей к дисциплинам;
+- показывать полноту данных по каждой дисциплине;
+- отображать нагрузку с нормой, отклонением и объяснением расчёта;
+- дать кафедре читаемый timeline заявки и явное следующее действие;
+- уведомлять о решениях учебного отдела и администратора.
+
+### 5.4. Пользователь просмотра расписаний
+
+#### Реальные цели
+
+- быстро найти расписание группы, преподавателя, аудитории или кафедры;
+- переключаться между несколькими результатами;
+- распечатать, экспортировать или отправить ссылку;
+- понимать, актуально ли расписание и есть ли разовые изменения.
+
+#### Улучшения
+
+- единая поисковая строка с типизированными результатами: «группа», «преподаватель», «аудитория»;
+- недавние и избранные расписания;
+- URL, содержащий выбранный объект, семестр, дату и режим представления;
+- кнопки «Скопировать ссылку», «Печать», «PDF», «ICS»;
+- явная отметка «Опубликовано …», легенда замен и отмен;
+- режим сравнения двух расписаний для поиска общего свободного времени;
+- сохранение фильтров после перезагрузки и возврата на страницу.
+
+### 5.5. Преподаватель
+
+#### Реальные цели
+
+- за несколько секунд увидеть ближайшую пару и аудиторию;
+- понять, что изменилось с последнего просмотра;
+- сообщить об отсутствии;
+- задать доступность и пожелания до составления расписания;
+- запросить перенос, аудиторию или отмену и отследить решение.
+
+#### Целевой сценарий
+
+1. После входа преподаватель видит «Сегодня» и карточку следующего занятия.
+2. Изменённые, перенесённые и отменённые пары выделены и объяснены текстом.
+3. Из карточки занятия доступны контекстные действия: маршрут до аудитории, заявка на изменение, добавление в календарь.
+4. Заявка создаётся с уже заполненными данными занятия; система сразу показывает допустимые даты, пары и аудитории.
+5. Статус заявки меняется в понятном timeline и сопровождается уведомлением.
+
+#### Улучшения
+
+- сделать «Сегодня / Неделя / Семестр» тремя явными режимами;
+- добавить карточку следующего занятия и блок недавних изменений;
+- показывать корпус, этаж и оборудование аудитории;
+- сохранить черновик длинной заявки при случайном закрытии;
+- объяснять разницу между строгой недоступностью и мягким пожеланием до выбора режима;
+- добавить массовое заполнение предпочтений: копирование дня, диапазон пар, типовая неделя;
+- показывать дедлайн подачи пожеланий и статус согласования;
+- добавить экспорт в личный календарь и подписку ICS.
+
+### 5.6. Студент
+
+#### Реальные цели
+
+- один раз выбрать свою группу;
+- быстро увидеть занятия сегодня и завтра;
+- узнать об отмене, переносе или смене аудитории;
+- перейти к нужной неделе без знания чётности;
+- поделиться расписанием или добавить его в календарь.
+
+#### Проблема масштаба
+
+Выбор группы обычным списком неудобен при сотнях групп. Пользователь не должен каждый раз разбираться в полном университетском справочнике.
+
+#### Улучшения
+
+- первый запуск: поиск группы по названию с подсказками по специальности, курсу и кафедре;
+- после выбора сделать группу постоянным профилем, а смену вынести в отдельное действие;
+- стартовый режим «Сегодня», ниже — «Завтра» и ближайшее изменение;
+- добавить быстрый переход «Эта неделя / Следующая неделя»;
+- показывать статус обновления расписания и понятную легенду изменений;
+- добавить shareable URL, печать, PDF и ICS;
+- предусмотреть кеш последнего расписания для слабой сети;
+- на мобильном устройстве использовать карточки дней и sticky-навигацию, а не уменьшенную широкую таблицу.
+
+## 6. Сквозные процессы между ролями
+
+### 6.1. Подготовка семестра
+
+```text
+Администратор настраивает структуру и справочники
+ ↓
+Кафедра заполняет дисциплины, преподавателей и пожелания
+ ↓
+Учебный отдел проверяет готовность календарей и данных
+ ↓
+Учебный отдел создаёт и проверяет черновик
+ ↓
+Публикация → преподаватели, студенты и просмотр расписаний
+```
+
+Для процесса нужен единый статус готовности с владельцем каждой проблемы. Пользователь должен видеть не просто «ошибка», а «что не готово → кто исправляет → куда перейти».
+
+### 6.2. Отсутствие и замена
+
+```text
+Преподаватель сообщает об отсутствии
+ ↓
+Кафедра подтверждает факт
+ ↓
+Учебный отдел выбирает решения по затронутым занятиям
+ ↓
+Расписание изменяется, решение попадает в аудит
+ ↓
+Преподаватель и затронутые группы получают уведомление
+```
+
+Все роли должны видеть один и тот же номер инцидента, единые статусы и хронологию, но разные доступные действия.
+
+### 6.3. Заявка на изменение занятия
+
+Нужна единая модель статусов: `Черновик → Отправлена → Проверяется → Требует уточнения / Одобрена / Отклонена → Применена`. Технические статусы API можно сохранить, но UI должен переводить их в понятный язык и показывать следующее действие.
+
+## 7. Масштабирование интерфейса
+
+### 7.1. Реестры
+
+Для групп, пользователей, аудиторий, дисциплин, заявок и правил необходимо:
+
+- серверная пагинация или cursor pagination;
+- серверные поиск, фильтрация и сортировка;
+- размер страницы 25/50/100;
+- строка поиска с задержкой 250–400 мс и отменой предыдущего запроса;
+- sticky-заголовок таблицы;
+- сохранение фильтров в URL;
+- выбор строк и массовая панель действий;
+- счётчик «Показано N из M»;
+- настраиваемые столбцы для сложных реестров;
+- виртуализация только там, где пагинация мешает задаче, например в большой визуальной матрице.
+
+### 7.2. Поиск сущностей
+
+Селекты с большими наборами следует заменить асинхронным комбобоксом:
+
+- поиск начинается после 2 символов;
+- результат показывает различающие атрибуты;
+- группы: название, курс, специальность, форма, кафедра;
+- преподаватели: ФИО, должность, кафедры;
+- аудитории: название, корпус, этаж, вместимость, оборудование;
+- графики: учебный год, специальность, профиль, форма, число курсов;
+- выбранное значение остаётся видимым чипом;
+- клавиатура и экранный диктор поддерживаются полностью.
+
+### 7.3. Календарные графики
+
+При десятках похожих графиков требуются:
+
+- пользовательское название или короткий alias графика;
+- поиск и фасетные фильтры по году, специальности, профилю и форме;
+- клонирование графика из прошлого года с предварительным просмотром сдвига дат;
+- массовое создание на основе шаблона;
+- массовое назначение выбранным совместимым группам;
+- индикатор использования: сколько групп назначено;
+- сравнение двух графиков и подсветка различий;
+- защита от потери несохранённой сетки;
+- автосохранение черновика сетки локально или на сервере;
+- undo/redo для заполнения диапазона и изменения ячеек;
+- проверка до сохранения с переходом к ошибочной дате;
+- закреплённые заголовки курса, месяца и легенды при прокрутке.
+
+### 7.4. Конструктор расписания
+
+- master-detail-компоновка: слева фильтруемый список правил, справа редактор выбранного правила;
+- создание из шаблона и дублирование правила;
+- массовое назначение групп, преподавателя, слотов и диапазонов недель;
+- фильтры по заполненности, конфликтам, кафедре, курсу и дисциплине;
+- визуальная сетка загружает только выбранный набор групп;
+- конфликт объясняется человеческим языком и содержит ссылку на конфликтующее правило;
+- перед удалением показывается количество затронутых занятий;
+- публикация заблокирована до прохождения обязательных проверок.
+
+## 8. Целевая информационная архитектура
+
+### Администратор
+
+- Обзор и задачи
+- Подготовка учебного года
+- Пользователи и доступ
+- Структура и справочники
+- Расписание
+- Оперативные изменения
+- Отчёты и аудит
+- Настройки
+
+### Учебный отдел
+
+- Обзор и очередь
+- Подготовка семестра
+- Конструктор расписания
+- Проверка и публикация
+- Просмотр расписаний
+- Изменения и замены
+- Ресурсы и загруженность
+
+### Кафедра
+
+- Обзор
+- Подготовка семестра
+- Дисциплины
+- Преподаватели и нагрузка
+- Расписание кафедры
+- Заявки и согласования
+
+Названия меню должны описывать рабочие задачи. Технические сущности остаются внутри экранов и документации, но не обязаны быть верхним уровнем навигации.
+
+## 9. Общие компоненты и паттерны
+
+Необходимо сформировать единый набор компонентов для Vanilla JS:
+
+- асинхронный combobox;
+- панель фильтров с чипами активных условий и кнопкой «Сбросить»;
+- таблица с серверной пагинацией, сортировкой, выбором строк и массовыми действиями;
+- боковая панель просмотра/редактирования;
+- диалог подтверждения с описанием последствий;
+- toast для краткого результата и inline-сообщение для ошибки поля/раздела;
+- skeleton-состояние вместо скачка пустой таблицы;
+- стандартизированные empty/error/permission states;
+- status badge с текстом и иконкой;
+- breadcrumb или ссылка «Назад к результатам» с восстановлением позиции;
+- индикатор сохранения и защита несохранённых изменений;
+- timeline для заявок и аудита;
+- центр уведомлений и счётчики очередей.
+
+Системные `confirm()` и `prompt()` следует постепенно заменить доступными проектными диалогами. Сообщение об успешном действии должно называть объект и результат: «График назначен 18 группам», а не просто «Успешно».
+
+## 10. Состояния интерфейса
+
+Каждый рабочий экран должен проектироваться минимум для следующих состояний:
+
+1. первоначальная загрузка;
+2. данные загружены;
+3. пустой справочник;
+4. фильтр не дал результатов;
+5. частичная ошибка;
+6. полная ошибка с повтором;
+7. нет прав;
+8. объект изменён другим пользователем;
+9. есть несохранённые изменения;
+10. действие выполняется;
+11. действие успешно завершено;
+12. действие завершено частично, например при массовом импорте.
+
+Для конкурентного редактирования версий, календарей и правил желательно использовать optimistic locking и понятный диалог: кто и когда изменил объект, можно ли обновить данные или сохранить копию.
+
+## 11. Уведомления
+
+Минимально необходимы уведомления внутри приложения:
+
+- новая заявка для ответственной роли;
+- решение по заявке;
+- приближение дедлайна пожеланий;
+- публикация новой версии;
+- перенос, отмена или смена аудитории;
+- конфликт или ошибка массовой операции.
+
+Уведомление должно вести к конкретному объекту, иметь статус прочтения и не раскрывать данные другой кафедры. Email/web-push можно добавить позднее, используя те же события.
+
+## 12. Доступность и адаптивность
+
+- обеспечить WCAG 2.2 AA как целевой уровень;
+- проверить контраст обеих тем;
+- добавить `aria-sort` таблицам и объявление количества результатов;
+- реализовать корректные combobox/listbox-роли для поиска;
+- возвращать фокус после закрытия модального окна;
+- закрывать диалоги по `Escape`, удерживать фокус внутри открытого диалога;
+- не использовать цвет как единственный признак конфликта, статуса или чётности;
+- увеличить интерактивные зоны минимум до 44×44 px на мобильных экранах;
+- для расписаний на телефоне показывать один день/список занятий, а не горизонтально сжатую таблицу;
+- уважать `prefers-reduced-motion`;
+- сделать печатные стили для расписаний и отчётов.
+
+## 13. Приоритетный roadmap
+
+### Этап 0. Проверка гипотез и базовые метрики — 1–2 недели
+
+- провести 5–7 интервью с учебным отделом, 3–5 с кафедрами, 5 с преподавателями и 5 со студентами;
+- наблюдать выполнение реальных задач без подсказок;
+- замерить время, число кликов, ошибки и точки возврата;
+- зафиксировать объёмы: группы, графики, правила, преподаватели, аудитории, заявки;
+- построить карту основных процессов и согласовать владельцев статусов.
+
+**Результат:** подтверждённый список top-10 проблем и исходные значения метрик.
+
+### Этап 1. Быстрые улучшения frontend — 2–4 недели
+
+- сохранять в URL и восстанавливать фильтры, вкладки, семестр и выбранный объект;
+- добавить поиск, сортировку и sticky-заголовки в текущие таблицы;
+- заменить опасные `confirm/prompt` проектными диалогами;
+- унифицировать загрузку, пустые состояния и ошибки;
+- добавить защиту несохранённых изменений в календарь и конструктор;
+- улучшить первый выбор группы студента;
+- добавить режим «Сегодня» преподавателю и студенту;
+- добавить ссылки «Скопировать» и печатные стили просмотра расписания.
+
+**Результат:** заметное сокращение ежедневного трения без крупной переделки backend.
+
+### Этап 2. Масштабные реестры и поиск — 4–8 недель
+
+- добавить API пагинации, поиска, сортировки и фасетных фильтров;
+- внедрить общий компонент серверной таблицы;
+- внедрить асинхронный combobox;
+- добавить массовые операции для групп, графиков и привязок;
+- добавить предварительный просмотр и отчёт массовых операций;
+- обеспечить стабильные deep links на сущности.
+
+**Результат:** интерфейс остаётся работоспособным на полном объёме данных университета.
+
+### Этап 3. Интерфейс сквозных задач — 6–10 недель
+
+- локальные каскады учебного года, семестра и версии в профильных вкладках;
+- чек-лист готовности семестра;
+- ролевые дашборды с очередями действий;
+- единая очередь инцидентов и согласований;
+- timeline заявок;
+- связанный путь «конструктор → качество → сравнение → публикация»;
+- центр уведомлений.
+
+**Результат:** пользователь проходит бизнес-процесс, не собирая его вручную из вкладок.
+
+### Этап 4. Продвинутые инструменты — после стабилизации
+
+- шаблоны и клонирование календарных графиков;
+- импорт из Excel с сопоставлением колонок;
+- сравнение расписаний и поиск общего свободного времени;
+- экспорт PDF/Excel/ICS и календарная подписка;
+- оптимистические блокировки и совместное редактирование;
+- командная палитра и горячие клавиши для опытных пользователей;
+- персональные сохранённые представления.
+
+## 14. Приоритеты по модели RICE без числовой фиксации
+
+| Инициатива | Охват | Влияние | Сложность | Приоритет |
+|---|---|---|---|---|
+| Серверный поиск и пагинация | Все внутренние роли | Очень высокое | Высокая | P0 |
+| Локальные каскады года/семестра/версии | Администратор, учебный отдел, кафедра | Очень высокое | Средняя | P0 |
+| Защита несохранённых изменений | Учебный отдел | Очень высокое | Средняя | P0 |
+| Асинхронные комбобоксы | Все роли | Высокое | Средняя | P0 |
+| URL-состояние и глубокие ссылки | Все роли | Высокое | Низкая/средняя | P0 |
+| Массовое назначение графиков группам | Администратор, учебный отдел | Очень высокое | Средняя/высокая | P0 |
+| Ролевые очереди задач | Внутренние роли | Высокое | Средняя | P1 |
+| Единый контур публикации | Учебный отдел | Высокое | Средняя | P1 |
+| Уведомления | Все роли | Высокое | Высокая | P1 |
+| Импорт с предварительной проверкой | Администратор, кафедра | Высокое | Высокая | P1 |
+| «Сегодня» и ближайшая пара | Преподаватель, студент | Высокое | Низкая | P1 |
+| PDF/ICS/ссылки | Просмотр, преподаватель, студент | Среднее | Средняя | P2 |
+| Командная палитра | Опытные внутренние пользователи | Среднее | Средняя | P2 |
+
+## 15. Изменения backend, необходимые для полноценного UX
+
+Frontend-улучшений недостаточно для больших объёмов. Понадобятся:
+
+- единый контракт пагинации и сортировки для реестров;
+- полнотекстовый или индексированный поиск по нормализованным полям;
+- batch-endpoints с атомарным и частичным режимами;
+- dry-run для импорта, назначения и публикации;
+- endpoint готовности семестра с кодами проблем и deep-link metadata;
+- endpoint глобального поиска в рамках прав пользователя;
+- события и хранилище уведомлений;
+- optimistic locking/version field для редактируемых сущностей;
+- агрегированные endpoints для дашбордов, чтобы не собирать их множеством клиентских запросов;
+- экспортные задания для тяжёлых PDF/Excel;
+- аудит массовых операций с автором, причиной и набором затронутых объектов.
+
+Все контракты должны сохранять мультитенантную и ролевую изоляцию. Сервер обязан повторно применять ограничения роли независимо от фильтров frontend.
+
+## 16. UX-метрики
+
+### Операционные
+
+- медианное время создания и назначения календарного графика;
+- время поиска конкретной группы, преподавателя и аудитории;
+- время от регистрации отсутствия до применения всех решений;
+- время от создания черновика до публикации;
+- число ручных действий на 10 групп или 10 правил;
+- процент массовых операций с частичными ошибками.
+
+### Качественные
+
+- success rate ключевого сценария без помощи;
+- число возвратов и повторных сохранений;
+- доля ошибок «не тот семестр / не та версия / не та кафедра»;
+- доля пользователей, понимающих следующий шаг;
+- SUS или UMUX-Lite по каждой роли;
+- CSAT после обработки заявки или публикации.
+
+### Технические UX-метрики
+
+- p75 времени появления полезного содержимого;
+- p75 ответа поиска;
+- размер DOM больших таблиц и сеток;
+- частота ошибок API по экрану и действию;
+- доля прерванных загрузок и повторных запросов;
+- Core Web Vitals для публичных/конечных кабинетов.
+
+Целевые значения следует зафиксировать после этапа 0. Без исходных измерений произвольные проценты улучшения будут недостоверны.
+
+## 17. Критерии готовности ключевых улучшений
+
+### Реестр больших данных готов, если
+
+- 10 000 записей не загружаются целиком в браузер;
+- поиск возвращает результат за приемлемое для проекта целевое время;
+- фильтры и страница восстанавливаются после возврата;
+- массовая операция показывает область действия до подтверждения;
+- частичные ошибки перечислены по объектам и доступны для выгрузки;
+- управление возможно с клавиатуры.
+
+### Календарный график готов, если
+
+- пользователь находит нужный график без просмотра полного списка;
+- может создать его из шаблона и назначить нескольким совместимым группам;
+- видит несохранённые изменения;
+- может отменить последнее редактирование;
+- ошибка указывает точный курс и дату;
+- уход со страницы не приводит к молчаливой потере работы.
+
+### Публикация готова, если
+
+- текущий семестр и версия видны постоянно;
+- до публикации показаны ошибки, предупреждения и diff;
+- указано число затронутых групп, преподавателей и занятий;
+- причина обязательна и попадает в аудит;
+- пользователь получает однозначное подтверждение результата;
+- конечные роли видят актуальность и изменения.
+
+## 18. План пользовательского тестирования
+
+Для каждого прототипа проводить короткие сценарные тесты:
+
+| Роль | Проверочное задание |
+|---|---|
+| Администратор | Создать 20 групп импортом, исправить ошибки и назначить графики |
+| Учебный отдел | Создать черновик, устранить конфликт, проверить качество и опубликовать |
+| Учебный отдел | Обработать отсутствие с несколькими затронутыми занятиями |
+| Кафедра | Добавить дисциплины и преподавателей, проверить готовность семестра |
+| Просмотр | Найти расписание аудитории и отправить ссылку коллеге |
+| Преподаватель | Указать недоступность и подать заявку на перенос |
+| Студент | Выбрать группу и найти аудиторию следующей пары |
+
+Фиксировать время, ошибки, вопросы пользователя, неверные ожидания и субъективную уверенность. Тестировать минимум на реальных объёмах, а не на 3–5 элементах.
+
+## 19. Что не следует делать первым
+
+- полностью менять визуальный стиль без исправления поиска, масштаба и процессов;
+- объединять все роли в один универсальный экран;
+- скрывать сложность только дополнительными модальными окнами;
+- переносить всю фильтрацию на клиент;
+- добавлять массовые действия без preview, отчёта и аудита;
+- полагаться только на цвет для статусов;
+- автоматически применять пожелания преподавателей без явного решения ответственной роли;
+- внедрять drag-and-drop как единственный способ редактирования расписания.
+
+## 20. Рекомендуемый первый пакет реализации
+
+Первый практический пакет должен дать максимальный эффект без полной перестройки системы:
+
+1. локальные каскады «Учебный год / Семестр / Версия» только в профильных вкладках;
+2. URL-состояние для вкладок и фильтров;
+3. асинхронный поиск групп, преподавателей, аудиторий и графиков;
+4. серверная пагинация для групп, пользователей, дисциплин и заявок;
+5. массовое назначение календарного графика выбранным группам с dry-run;
+6. предупреждение о несохранённых изменениях календаря и конструктора;
+7. проектные диалоги вместо `confirm/prompt`;
+8. «Сегодня» и «Следующая пара» в кабинетах преподавателя и студента;
+9. единые состояния загрузки, отсутствия данных и ошибки;
+10. базовые события аналитики для измерения результата.
+
+После этого пакета следует переходить к чек-листу готовности семестра, ролевым очередям и единому процессу публикации.
+
+### 20.1. Статус реализации первого пакета — 19 августа 2026
+
+| № | Статус | Что реализовано |
+|---|---|---|
+| 1 | Выполнено, решение уточнено | Общая панель контекста удалена. В `schedule`, `schedule-versions` и `schedule-quality` используются локальные каскады учебного года, семестра и версии; в `schedule-view` — года и семестра, поскольку экран показывает только опубликованное расписание. |
+| 2 | Выполнено | Вкладки, фильтры, сортировка, размер и номер страницы сохраняются в query string. Просмотр расписания сохраняет локальные год и семестр; преподаватель — вкладку и неделю, студент — группу и неделю. |
+| 3 | Выполнено | Добавлены серверные endpoints и общий `AsyncCombobox` для групп, преподавателей, аудиторий и совместимых календарных графиков. Первый выбор группы студента и выбор преподавателя при регистрации отсутствия больше не загружают полный справочник. |
+| 4 | Выполнено | Группы, пользователи, дисциплины и заявки преподавателей используют серверные поиск, фильтры, сортировку и пагинацию 25/50/100 с общим `PageResponse`. Для страницы дисциплин загружаются только её привязки преподавателей, а подгруппы запрашиваются только для открытой или выбранной в конструкторе группы. |
+| 5 | Отложено по решению заказчика | Массовое назначение одного календарного графика нескольким совместимым группам и dry-run в этот пакет не входят. Текущее одиночное назначение сохранено. |
+| 6 | Выполнено | Календарная сетка, привязки дисциплин и конструктор правил отслеживают несохранённые изменения, показывают индикатор на кнопке и предупреждают при уходе, смене графика или версии. |
+| 7 | Выполнено | Нативные `confirm`, `prompt` и `alert` в административных и settings-модулях заменены единым доступным проектным диалогом. |
+| 8 | Выполнено | В кабинетах преподавателя и студента появились блок «Сегодня» и карточка «Следующая пара» с поиском занятия в горизонте 14 дней. |
+| 9 | Выполнено | Для серверных реестров внедрены общие loading/empty/error-состояния с повтором; для ближайших занятий применяется единый компонент состояния. |
+| 10 | Выполнено | Добавлены прикладные UX-события: смена раздела, загрузка реестра/расписания, выбор группы, сохранение правила, сетки и привязок. В production события преобразуются в OpenTelemetry spans. |
+
+Дополнительно закрыты три согласованных требования масштабирования: серверные
+поиск/фильтрация/сортировка/пагинация реестров, асинхронные селекты больших справочников и
+восстановление фильтров/страницы из URL. Ролевое ограничение кафедры повторно применяется
+backend и не зависит от переданного frontend-фильтра.
+
+Проверка реализации: `npm run check` выполняет синтаксический контроль новых модулей и все
+18 frontend-наборов; отдельный тест закрепляет выбор сегодняшних занятий, текущей и следующей
+пары. Production-сборка frontend и 339 backend-тестов через полный `mvn test` в Java 17
+контейнере проходят успешно. Массовое назначение из пункта 5 остаётся единственным
+сознательно незавершённым пунктом этого пакета.
+
+## 21. Источники анализа в проекте
+
+- `docs/BUSINESS_LOGIC.md` — роли и бизнес-процессы;
+- `docs/FRONTEND.md` — маршруты и текущее поведение экранов;
+- `docs/API.md` — возможности и ограничения API;
+- `frontend/admin/js/role-capabilities.js` — фактическая матрица вкладок;
+- `frontend/admin/views/` и `frontend/admin/js/views/` — административные сценарии;
+- `frontend/teacher/` — кабинет преподавателя;
+- `frontend/student/` — кабинет студента.
diff --git a/UX_SITE_CHECKLIST.md b/UX_SITE_CHECKLIST.md
new file mode 100644
index 0000000..af856a5
--- /dev/null
+++ b/UX_SITE_CHECKLIST.md
@@ -0,0 +1,55 @@
+# Чек-лист проверки UX-изменений на сайте
+
+## Перед проверкой
+
+1. Запустите проект: `docker compose up -d --build`.
+2. Откройте [http://localhost:80](http://localhost:80) или production-сайт.
+3. Для полной проверки нужны пользователи с ролями `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `TEACHER` и `STUDENT`.
+4. Пагинацию можно полноценно проверить, если в справочнике больше 25 записей.
+
+## Что проверить
+
+| № | Роль и куда зайти | Что нажать | Что должно быть видно |
+|---|---|---|---|
+| 1 | `ADMIN` → **Дашборд**, **Группы обучения** или **Пользователи** | Просто откройте вкладки | Под заголовком нет общей панели **КОНТЕКСТ**; она не занимает место в разделах, которым учебный период не нужен |
+| 2 | `ADMIN` → **Группы обучения** | Введите текст в **Поиск**, выберите форму обучения и сортировку, затем обновите страницу | Фильтры остаются выбранными, а в адресной строке появляются параметры `groupQuery`, `groupEducationForm`, `groupSort`, `groupPage` |
+| 3 | `ADMIN` → **Группы обучения**, **Пользователи**, **Дисциплины**, **Заявки** | Используйте поиск, фильтры, сортировку, **Назад / Далее** и список **На странице: 25 / 50 / 100** | Таблица обновляется без полной загрузки всех записей; показан диапазон вида «Показано 1–25 из …» |
+| 4 | `ADMIN` или `EDUCATION_OFFICE` → **Просмотр расписаний** | Откройте вкладку, не нажимая на поле **Группа**; затем раскройте **Дополнительные фильтры**, выберите **Учебный год** и **Семестр** и нажмите **Показать** | При открытии список групп свёрнут; семестры ограничиваются выбранным годом, значения сохраняются в URL; экран показывает опубликованное расписание |
+| 5 | `ADMIN` → **Группы обучения** → у группы кнопка **Календарь** | Выберите учебный год и начните печатать название графика | Совместимые календарные графики ищутся асинхронно; весь справочник заранее не загружается. Сохранять назначение для проверки не обязательно |
+| 6 | `ADMIN` или `EDUCATION_OFFICE` → **Конструктор правил** | В верхней карточке по очереди выберите **Учебный год**, **Семестр**, **Версию расписания**; затем раскройте **Группы** и найдите преподавателя или аудиторию | Каждый селект ограничивает следующий; загружаются версии только выбранного семестра, а большие справочники ищутся через поисковые списки |
+| 7 | `ADMIN` или `EDUCATION_OFFICE` → **Запросы преподавателей** → блок **Зарегистрировать отсутствие** | Начните вводить ФИО преподавателя из любой кафедры | Поиск находит преподавателя, а список результатов отображается поверх блока **Реестр инцидентов**, не прячется за ним |
+| 8 | `ADMIN` → **Календарный график** → **Сетки** | Выберите график → **Загрузить сетку** → измените ячейку или примените **Заполнить диапазон** → нажмите другую вкладку слева | Кнопка меняется на **Сохранить сетку •**, затем появляется диалог **Несохранённые изменения** с действиями **Остаться** и **Покинуть без сохранения** |
+| 9 | `ADMIN` → **Конструктор правил** | Сначала сразу перейдите в другой раздел, затем вернитесь, измените любое поле правила и снова попробуйте уйти | Без изменений переход выполняется без предупреждения; после ручного изменения появляется диалог **Несохранённые изменения** |
+| 10 | `ADMIN` → **Группы обучения** | У активной группы нажмите **Архив**, затем в диалоге нажмите **Отмена** | Появляется проектный диалог **Архивировать группу?**, а не системное окно браузера; данные после отмены не меняются |
+| 11 | `TEACHER` → **Расписание** | Просто откройте кабинет; затем нажмите **Сегодня** | Над недельным расписанием видны карточки **Сегодня** и **Следующая пара**; кнопка возвращает к текущей неделе |
+| 12 | `STUDENT` → страница расписания | Начните вводить название группы, выберите её и нажмите **Сегодня** | Группа ищется асинхронно; появляются карточки **Сегодня** и **Следующая пара**; выбранная группа сохраняется после перезагрузки |
+| 13 | `TEACHER` → **Мои пожелания** или **Заявки на изменение** | Переключите вкладку и обновите страницу | Открытая вкладка восстанавливается из URL; выбранная неделя также сохраняется |
+| 14 | Любой серверный реестр, например `ADMIN` → **Дисциплины** | Введите заведомо отсутствующую строку, например `___нет_такой_записи___` | Появляется понятное пустое состояние **Дисциплины не найдены**, а не пустая таблица |
+| 15 | `ADMIN` или `EDUCATION_OFFICE` → **Версии расписания**, затем **Анализ качества** | На обеих вкладках смените **Учебный год** и **Семестр**; в анализе дополнительно выберите **Версию** | В «Версиях» обновляются карточки публикации/черновиков выбранного семестра, в «Анализе качества» — список доступных версий |
+
+## Дополнительные проверки
+
+### Ошибка и повторная загрузка
+
+1. Откройте DevTools → **Network** → включите **Offline**.
+2. Обновите реестр групп, пользователей, дисциплин или заявок.
+3. Должно появиться сообщение об ошибке и кнопка **Повторить**.
+4. Верните **Online** и нажмите **Повторить** — данные должны загрузиться.
+
+### UX-аналитика
+
+До выполнения действий вставьте в консоль браузера:
+
+```javascript
+document.addEventListener('magistr:ux-event', event => console.log(event.detail));
+```
+
+После смены вкладки, фильтра или загрузки расписания в консоли должны появляться UX-события. На production они также преобразуются в OpenTelemetry spans.
+
+### Ролевое ограничение кафедры
+
+Войдите как `DEPARTMENT` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий.
+
+## Что пока не проверяем
+
+Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**.
diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java
index 728facc..8e7d9a8 100644
--- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java
+++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java
@@ -8,6 +8,8 @@ import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
@@ -18,6 +20,8 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.ArrayList;
+import java.util.Locale;
import java.util.stream.Collectors;
@RestController
@@ -58,6 +62,32 @@ public class AcademicCalendarController {
.toList();
}
+ @GetMapping("/options")
+ public List searchCalendarOptions(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) Long academicYearId,
+ @RequestParam(required = false) Long specialtyId,
+ @RequestParam(required = false) Long profileId,
+ @RequestParam(required = false) Long studyFormId,
+ @RequestParam(required = false) Long selectedId,
+ @RequestParam(defaultValue = "20") int limit) {
+ String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
+ List options = new ArrayList<>(calendarRepository.searchOptions(
+ normalizedQuery,
+ academicYearId,
+ specialtyId,
+ profileId,
+ studyFormId,
+ PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("title").ascending())
+ ).stream()
+ .map(this::toCalendarDto)
+ .toList());
+ if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.id()))) {
+ calendarRepository.findByIdWithDetails(selectedId).map(this::toCalendarDto).ifPresent(options::add);
+ }
+ return options;
+ }
+
@GetMapping("/{id}")
public ResponseEntity> getCalendar(@PathVariable Long id) {
return calendarRepository.findByIdWithDetails(id)
diff --git a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java
index 89c76eb..c37c283 100755
--- a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java
+++ b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java
@@ -10,11 +10,15 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.ArrayList;
+import java.util.Locale;
@RestController
@RequestMapping("/api/classrooms")
@@ -40,6 +44,26 @@ public class ClassroomController {
.toList();
}
+ @GetMapping("/options")
+ @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
+ public List searchClassroomOptions(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) Long selectedId,
+ @RequestParam(defaultValue = "20") int limit) {
+ String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
+ List options = new ArrayList<>(classroomRepository.searchActiveOptions(
+ normalizedQuery,
+ LifecycleEntity.STATUS_ARCHIVED,
+ PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("name").ascending())
+ ).stream()
+ .map(this::mapToResponse)
+ .toList());
+ if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
+ classroomRepository.findById(selectedId).map(this::mapToResponse).ifPresent(options::add);
+ }
+ return options;
+ }
+
@PostMapping
public ResponseEntity> createClassroom(@RequestBody ClassroomRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java
index 85eb0f5..4461115 100755
--- a/backend/src/main/java/com/magistr/app/controller/GroupController.java
+++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java
@@ -1,10 +1,13 @@
package com.magistr.app.controller;
+import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
+import com.magistr.app.dto.PageResponse;
+import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
@@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -98,6 +108,118 @@ public class GroupController {
}
}
+ @GetMapping("/page")
+ public PageResponse getGroupsPage(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) List educationFormId,
+ @RequestParam(required = false) Long departmentId,
+ @RequestParam(required = false) Integer yearStartStudy,
+ @RequestParam(defaultValue = "true") boolean includeArchived,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "25") int size,
+ @RequestParam(defaultValue = "name") String sort,
+ @RequestParam(defaultValue = "asc") String direction) {
+ Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
+ Specification specification = groupSpecification(
+ query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived
+ );
+ Page groups = groupRepository.findAll(
+ specification,
+ PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction))
+ );
+ return PageResponse.from(groups.map(this::mapToResponse));
+ }
+
+ @GetMapping("/options")
+ public List searchGroupOptions(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) Long departmentId,
+ @RequestParam(required = false) Long selectedId,
+ @RequestParam(defaultValue = "20") int limit) {
+ Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
+ List options = new ArrayList<>(groupRepository.findAll(
+ groupSpecification(query, null, effectiveDepartmentId, null, false),
+ PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("name").ascending())
+ ).stream()
+ .filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
+ .map(this::mapToResponse)
+ .toList());
+ if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
+ groupRepository.findById(selectedId)
+ .filter(group -> effectiveDepartmentId == null
+ || Objects.equals(group.getDepartmentId(), effectiveDepartmentId))
+ .filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
+ .map(this::mapToResponse)
+ .ifPresent(options::add);
+ }
+ return options;
+ }
+
+ private Long effectiveDepartmentId(Long requestedDepartmentId) {
+ var currentUser = AuthContext.getCurrentUser();
+ return currentUser != null && currentUser.role() == Role.DEPARTMENT
+ ? currentUser.departmentId()
+ : requestedDepartmentId;
+ }
+
+ private Specification groupSpecification(String query,
+ List educationFormIds,
+ Long departmentId,
+ Integer yearStartStudy,
+ boolean includeArchived) {
+ return (root, criteriaQuery, builder) -> {
+ List predicates = new ArrayList<>();
+ if (!includeArchived) {
+ predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
+ }
+ if (educationFormIds != null && !educationFormIds.isEmpty()) {
+ predicates.add(root.get("educationForm").get("id").in(educationFormIds));
+ }
+ if (departmentId != null) {
+ predicates.add(builder.equal(root.get("departmentId"), departmentId));
+ }
+ if (yearStartStudy != null) {
+ predicates.add(builder.equal(root.get("yearStartStudy"), yearStartStudy));
+ }
+ String normalizedQuery = normalizeQuery(query);
+ if (!normalizedQuery.isEmpty()) {
+ String pattern = "%" + normalizedQuery + "%";
+ predicates.add(builder.or(
+ builder.like(builder.lower(root.get("name")), pattern),
+ builder.like(builder.lower(root.get("speciality").get("specialityCode")), pattern),
+ builder.like(builder.lower(root.get("speciality").get("specialityName")), pattern),
+ builder.like(builder.lower(root.get("specialtyProfile").get("name")), pattern),
+ builder.like(builder.lower(root.get("educationForm").get("name")), pattern)
+ ));
+ }
+ return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
+ };
+ }
+
+ private Sort groupSort(String field, String direction) {
+ String property = switch (field) {
+ case "yearStartStudy" -> "yearStartStudy";
+ case "groupSize" -> "groupSize";
+ case "status" -> "status";
+ default -> "name";
+ };
+ return "desc".equalsIgnoreCase(direction)
+ ? Sort.by(property).descending()
+ : Sort.by(property).ascending();
+ }
+
+ private int normalizePage(int page) {
+ return Math.max(page, 0);
+ }
+
+ private int normalizeSize(int size) {
+ return Math.max(1, Math.min(size, 100));
+ }
+
+ private String normalizeQuery(String query) {
+ return query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
+ }
+
@GetMapping("/{departmentId}")
public ResponseEntity> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java
index 58abb1f..dfe63e6 100644
--- a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java
+++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java
@@ -12,10 +12,13 @@ import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+
@RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController {
@@ -36,8 +39,16 @@ public class SubgroupController {
}
@GetMapping("/api/subgroups")
- public List getAll() {
- return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
+ public List getAll(@RequestParam(required = false) List groupId) {
+ if (groupId != null && groupId.size() > 100) {
+ throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп");
+ }
+ List selected = groupId == null
+ ? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()
+ : groupId.isEmpty()
+ ? List.of()
+ : subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList());
+ return selected.stream()
.map(this::toDto)
.toList();
}
diff --git a/backend/src/main/java/com/magistr/app/controller/SubjectController.java b/backend/src/main/java/com/magistr/app/controller/SubjectController.java
index 019bb08..c76db33 100755
--- a/backend/src/main/java/com/magistr/app/controller/SubjectController.java
+++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java
@@ -1,8 +1,10 @@
package com.magistr.app.controller;
+import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
+import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
@@ -11,9 +13,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.*;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
@RestController
@@ -53,6 +60,74 @@ public class SubjectController {
}
}
+ @GetMapping("/page")
+ @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
+ public PageResponse getSubjectsPage(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) Long departmentId,
+ @RequestParam(defaultValue = "false") boolean includeArchived,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "25") int size,
+ @RequestParam(defaultValue = "name") String sort,
+ @RequestParam(defaultValue = "asc") String direction) {
+ Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
+ var subjects = subjectRepository.findAll(
+ subjectSpecification(query, effectiveDepartmentId, includeArchived),
+ PageRequest.of(
+ Math.max(page, 0),
+ Math.max(1, Math.min(size, 100)),
+ subjectSort(sort, direction)
+ )
+ );
+ return PageResponse.from(subjects.map(this::toResponse));
+ }
+
+ private Long effectiveDepartmentId(Long requestedDepartmentId) {
+ var currentUser = AuthContext.getCurrentUser();
+ return currentUser != null && currentUser.role() == Role.DEPARTMENT
+ ? currentUser.departmentId()
+ : requestedDepartmentId;
+ }
+
+ private Specification subjectSpecification(String query,
+ Long departmentId,
+ boolean includeArchived) {
+ return (root, criteriaQuery, builder) -> {
+ List predicates = new ArrayList<>();
+ if (!includeArchived) {
+ predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
+ }
+ if (departmentId != null) {
+ predicates.add(builder.equal(root.get("departmentId"), departmentId));
+ }
+ String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
+ if (!normalizedQuery.isEmpty()) {
+ String pattern = "%" + normalizedQuery + "%";
+ predicates.add(builder.or(
+ builder.like(builder.lower(root.get("name")), pattern),
+ builder.like(builder.lower(root.get("code")), pattern)
+ ));
+ }
+ return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
+ };
+ }
+
+ private Sort subjectSort(String field, String direction) {
+ String property = switch (field) {
+ case "code" -> "code";
+ case "departmentId" -> "departmentId";
+ case "status" -> "status";
+ default -> "name";
+ };
+ return "desc".equalsIgnoreCase(direction)
+ ? Sort.by(property).descending()
+ : Sort.by(property).ascending();
+ }
+
+ private SubjectResponse toResponse(Subject subject) {
+ return new SubjectResponse(subject.getId(), subject.getName(), subject.getCode(), subject.getDepartmentId());
+ }
+
@GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java
index e462b66..d378024 100644
--- a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java
+++ b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java
@@ -5,6 +5,7 @@ import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
+import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
@@ -12,11 +13,16 @@ import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.http.ResponseEntity;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
@RestController
@@ -55,6 +61,63 @@ public class TeacherCreationRequestController {
.toList();
}
+ @GetMapping("/page")
+ public PageResponse getRequestsPage(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) TeacherCreationRequestStatus status,
+ @RequestParam(required = false) Long departmentId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "25") int size,
+ @RequestParam(defaultValue = "createdAt") String sort,
+ @RequestParam(defaultValue = "desc") String direction) {
+ var requests = teacherCreationRequestRepository.findAll(
+ requestSpecification(query, status, departmentId),
+ PageRequest.of(
+ Math.max(page, 0),
+ Math.max(1, Math.min(size, 100)),
+ requestSort(sort, direction)
+ )
+ );
+ return PageResponse.from(requests.map(this::toResponse));
+ }
+
+ private Specification requestSpecification(String query,
+ TeacherCreationRequestStatus status,
+ Long departmentId) {
+ return (root, criteriaQuery, builder) -> {
+ List predicates = new ArrayList<>();
+ if (status != null) {
+ predicates.add(builder.equal(root.get("status"), status));
+ }
+ if (departmentId != null) {
+ predicates.add(builder.equal(root.get("department").get("id"), departmentId));
+ }
+ String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
+ if (!normalizedQuery.isEmpty()) {
+ String pattern = "%" + normalizedQuery + "%";
+ predicates.add(builder.or(
+ builder.like(builder.lower(root.get("username")), pattern),
+ builder.like(builder.lower(root.get("fullName")), pattern),
+ builder.like(builder.lower(root.get("jobTitle")), pattern),
+ builder.like(builder.lower(root.get("department").get("departmentName")), pattern)
+ ));
+ }
+ return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
+ };
+ }
+
+ private Sort requestSort(String field, String direction) {
+ String property = switch (field) {
+ case "fullName" -> "fullName";
+ case "username" -> "username";
+ case "status" -> "status";
+ default -> "createdAt";
+ };
+ return "asc".equalsIgnoreCase(direction)
+ ? Sort.by(property).ascending()
+ : Sort.by(property).descending();
+ }
+
@PostMapping("/{id}/approve")
@Transactional
public ResponseEntity> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java
index a844c86..ea88a7a 100755
--- a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java
+++ b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java
@@ -16,10 +16,13 @@ import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+
@RestController
@RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
@@ -53,15 +56,18 @@ public class TeacherSubjectController {
}
@GetMapping
- public List getAll() {
- return teacherSubjectRepository.findAll().stream()
+ public List getAll(@RequestParam(required = false) List subjectId) {
+ if (subjectId != null && subjectId.size() > 100) {
+ throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин");
+ }
+ List assignments = subjectId == null
+ ? teacherSubjectRepository.findAll()
+ : subjectId.isEmpty()
+ ? List.of()
+ : teacherSubjectRepository.findBySubjectIdInWithDetails(subjectId.stream().distinct().toList());
+ return assignments.stream()
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
- .map(ts -> new TeacherSubjectResponse(
- ts.getUserId(),
- ts.getUser().getUsername(),
- ts.getSubjectId(),
- ts.getSubject().getName()
- ))
+ .map(this::toResponse)
.toList();
}
@@ -136,4 +142,14 @@ public class TeacherSubjectController {
)
&& departmentId.equals(subject.getDepartmentId());
}
+
+ private TeacherSubjectResponse toResponse(TeacherSubject teacherSubject) {
+ return new TeacherSubjectResponse(
+ teacherSubject.getUserId(),
+ teacherSubject.getUser().getUsername(),
+ teacherSubject.getUser().getFullName(),
+ teacherSubject.getSubjectId(),
+ teacherSubject.getSubject().getName()
+ );
+ }
}
diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java
index 80a1ff9..9eecb8f 100755
--- a/backend/src/main/java/com/magistr/app/controller/UserController.java
+++ b/backend/src/main/java/com/magistr/app/controller/UserController.java
@@ -6,6 +6,7 @@ import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
import com.magistr.app.dto.UserResponse;
+import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
@@ -22,11 +23,16 @@ import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
@RestController
@@ -80,6 +86,23 @@ public class UserController {
return response;
}
+ @GetMapping("/page")
+ public PageResponse getUsersPage(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) Role role,
+ @RequestParam(required = false) Long departmentId,
+ @RequestParam(defaultValue = "true") boolean includeArchived,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "25") int size,
+ @RequestParam(defaultValue = "fullName") String sort,
+ @RequestParam(defaultValue = "asc") String direction) {
+ var users = userRepository.findAll(
+ userSpecification(query, role, departmentId, includeArchived),
+ PageRequest.of(normalizePage(page), normalizeSize(size), userSort(sort, direction))
+ );
+ return PageResponse.from(users.map(this::toUserResponse));
+ }
+
@GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List getTeachers() {
@@ -91,6 +114,92 @@ public class UserController {
return response;
}
+ @GetMapping("/teachers/options")
+ @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
+ public List searchTeacherOptions(
+ @RequestParam(defaultValue = "") String query,
+ @RequestParam(required = false) Long departmentId,
+ @RequestParam(required = false) Long selectedId,
+ @RequestParam(defaultValue = "20") int limit) {
+ var currentUser = AuthContext.getCurrentUser();
+ Long effectiveDepartmentId = currentUser != null && currentUser.role() == Role.DEPARTMENT
+ ? currentUser.departmentId()
+ : departmentId;
+ LocalDate today = businessTime.today();
+ List options = new ArrayList<>(userRepository.searchTeacherOptions(
+ query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
+ Role.TEACHER,
+ LifecycleEntity.STATUS_ARCHIVED,
+ effectiveDepartmentId,
+ today,
+ PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("fullName").ascending())
+ ).stream()
+ .map(user -> effectiveDepartmentId == null
+ ? toUserResponse(user)
+ : toUserResponse(user, effectiveDepartmentId))
+ .toList());
+ if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
+ userRepository.findById(selectedId)
+ .filter(user -> user.getRole() == Role.TEACHER)
+ .filter(user -> !user.isArchivedRecord())
+ .filter(user -> effectiveDepartmentId == null
+ || teacherDepartmentService.hasAssignmentAtDate(user.getId(), effectiveDepartmentId, today))
+ .map(user -> effectiveDepartmentId == null
+ ? toUserResponse(user)
+ : toUserResponse(user, effectiveDepartmentId))
+ .ifPresent(options::add);
+ }
+ return options;
+ }
+
+ private Specification userSpecification(String query,
+ Role role,
+ Long departmentId,
+ boolean includeArchived) {
+ return (root, criteriaQuery, builder) -> {
+ List predicates = new ArrayList<>();
+ if (!includeArchived) {
+ predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
+ }
+ if (role != null) {
+ predicates.add(builder.equal(root.get("role"), role));
+ }
+ if (departmentId != null) {
+ predicates.add(builder.equal(root.get("departmentId"), departmentId));
+ }
+ String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
+ if (!normalizedQuery.isEmpty()) {
+ String pattern = "%" + normalizedQuery + "%";
+ predicates.add(builder.or(
+ builder.like(builder.lower(root.get("username")), pattern),
+ builder.like(builder.lower(root.get("fullName")), pattern),
+ builder.like(builder.lower(root.get("jobTitle")), pattern)
+ ));
+ }
+ return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
+ };
+ }
+
+ private Sort userSort(String field, String direction) {
+ String property = switch (field) {
+ case "username" -> "username";
+ case "role" -> "role";
+ case "status" -> "status";
+ default -> "fullName";
+ };
+ return "desc".equalsIgnoreCase(direction)
+ ? Sort.by(property).descending()
+ : Sort.by(property).ascending();
+ }
+
+ private int normalizePage(int page) {
+ return Math.max(page, 0);
+ }
+
+ private int normalizeSize(int size) {
+ return Math.max(1, Math.min(size, 100));
+ }
+
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity> getTeachersByDepartmentId(@PathVariable Long departmentId){
diff --git a/backend/src/main/java/com/magistr/app/dto/PageResponse.java b/backend/src/main/java/com/magistr/app/dto/PageResponse.java
new file mode 100644
index 0000000..d64bdea
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/dto/PageResponse.java
@@ -0,0 +1,27 @@
+package com.magistr.app.dto;
+
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+public record PageResponse(
+ List items,
+ int page,
+ int size,
+ long totalItems,
+ int totalPages,
+ boolean first,
+ boolean last
+) {
+ public static PageResponse from(Page page) {
+ return new PageResponse<>(
+ page.getContent(),
+ page.getNumber(),
+ page.getSize(),
+ page.getTotalElements(),
+ page.getTotalPages(),
+ page.isFirst(),
+ page.isLast()
+ );
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java b/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java
index c3c4e93..b129295 100755
--- a/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java
+++ b/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java
@@ -4,12 +4,14 @@ public class TeacherSubjectResponse {
private Long userId;
private String username;
+ private String fullName;
private Long subjectId;
private String subjectName;
- public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
+ public TeacherSubjectResponse(Long userId, String username, String fullName, Long subjectId, String subjectName) {
this.userId = userId;
this.username = username;
+ this.fullName = fullName;
this.subjectId = subjectId;
this.subjectName = subjectName;
}
@@ -22,6 +24,10 @@ public class TeacherSubjectResponse {
return username;
}
+ public String getFullName() {
+ return fullName;
+ }
+
public Long getSubjectId() {
return subjectId;
}
diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java
index 51ee39d..caa0d39 100644
--- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java
@@ -3,14 +3,17 @@ package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendar;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
-public interface AcademicCalendarRepository extends JpaRepository {
+public interface AcademicCalendarRepository extends JpaRepository, JpaSpecificationExecutor {
long countByStudyFormId(Long studyFormId);
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository findByIdWithDetailsForUpdate(@Param("id") Long id);
+
+ @Query(value = """
+ select calendar
+ from AcademicCalendar calendar
+ join fetch calendar.academicYear academicYear
+ join fetch calendar.speciality speciality
+ join fetch calendar.specialtyProfile specialtyProfile
+ join fetch calendar.studyForm studyForm
+ where (:query = ''
+ or lower(calendar.title) like concat('%', :query, '%')
+ or lower(academicYear.title) like concat('%', :query, '%')
+ or lower(speciality.specialityCode) like concat('%', :query, '%')
+ or lower(speciality.specialityName) like concat('%', :query, '%')
+ or lower(specialtyProfile.name) like concat('%', :query, '%')
+ or lower(studyForm.name) like concat('%', :query, '%'))
+ and (:academicYearId is null or academicYear.id = :academicYearId)
+ and (:specialtyId is null or speciality.id = :specialtyId)
+ and (:profileId is null or specialtyProfile.id = :profileId)
+ and (:studyFormId is null or studyForm.id = :studyFormId)
+ """,
+ countQuery = """
+ select count(calendar)
+ from AcademicCalendar calendar
+ join calendar.academicYear academicYear
+ join calendar.speciality speciality
+ join calendar.specialtyProfile specialtyProfile
+ join calendar.studyForm studyForm
+ where (:query = ''
+ or lower(calendar.title) like concat('%', :query, '%')
+ or lower(academicYear.title) like concat('%', :query, '%')
+ or lower(speciality.specialityCode) like concat('%', :query, '%')
+ or lower(speciality.specialityName) like concat('%', :query, '%')
+ or lower(specialtyProfile.name) like concat('%', :query, '%')
+ or lower(studyForm.name) like concat('%', :query, '%'))
+ and (:academicYearId is null or academicYear.id = :academicYearId)
+ and (:specialtyId is null or speciality.id = :specialtyId)
+ and (:profileId is null or specialtyProfile.id = :profileId)
+ and (:studyFormId is null or studyForm.id = :studyFormId)
+ """)
+ Page searchOptions(@Param("query") String query,
+ @Param("academicYearId") Long academicYearId,
+ @Param("specialtyId") Long specialtyId,
+ @Param("profileId") Long profileId,
+ @Param("studyFormId") Long studyFormId,
+ Pageable pageable);
}
diff --git a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java
index c96cb49..98004c8 100755
--- a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java
@@ -2,12 +2,29 @@ package com.magistr.app.repository;
import com.magistr.app.model.Classroom;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import java.util.Optional;
import java.util.List;
-public interface ClassroomRepository extends JpaRepository {
+public interface ClassroomRepository extends JpaRepository, JpaSpecificationExecutor {
Optional findByName(String name);
List findByStatusNot(String status);
+
+ @Query("""
+ select classroom
+ from Classroom classroom
+ where classroom.status <> :archivedStatus
+ and (:query = ''
+ or lower(classroom.name) like concat('%', :query, '%')
+ or lower(coalesce(classroom.building, '')) like concat('%', :query, '%'))
+ """)
+ Page searchActiveOptions(@Param("query") String query,
+ @Param("archivedStatus") String archivedStatus,
+ Pageable pageable);
}
diff --git a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java
index 4cdfe96..c9fd58b 100755
--- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java
@@ -3,6 +3,7 @@ package com.magistr.app.repository;
import com.magistr.app.model.StudentGroup;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -10,7 +11,7 @@ import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
-public interface GroupRepository extends JpaRepository {
+public interface GroupRepository extends JpaRepository, JpaSpecificationExecutor {
List findByEducationFormId(Long educationFormId);
diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java
index a1524d9..5646a09 100644
--- a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java
@@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository {
""")
List findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
+ @Query("""
+ select subgroup
+ from Subgroup subgroup
+ join fetch subgroup.studentGroup studentGroup
+ where studentGroup.id in :groupIds
+ and subgroup.status <> 'ARCHIVED'
+ order by studentGroup.name, subgroup.name
+ """)
+ List findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List groupIds);
+
@Query("""
select subgroup
from Subgroup subgroup
diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java
index bf942d0..c511239 100755
--- a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java
@@ -2,11 +2,12 @@ package com.magistr.app.repository;
import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
-public interface SubjectRepository extends JpaRepository {
+public interface SubjectRepository extends JpaRepository, JpaSpecificationExecutor {
Optional findByNameIgnoreCase(String name);
List findByDepartmentId(Long departmentId);
diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java
index 44dfaec..ae70ed7 100644
--- a/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java
@@ -3,10 +3,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.TeacherCreationRequest;
import com.magistr.app.model.TeacherCreationRequestStatus;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
-public interface TeacherCreationRequestRepository extends JpaRepository {
+public interface TeacherCreationRequestRepository extends JpaRepository, JpaSpecificationExecutor {
List findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java
index 05c439c..af4106a 100755
--- a/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java
@@ -12,6 +12,16 @@ public interface TeacherSubjectRepository extends JpaRepository findByUserId(Long userId);
List findBySubjectId(Long subjectId);
+ @Query("""
+ select teacherSubject
+ from TeacherSubject teacherSubject
+ join fetch teacherSubject.user
+ join fetch teacherSubject.subject
+ where teacherSubject.subjectId in :subjectIds
+ order by teacherSubject.subject.name, teacherSubject.user.fullName
+ """)
+ List findBySubjectIdInWithDetails(@Param("subjectIds") List subjectIds);
+
@Query(value = """
select subject_link.user_id
from teacher_subjects subject_link
diff --git a/backend/src/main/java/com/magistr/app/repository/UserRepository.java b/backend/src/main/java/com/magistr/app/repository/UserRepository.java
index 95e9700..fa52870 100755
--- a/backend/src/main/java/com/magistr/app/repository/UserRepository.java
+++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java
@@ -4,14 +4,18 @@ import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
-public interface UserRepository extends JpaRepository {
+public interface UserRepository extends JpaRepository, JpaSpecificationExecutor {
Optional findByUsername(String username);
@@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository {
List findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
+ @Query(value = """
+ select user
+ from User user
+ where user.role = :role
+ and user.status <> :archivedStatus
+ and (:query = ''
+ or lower(user.username) like concat('%', :query, '%')
+ or lower(user.fullName) like concat('%', :query, '%')
+ or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
+ and (:departmentId is null or exists (
+ select assignment.id
+ from TeacherDepartmentAssignment assignment
+ where assignment.teacher.id = user.id
+ and assignment.department.id = :departmentId
+ and assignment.validFrom <= :date
+ and (assignment.validTo is null or assignment.validTo >= :date)
+ ))
+ """,
+ countQuery = """
+ select count(user)
+ from User user
+ where user.role = :role
+ and user.status <> :archivedStatus
+ and (:query = ''
+ or lower(user.username) like concat('%', :query, '%')
+ or lower(user.fullName) like concat('%', :query, '%')
+ or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
+ and (:departmentId is null or exists (
+ select assignment.id
+ from TeacherDepartmentAssignment assignment
+ where assignment.teacher.id = user.id
+ and assignment.department.id = :departmentId
+ and assignment.validFrom <= :date
+ and (assignment.validTo is null or assignment.validTo >= :date)
+ ))
+ """)
+ Page searchTeacherOptions(@Param("query") String query,
+ @Param("role") Role role,
+ @Param("archivedStatus") String archivedStatus,
+ @Param("departmentId") Long departmentId,
+ @Param("date") LocalDate date,
+ Pageable pageable);
+
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from User user where user.id = :id")
Optional findByIdForUpdate(@Param("id") Long id);
diff --git a/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java b/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java
index 64e4bcb..11bab83 100644
--- a/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java
+++ b/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java
@@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -74,6 +75,52 @@ class TeacherSubjectControllerTest {
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
}
+ @Test
+ void returnsAssignmentsOnlyForRequestedSubjects() {
+ TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class);
+ UserRepository userRepository = mock(UserRepository.class);
+ SubjectRepository subjectRepository = mock(SubjectRepository.class);
+ TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
+ User teacher = teacher();
+ Subject subject = new Subject();
+ subject.setId(20L);
+ subject.setName("Высшая математика");
+ TeacherSubject assignment = new TeacherSubject(teacher.getId(), subject.getId());
+ setTeacherSubjectDetails(assignment, teacher, subject);
+ when(teacherSubjectRepository.findBySubjectIdInWithDetails(List.of(subject.getId())))
+ .thenReturn(List.of(assignment));
+ TeacherSubjectController controller = new TeacherSubjectController(
+ teacherSubjectRepository,
+ userRepository,
+ subjectRepository,
+ teacherDepartmentService,
+ BUSINESS_TIME
+ );
+ AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
+
+ var result = controller.getAll(List.of(subject.getId()));
+
+ assertThat(result).singleElement().satisfies(response -> {
+ assertThat(response.getSubjectId()).isEqualTo(subject.getId());
+ assertThat(response.getUserId()).isEqualTo(teacher.getId());
+ assertThat(response.getFullName()).isEqualTo(teacher.getFullName());
+ });
+ verify(teacherSubjectRepository).findBySubjectIdInWithDetails(List.of(subject.getId()));
+ }
+
+ private void setTeacherSubjectDetails(TeacherSubject assignment, User teacher, Subject subject) {
+ try {
+ var userField = TeacherSubject.class.getDeclaredField("user");
+ userField.setAccessible(true);
+ userField.set(assignment, teacher);
+ var subjectField = TeacherSubject.class.getDeclaredField("subject");
+ subjectField.setAccessible(true);
+ subjectField.set(assignment, subject);
+ } catch (ReflectiveOperationException exception) {
+ throw new AssertionError("Не удалось подготовить тестовую привязку", exception);
+ }
+ }
+
private User teacher() {
User teacher = new User();
teacher.setId(10L);
diff --git a/docs/API.md b/docs/API.md
index e3b63cc..4fd045f 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -230,10 +230,39 @@ Refresh-токен ротируется при каждом успешном о
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
+### `GET /api/users/page`
+
+Пагинированный реестр пользователей для администратора. Параметры: `query`, `role`,
+`departmentId`, `includeArchived`, `page`, `size`, `sort`, `direction`. Размер страницы
+ограничен диапазоном `1..100`; поддерживаемые поля сортировки — `fullName`, `username`,
+`role`, `status`.
+
+Пагинированные endpoints используют общий ответ:
+
+```json
+{
+ "items": [],
+ "page": 0,
+ "size": 25,
+ "totalItems": 0,
+ "totalPages": 0,
+ "first": true,
+ "last": true
+}
+```
+
### `GET /api/users/teachers`
Список только преподавателей (роль `TEACHER`).
+### `GET /api/users/teachers/options`
+
+Серверный поиск преподавателей для асинхронных списков. Параметры: `query`,
+`departmentId`, `selectedId`, `limit` (`1..50`). Поиск выполняется по логину, ФИО и
+должности; архивные записи исключаются. Для роли `DEPARTMENT` backend принудительно
+использует кафедру текущего пользователя и учитывает действующие основные и дополнительные
+назначения из `teacher_department_assignments`. `selectedId` не обходит это ограничение.
+
### `GET /api/users/teachers/{departmentId}`
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
@@ -309,6 +338,7 @@ Refresh-токен ротируется при каждом успешном о
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
+| `GET` | `/api/teacher-requests/page?query=&status=PENDING&page=0&size=25` | Пагинированный поиск заявок по логину, ФИО, должности и кафедре |
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
@@ -567,6 +597,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
+| `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса |
| `GET` | `/api/admin/academic-calendars/{id}` | Один график |
| `POST` | `/api/admin/academic-calendars` | Создать график |
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
@@ -593,6 +624,11 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
Отдельного пользовательского названия у календарного графика нет.
+`GET /api/admin/academic-calendars/options` принимает `query`, `academicYearId`,
+`specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск
+выполняется по составному названию, учебному году, коду/названию специальности, профилю и
+форме обучения. Фасетные параметры позволяют не показывать группе несовместимые графики.
+
`POST /api/admin/academic-calendars` создаёт график и начальную сетку одной транзакцией.
Для каждого курса из диапазона `1..courseCount` backend сохраняет один период с кодом `Т`
на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после
@@ -1305,6 +1341,21 @@ payload обрабатываются один раз, используется
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
+### `GET /api/groups/page`
+
+Пагинированный реестр групп. Параметры: `query`, повторяемый `educationFormId`,
+`departmentId`, `yearStartStudy`, `includeArchived`, `page`, `size`, `sort`, `direction`.
+Поддерживаемые поля сортировки: `name`, `yearStartStudy`, `groupSize`, `status`. Поиск
+проверяет название группы, код и название специальности, профиль и форму обучения. Для
+роли `DEPARTMENT` кафедра всегда берётся из JWT-контекста независимо от переданного фильтра.
+
+### `GET /api/groups/options`
+
+Асинхронный поиск активных групп. Параметры: `query`, `departmentId`, `selectedId`, `limit`
+(`1..50`). Завершившие обучение и архивные группы исключаются. Для роли `DEPARTMENT`
+действует то же принудительное ограничение своей кафедрой, включая восстановление выбранного
+`selectedId`.
+
**Ответ:**
```json
[
@@ -1408,7 +1459,7 @@ legacy-alias для старых клиентов и исторически со
| Метод | URL | Назначение |
|-------|-----|------------|
-| `GET` | `/api/subgroups` | Список всех подгрупп |
+| `GET` | `/api/subgroups?groupId=1&groupId=2` | Подгруппы выбранных групп; без параметра — совместимый список всех подгрупп, не более 100 ID групп за запрос |
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
@@ -1486,6 +1537,12 @@ legacy-alias для старых клиентов и исторически со
Список аудиторий с привязанным оборудованием.
+### `GET /api/classrooms/options`
+
+Асинхронный поиск доступных неархивных аудиторий по названию и корпусу. Параметры:
+`query`, `selectedId`, `limit` (`1..50`). Выбранная запись добавляется в результат, если
+она не попала в первую страницу поиска.
+
**Ответ:**
```json
[
@@ -1539,6 +1596,12 @@ legacy-alias для старых клиентов и исторически со
Список всех дисциплин.
+### `GET /api/subjects/page`
+
+Пагинированный реестр дисциплин. Параметры: `query`, `departmentId`, `includeArchived`,
+`page`, `size`, `sort`, `direction`; сортировка разрешена по `name`, `code`,
+`departmentId`, `status`. Роль `DEPARTMENT` всегда ограничивается своей кафедрой на backend.
+
```json
{
"name": "Физика",
@@ -1620,14 +1683,17 @@ legacy-alias для старых клиентов и исторически со
### `GET /api/teacher-subjects`
-Список всех привязок.
+Список привязок. Повторяемый параметр `subjectId` ограничивает результат указанными
+дисциплинами; за один запрос принимается не более 100 ID. Без параметра сохраняется
+совместимый режим получения всех доступных текущей роли привязок.
**Ответ:**
```json
[
{
"userId": 2,
- "userName": "Тестовый преподаватель",
+ "username": "teacher2",
+ "fullName": "Тестовый преподаватель",
"subjectId": 1,
"subjectName": "Высшая математика"
}
diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index a5d8ced..f5e0439 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -20,6 +20,8 @@ frontend/
├── package-lock.json # Зафиксированное npm-дерево
├── auth-session.js # Access JWT и профиль сессии только в памяти вкладки
├── telemetry.js # Same-origin загрузчик собранного OTel bundle
+├── schedule-overview.js # Общий расчёт и рендер «Сегодня / Следующая пара»
+├── schedule-overview.css # Общие карточки ближайших занятий и поискового выбора группы
├── security.conf # CSP и защитные HTTP-заголовки Apache
├── telemetry/
│ └── otel-entry.js # Исходная точка сборки OpenTelemetry
@@ -29,6 +31,7 @@ frontend/
│ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика
│ ├── academic-calendar-title.test.mjs # Составное название календарного графика
│ ├── academic-calendar-tooltip.test.mjs # Позиционирование подсказки возле курсора и границ viewport
+│ ├── schedule-overview.test.mjs # Текущее занятие, список на сегодня и следующая пара
│ ├── admin-ui-regressions.test.mjs # Заголовки, меню, действия слотов и компоновка вкладок
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
@@ -63,6 +66,13 @@ frontend/
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO
+│ │ ├── async-combobox.js # Асинхронный поиск больших справочников
+│ │ ├── dialog.js # Доступные подтверждения, сообщения и ввод причины
+│ │ ├── dirty-state.js # Защита несохранённых изменений
+│ │ ├── pagination.js # Общая панель серверной пагинации
+│ │ ├── url-state.js # Чтение и запись фильтров в query string
+│ │ ├── view-state.js # Единые loading/empty/error-состояния таблиц
+│ │ ├── academic-period.js # Каскад учебного года и семестра в профильных вкладках
│ │ ├── utils.js # Утилиты
│ │ ├── schedule-period.js # Выбор ближайшего учебного периода для расписаний и нагрузки
│ │ └── views/ # Модули представлений
@@ -172,19 +182,19 @@ frontend/
| Tab | Описание | API |
|-----|----------|-----|
| `dashboard` | Сводные метрики и проверка конфликтов текущей недели с явным статусом полноты данных | `/api/departments`, `/api/classrooms`, `/api/groups`, `/api/users/teachers`, `/api/schedule/search`, `/api/admin/time-slots` |
-| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
-| `users` | CRUD пользователей | `/api/users` |
-| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
+| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с поиском, статусом, пагинацией и редактированием перед одобрением | `/api/teacher-requests/page`, `/api/departments` |
+| `users` | CRUD пользователей с серверными поиском, ролевым фильтром, сортировкой и пагинацией | `/api/users/page` |
+| `groups` | CRUD групп, серверный поиск и пагинация, мультифильтр по формам обучения, настройка 0/2/3 подгрупп и поиск совместимого графика | `/api/groups/page`, `/api/groups/options`, `/api/admin/academic-calendars/options`, `/api/groups/{id}/subgroups` |
| `classrooms` | Аудитории | `/api/classrooms` |
-| `subjects` | Дисциплины | `/api/subjects` |
+| `subjects` | Дисциплины с серверным реестром, привязками только для текущей страницы и асинхронным выбором преподавателя | `/api/subjects/page`, `/api/teacher-subjects?subjectId=…`, `/api/users/teachers/options` |
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
-| `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` |
-| `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers` |
-| `schedule` | Конструктор правил: по умолчанию редактирует опубликованное расписание, позволяет переключиться на черновик или создать его из выбранной версии; содержит подсказки пожеланий и визуальную матрицу групп | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
-| `schedule-versions` | Контур публикации: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` |
-| `schedule-quality` | Диагностика выбранной версии семестра: оценка, метрики и проблемные правила с переходом к их редактированию | `/api/edu-office/schedule/quality`, `/api/admin/schedule-rules` |
-| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
+| `schedule-view` | Просмотр опубликованного расписания: учебный год и семестр выбираются каскадом в дополнительных фильтрах, поисковые списки сущностей при открытии остаются свёрнутыми и используют размытый непрозрачный слой меню, найденные расписания переключаются без общего контекста страницы, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` |
+| `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену; преподаватель ищется асинхронно по всем кафедрам для `ADMIN`/`EDUCATION_OFFICE` и только по своей кафедре для `DEPARTMENT`; поисковое меню размывает фон, а фильтры согласования оформлены для светлой и тёмной тем | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers/options` |
+| `schedule` | Конструктор правил: локальный каскад `Учебный год → Семестр → Версия` открывает опубликованное расписание или черновик; первичная загрузка скрывает нестабильную компоновку до подготовки полей, а таблица правил использует фиксированные колонки; группы, преподаватели и аудитории ищутся на сервере, подгруппы загружаются только для выбранных групп; предупреждение о несохранённых изменениях появляется только после действия пользователя | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/groups/options`, `/api/subgroups?groupId=…`, `/api/users/teachers/options`, `/api/classrooms/options` |
+| `schedule-versions` | Контур публикации с локальным выбором учебного года и семестра: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` |
+| `schedule-quality` | Диагностика выбранной версии через каскад `Учебный год → Семестр → Версия`: оценка, метрики и проблемные правила с переходом к их редактированию | `/api/edu-office/schedule/quality`, `/api/admin/schedule-rules` |
+| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика; редко используемая форма создания учебного года расположена внизу вкладки `Графики` | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
### Особенности админских вкладок
@@ -206,10 +216,12 @@ frontend/
расположены реестр пожеланий на семестр и очередь заявок на изменение опубликованных пар.
Пожелание можно принять или отклонить с комментарием. Заявку применяют только
`ADMIN` и `EDUCATION_OFFICE`; кафедра видит записи своих преподавателей без управляющих
- действий. Каждая карточка показывает исходное и запрошенное состояние, результат
+ действий. Фильтры обоих реестров используют тематические кастомные списки с отдельными
+ поверхностями для светлой и тёмной тем, а поиск преподавателя — общее размытое меню
+ `AsyncCombobox`. Каждая карточка показывает исходное и запрошенное состояние, результат
предварительной проверки и хронологию решения.
-- Вкладка `schedule-quality` доступна администратору и учебному отделу. После выбора
- семестра и версии она выводит круговую оценку от 0 до 100, карточки метрик и реестр правил
+- Вкладка `schedule-quality` доступна администратору и учебному отделу. После каскадного
+ выбора учебного года, семестра и версии она выводит круговую оценку от 0 до 100, карточки метрик и реестр правил
с фильтрами по серьёзности и типу. Все проблемные занятия одного правила показаны одной
карточкой; в ней выводятся число затронутых занятий, категории и суммарный штраф. Кнопка
`Изменить правило` открывает выбранную версию в конструкторе и сразу заполняет форму этим
@@ -217,9 +229,11 @@ frontend/
чтения. Переход между вкладками передаёт ID версии и правила через одноразовые ключи
`magistr.schedule.openVersionId` и `magistr.schedule.openRuleId` в `localStorage`.
- Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как
- отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные
+ отдельный контур публикации. В верхней карточке учебный год сначала ограничивает список
+ семестров, поэтому архив периодов не превращается в один длинный селект. Карточка показывает версию, которую видят конечные
пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал.
- В конструкторе опубликованная версия текущего семестра выбирается по умолчанию и выделяется
+ В конструкторе локальный каскад `Учебный год → Семестр → Версия` по умолчанию выбирает
+ опубликованную версию текущего семестра и выделяет её
зелёным контуром; её изменения применяются сразу. Селект позволяет перейти в медный режим
черновика. Кнопка `Создать черновик` копирует выбранное опубликованное расписание или
черновик и сразу открывает новую рабочую копию. Перед публикацией интерфейс
@@ -227,18 +241,47 @@ frontend/
при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор
или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
-- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. При загрузке автоматически выбирается текущий семестр, а между учебными периодами — ближайший будущий; если будущего нет, используется последний завершённый. Для выбранного семестра frontend запрашивает весь период от его первой до последней даты и собирает повторяющиеся занятия в одну семестровую матрицу; режим «По текущей дате» сохраняет двухнедельный диапазон. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается действующей кафедральной связью преподавателя на дату занятия; группы другой кафедры не отбрасываются, если занятие ведёт преподаватель текущей кафедры. Преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Каждая карточка занятия явно показывает фактические границы недель: `(с 1 по 18 нед.)` либо `(7 нед.)`. На мобильной ширине вместо широкой матрицы показывается один день активного расписания с переключателем дней.
-- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
+- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; поисковые меню группы, преподавателя и аудитории используют плотный фон с `backdrop-filter`, чтобы строки не смешивались с карточками под списком. Основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах учебный год каскадно ограничивает список семестров из `/api/schedule/semesters`; оба значения сохраняются в URL. При загрузке автоматически выбирается текущий семестр, а между учебными периодами — ближайший будущий; если будущего нет, используется последний завершённый. Экран всегда показывает опубликованную версию: выбор черновика остаётся в `schedule-quality` и `schedule`. Для выбранного семестра frontend запрашивает весь период от его первой до последней даты и собирает повторяющиеся занятия в одну семестровую матрицу. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается действующей кафедральной связью преподавателя на дату занятия; группы другой кафедры не отбрасываются, если занятие ведёт преподаватель текущей кафедры. Преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Каждая карточка занятия явно показывает фактические границы недель: `(с 1 по 18 нед.)` либо `(7 нед.)`. На мобильной ширине вместо широкой матрицы показывается один день активного расписания с переключателем дней.
+- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Преподаватель и аудитория ищутся асинхронно через `/api/users/teachers/options` и `/api/classrooms/options`; в подписи аудитории показывается её название. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
- Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
-- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню возле нажатой кнопки, с автоматическим разворотом от границ viewport: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
-- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Учебный год создаётся одной формой вместе с обязательными осенним и весенним семестрами: пользователь вводит только четырёхзначный год начала, например `2026`; интерфейс подставляет год окончания `2027`, формирует название `2026-2027` и внутренний период `01.09.2026–30.06.2027`. Границы семестров можно скорректировать до сохранения. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` календарного графика в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Каждый код активности окрашивает ячейку, подсказку, диалог и итоговый чип своим `colorCode`. REST-контракт сетки остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`; два поля учебного года принимают и показывают только четыре цифры.
+- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. До завершения первичной загрузки справочников и правил вкладка показывает единое состояние загрузки, после чего открывает уже подготовленную форму; таблица правил использует `table-layout: fixed`, явный `colgroup` и отключённую анимацию строк, поэтому заголовки колонок не смещаются при замене состояния загрузки данными. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню возле нажатой кнопки, с автоматическим разворотом от границ viewport: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
+- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Форма редкого ежегодного действия — создания учебного года вместе с семестрами — расположена после реестров учебных годов и календарных графиков, в нижней части вкладки `Графики`. Пользователь вводит только четырёхзначный год начала, например `2026`; интерфейс подставляет год окончания `2027`, формирует название `2026-2027` и внутренний период `01.09.2026–30.06.2027`. Границы семестров можно скорректировать до сохранения. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` календарного графика в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Каждый код активности окрашивает ячейку, подсказку, диалог и итоговый чип своим `colorCode`. REST-контракт сетки остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`; два поля учебного года принимают и показывают только четыре цифры.
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
+### Общие UX-компоненты первого пакета
+
+- Общей панели учебного контекста под topbar нет. Каскадные селекты периода размещены только
+ в профильных вкладках: `schedule`, `schedule-versions`, `schedule-quality` и
+ `schedule-view`. Календарный график и назначение графика группе сохраняют собственные
+ локальные селекты.
+- Вкладка и фильтры сохраняются в URL. Реестры групп, пользователей, дисциплин и заявок
+ восстанавливают поиск, фильтры, сортировку, номер и размер страницы после reload или
+ возврата по ссылке. Просмотр расписания сохраняет вид сущности, выбранную сущность, дату,
+ учебный год, семестр и дополнительные фильтры; конечные кабинеты сохраняют неделю, вкладку и группу.
+- `pagination.js` работает с общим ответом `PageResponse`; размер страницы — 25, 50 или
+ 100. `view-state.js` унифицирует загрузку со spinner, пустой результат и ошибку с кнопкой
+ повторного запроса.
+- `AsyncCombobox` загружает варианты с сервера, сохраняет выбранный ID, поддерживает
+ клавиатуру и роли `combobox/listbox`. Он используется для групп, преподавателей,
+ аудиторий и совместимых календарных графиков; предварительная загрузка вариантов не
+ раскрывает меню. Студент больше не загружает полный список групп.
+- `dialog.js` заменяет нативные `confirm`, `prompt` и `alert`: возвращает фокус, удерживает
+ его внутри окна, закрывается по Escape и может требовать причину. Нативных диалогов в
+ admin/settings-модулях нет.
+- `dirty-state.js` предупреждает при смене вкладки, версии, графика или закрытии страницы.
+ Календарная сетка и привязки дисциплин показывают маркер `•` на кнопке сохранения;
+ конструктор правил отслеживает изменения полей и состава слотов, но игнорирует
+ программную синхронизацию селектов при загрузке и сбросе формы.
+- `trackUxEvent()` создаёт базовые события смены раздела, загрузки реестров и расписания,
+ сохранения календаря/правила и просмотра ближайших занятий. В production они
+ преобразуются в OpenTelemetry spans, а до готовности bundle хранятся в ограниченной
+ очереди.
+
### Страница настроек (`/admin/settings/`)
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
@@ -358,7 +401,7 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
### Просмотр расписаний (`/admin/#schedule-view`)
-Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра расписаний. Доступны фильтры по семестру, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. При выборе семестра найденные занятия отображаются одной матрицей на весь период с явными границами недель для каждой пары; без семестра используется совмещённая двухнедельная таблица чётной/нечётной недели.
+Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра опубликованного расписания. Доступны каскад учебного года и семестра, а также фильтры по группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. При выборе семестра найденные занятия отображаются одной матрицей на весь период с явными границами недель для каждой пары.
### Преподаватель (`/teacher/`)
@@ -367,6 +410,8 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
сессии.
Основные элементы:
+- общий блок «Сегодня» и карточка «Следующая пара»; отдельный запрос покрывает ближайшие
+ 14 дней, поэтому следующая пара находится и за границей текущей недели;
- навигация по неделям: предыдущая, текущая, следующая;
- выбор даты через `input[type="date"]`;
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
@@ -387,10 +432,13 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
### Студент (`/student/`)
-В текущей модели студент не связан с конкретной группой, поэтому страница использует выбор группы из `/api/groups`.
+В текущей модели студент не связан с конкретной группой, поэтому страница использует
+асинхронный поиск группы через `/api/groups/options`.
Основные элементы:
-- селект группы с сохранением выбора в `localStorage.studentGroupId`;
+- поисковый combobox группы с подсказками по специальности, профилю и курсу; выбор хранится
+ в `localStorage.studentGroupId` и `studentGroup` query string;
+- общий блок «Сегодня» и карточка «Следующая пара», рассчитанные по ближайшим 14 дням;
- недельная сетка по дням;
- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп.
@@ -461,6 +509,8 @@ same-origin путь `/otel/v1/traces`.
- `scripts/build-vendor.mjs` собирает их через esbuild в `/vendor/otel.js`;
- браузер импортирует только same-origin bundle, CDN-код в runtime отсутствует;
- на `localhost` телеметрия не включается.
+- прикладные `magistr:ux-event` в production записываются отдельными spans с безопасными
+ скалярными атрибутами; очередь ограничена 100 событиями.
```javascript
telemetryPromise = import('/vendor/otel.js');
diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css
index 3b0104f..79afd49 100644
--- a/frontend/admin/css/auditorium-workload.css
+++ b/frontend/admin/css/auditorium-workload.css
@@ -630,7 +630,7 @@
}
.schedule-view-extra-grid {
- grid-template-columns: repeat(4, minmax(180px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
margin-top: 0.9rem;
}
diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css
index edaf6fc..89c81ba 100755
--- a/frontend/admin/css/components.css
+++ b/frontend/admin/css/components.css
@@ -689,6 +689,58 @@
letter-spacing: 0.04em;
}
+.schedule-initial-state {
+ display: flex;
+ min-height: 220px;
+ align-items: center;
+ justify-content: center;
+ gap: 0.7rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-md);
+ background: var(--bg-card);
+ color: var(--text-secondary);
+}
+
+.schedule-initial-content {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.schedule-initial-state[hidden],
+.schedule-initial-content.is-loading {
+ display: none;
+}
+
+.schedule-initial-content .card,
+#schedule-table tbody tr {
+ animation: none;
+}
+
+#schedule-table {
+ min-width: 1180px;
+ table-layout: fixed;
+}
+
+#schedule-table .schedule-rule-col-id { width: 5%; }
+#schedule-table .schedule-rule-col-subject { width: 16%; }
+#schedule-table .schedule-rule-col-semester { width: 13%; }
+#schedule-table .schedule-rule-col-groups { width: 16%; }
+#schedule-table .schedule-rule-col-load { width: 14%; }
+#schedule-table .schedule-rule-col-slots { width: 20%; }
+#schedule-table .schedule-rule-col-actions { width: 16%; }
+
+#schedule-table th,
+#schedule-table td {
+ overflow-wrap: anywhere;
+ vertical-align: top;
+}
+
+#schedule-table thead th {
+ white-space: nowrap;
+}
+
.schedule-group-select {
max-width: 520px;
}
@@ -703,6 +755,23 @@
max-height: 220px;
}
+.schedule-group-search {
+ width: calc(100% - 1rem);
+ min-height: 38px;
+ margin: 0.5rem;
+ padding: 0 0.75rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+ color: var(--text-primary);
+ font: inherit;
+}
+
+.schedule-group-search:focus {
+ border-color: var(--accent);
+ outline: 3px solid var(--accent-glow);
+}
+
.schedule-hours-panel {
margin-top: 1rem;
}
@@ -3920,3 +3989,193 @@ tbody tr:hover {
white-space: nowrap;
line-height: 1.2;
}
+.registry-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 0.75rem;
+ margin: 1rem 0;
+}
+
+.registry-toolbar .form-group {
+ min-width: min(260px, 100%);
+ margin: 0;
+}
+
+.registry-search {
+ width: min(420px, 100%);
+}
+
+.pagination {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-top: 1rem;
+}
+
+.pagination-actions,
+.pagination-size {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+}
+
+.pagination-summary,
+.pagination-page,
+.pagination-size span {
+ color: var(--text-secondary);
+ font-size: 0.86rem;
+}
+
+.pagination-size select {
+ min-height: 36px;
+ padding: 0.25rem 1.75rem 0.25rem 0.55rem;
+ color: var(--text-primary);
+ background: var(--bg-secondary);
+ border: 1px solid var(--bg-card-border);
+ border-radius: 8px;
+}
+
+.state-spinner {
+ display: inline-block;
+ width: 15px;
+ height: 15px;
+ margin-right: 0.55rem;
+ vertical-align: -2px;
+ border: 2px solid color-mix(in srgb, var(--accent) 22%, transparent);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: state-spin 0.75s linear infinite;
+}
+
+@keyframes state-spin { to { transform: rotate(360deg); } }
+
+.view-state-error {
+ color: var(--danger, #dc2626);
+}
+
+.view-state-error .btn {
+ margin-left: 0.75rem;
+}
+
+.async-combobox {
+ position: relative;
+ width: 100%;
+}
+
+.async-combobox-control {
+ position: relative;
+}
+
+.async-combobox-control input {
+ width: 100%;
+ min-height: 42px;
+ padding-right: 2.5rem;
+}
+
+.async-combobox-clear {
+ position: absolute;
+ top: 50%;
+ right: 0.45rem;
+ width: 30px;
+ height: 30px;
+ transform: translateY(-50%);
+ color: var(--text-secondary);
+ background: transparent;
+ border: 0;
+ border-radius: 8px;
+ cursor: pointer;
+}
+
+.async-combobox-clear:hover { background: var(--bg-hover); color: var(--text-primary); }
+
+.async-combobox-menu {
+ position: absolute;
+ z-index: 1200;
+ top: calc(100% + 0.35rem);
+ left: 0;
+ right: 0;
+ max-height: 320px;
+ overflow: auto;
+ padding: 0.35rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: 12px;
+ background: rgba(10, 10, 15, 0.96);
+ backdrop-filter: blur(20px) saturate(135%);
+ -webkit-backdrop-filter: blur(20px) saturate(135%);
+ box-shadow: 0 18px 45px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
+}
+
+[data-theme="light"] .async-combobox-menu {
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
+}
+
+.async-combobox-option {
+ display: grid;
+ width: 100%;
+ gap: 0.2rem;
+ padding: 0.7rem 0.75rem;
+ color: var(--text-primary);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 9px;
+ cursor: pointer;
+}
+
+.async-combobox-option:hover,
+.async-combobox-option:focus,
+.async-combobox-option[aria-selected="true"] {
+ outline: none;
+ background: var(--bg-hover);
+}
+
+.async-combobox-option small,
+.async-combobox-state {
+ color: var(--text-secondary);
+}
+
+.async-combobox-state {
+ padding: 0.8rem;
+ font-size: 0.9rem;
+}
+
+.async-combobox-state.is-error { color: var(--danger, #dc2626); }
+
+.project-dialog-backdrop {
+ position: fixed;
+ z-index: 4000;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 1rem;
+ background: rgba(15, 23, 42, 0.48);
+ backdrop-filter: blur(8px);
+}
+
+.project-dialog {
+ width: min(520px, 100%);
+ padding: 1.25rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: 18px;
+ color: var(--text-primary);
+ background: var(--bg-card);
+ box-shadow: 0 28px 80px rgba(15, 23, 42, 0.32);
+}
+
+.project-dialog header,
+.project-dialog footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.project-dialog header h2 { margin: 0; }
+.project-dialog footer { justify-content: flex-end; margin-top: 1rem; }
+.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; }
diff --git a/frontend/admin/css/schedule-versions.css b/frontend/admin/css/schedule-versions.css
index ef2deeb..6e6bdfb 100644
--- a/frontend/admin/css/schedule-versions.css
+++ b/frontend/admin/css/schedule-versions.css
@@ -79,6 +79,8 @@
}
.version-hero-controls label {
+ display: block;
+ margin-bottom: .35rem;
color: rgba(255, 255, 255, .62);
font-size: .7rem;
font-weight: 800;
@@ -98,6 +100,12 @@
.version-hero-controls option { color: #152536; background: #f7f4ed; }
+.version-hero-period-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: .65rem;
+}
+
.version-status {
min-height: 2.8rem;
padding: .8rem 1rem;
@@ -130,9 +138,13 @@
}
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
-.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .55rem; align-items: end; }
-.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
+.version-constructor-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; align-items: end; }
+.version-constructor-field { min-width: 0; }
+.version-constructor-field-wide { grid-column: 1 / -1; }
+.version-constructor-controls label { display: block; margin-bottom: .35rem; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); }
+.version-constructor-field select { width: 100%; }
+.version-constructor-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: .55rem; }
.version-current {
display: grid;
@@ -304,5 +316,7 @@
.version-diff-score { grid-template-columns: 1fr; }
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
.version-constructor-controls { grid-template-columns: 1fr; }
- .version-constructor-controls label { grid-column: auto; }
+ .version-constructor-field-wide { grid-column: auto; }
+ .version-constructor-actions { grid-column: auto; flex-direction: column; }
+ .version-hero-period-grid { grid-template-columns: 1fr; }
}
diff --git a/frontend/admin/css/teacher-absences.css b/frontend/admin/css/teacher-absences.css
index 5b33159..0a9fe6e 100644
--- a/frontend/admin/css/teacher-absences.css
+++ b/frontend/admin/css/teacher-absences.css
@@ -89,6 +89,15 @@
border-radius: var(--radius-lg);
}
+.absence-create-card {
+ z-index: 20;
+ overflow: visible;
+}
+
+.absence-create-card:focus-within {
+ z-index: 30;
+}
+
.absence-create-card .card-header-row > div:first-child,
.absence-registry-head > div:first-child {
display: flex;
@@ -526,13 +535,64 @@ body.absence-wizard-open {
.teacher-dialogue-toolbar {
display: flex;
+ align-items: stretch;
gap: .55rem;
margin: 1rem 0;
}
-.teacher-dialogue-toolbar select {
+.teacher-dialogue-toolbar > select,
+.teacher-dialogue-toolbar .custom-select-wrapper {
min-width: 0;
- flex: 1;
+ flex: 1 1 0;
+}
+
+.teacher-dialogue-toolbar > select {
+ min-height: 44px;
+ padding: 0.65rem 2.15rem 0.65rem 0.8rem;
+ border: 1px solid color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
+ border-radius: 11px;
+ background: var(--bg-input);
+ color: var(--text-primary);
+ font: inherit;
+}
+
+.teacher-dialogue-toolbar .custom-select-trigger {
+ min-height: 44px;
+ border-color: color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
+ border-radius: 11px;
+ background: color-mix(in srgb, var(--bg-input) 88%, #f59e0b 4%);
+}
+
+.teacher-dialogue-toolbar .custom-select-trigger:hover,
+.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger {
+ border-color: color-mix(in srgb, #f59e0b 62%, var(--bg-card-border));
+ background: color-mix(in srgb, var(--bg-hover) 90%, #f59e0b 6%);
+}
+
+.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger,
+.teacher-dialogue-toolbar .custom-select-trigger:focus {
+ box-shadow: 0 0 0 4px color-mix(in srgb, #f59e0b 20%, transparent);
+}
+
+.teacher-dialogue-toolbar .custom-select-menu {
+ border-color: color-mix(in srgb, #f59e0b 25%, var(--bg-card-border));
+ background: rgba(10, 10, 15, 0.96);
+ backdrop-filter: blur(20px) saturate(135%);
+ -webkit-backdrop-filter: blur(20px) saturate(135%);
+}
+
+.teacher-dialogue-toolbar .custom-select-item:hover:not(.disabled) {
+ background: color-mix(in srgb, #f59e0b 10%, var(--bg-hover));
+}
+
+.teacher-dialogue-toolbar .custom-select-item.selected {
+ background: color-mix(in srgb, #f59e0b 18%, var(--bg-input));
+ color: var(--text-primary);
+}
+
+[data-theme="light"] .teacher-dialogue-toolbar .custom-select-menu {
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: 0 16px 36px rgba(15, 23, 42, 0.15), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
}
.teacher-dialogue-list {
@@ -669,6 +729,16 @@ body.absence-wizard-open {
grid-template-columns: 1fr;
}
+ .teacher-dialogue-toolbar {
+ flex-wrap: wrap;
+ }
+
+ .teacher-dialogue-toolbar .custom-select-wrapper,
+ .teacher-dialogue-toolbar > select,
+ .teacher-dialogue-toolbar > .btn {
+ flex: 1 1 100%;
+ }
+
.absence-command-stats {
grid-template-columns: repeat(3, 1fr);
}
diff --git a/frontend/admin/js/academic-period.js b/frontend/admin/js/academic-period.js
new file mode 100644
index 0000000..1f00a1f
--- /dev/null
+++ b/frontend/admin/js/academic-period.js
@@ -0,0 +1,51 @@
+import { preferredScheduleSemester } from './schedule-period.js';
+
+export function academicYearsFromSemesters(semesters = []) {
+ const years = new Map();
+ semesters.forEach(semester => {
+ const id = String(semester?.academicYearId ?? '');
+ if (!id || years.has(id)) return;
+ years.set(id, {
+ id,
+ title: semester.academicYearTitle || `Учебный год ${id}`
+ });
+ });
+ return [...years.values()];
+}
+
+export function semestersForAcademicYear(semesters = [], academicYearId = '') {
+ return semesters.filter(semester =>
+ String(semester?.academicYearId ?? '') === String(academicYearId)
+ );
+}
+
+export function resolveAcademicPeriod(semesters = [], options = {}) {
+ if (!semesters.length) return { academicYearId: '', semesterId: '' };
+
+ const requestedSemester = semesters.find(semester =>
+ String(semester.id) === String(options.semesterId || '')
+ );
+ const availableYearIds = new Set(academicYearsFromSemesters(semesters).map(year => year.id));
+ const requestedYearId = availableYearIds.has(String(options.academicYearId || ''))
+ ? String(options.academicYearId)
+ : '';
+ const preferred = preferredScheduleSemester(semesters, options.date || isoToday()) || semesters[0];
+ const academicYearId = requestedSemester
+ ? String(requestedSemester.academicYearId)
+ : (requestedYearId || String(preferred?.academicYearId || semesters[0].academicYearId));
+ const yearSemesters = semestersForAcademicYear(semesters, academicYearId);
+ const semester = requestedSemester && String(requestedSemester.academicYearId) === academicYearId
+ ? requestedSemester
+ : (preferredScheduleSemester(yearSemesters, options.date || isoToday()) || yearSemesters[0]);
+
+ return {
+ academicYearId,
+ semesterId: String(semester?.id || '')
+ };
+}
+
+function isoToday() {
+ const date = new Date();
+ const offset = date.getTimezoneOffset() * 60_000;
+ return new Date(date.getTime() - offset).toISOString().slice(0, 10);
+}
diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js
index f936e44..99c59e4 100755
--- a/frontend/admin/js/api.js
+++ b/frontend/admin/js/api.js
@@ -135,7 +135,8 @@ export async function logout() {
export const api = {
get: async (url) => {
- const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
+ const isCachable = !url.includes('/page') && !url.includes('/options')
+ && CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
if (isCachable) {
if (!cache.has(url)) {
cache.set(url, await apiFetch(url, 'GET'));
diff --git a/frontend/admin/js/async-combobox.js b/frontend/admin/js/async-combobox.js
new file mode 100644
index 0000000..ca034d6
--- /dev/null
+++ b/frontend/admin/js/async-combobox.js
@@ -0,0 +1,193 @@
+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 = `
+