2 Commits

Author SHA1 Message Date
Zuev
e3b6797713 много всего и тестовые данные 2026-09-06 19:17:06 +03:00
Zuev
a1c4ee4298 Много разных изменений 2026-08-20 00:55:27 +03:00
108 changed files with 6831 additions and 961 deletions

View File

@@ -62,8 +62,14 @@ docker compose logs -f backend
## Критические правила для агентов
### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- До отдельного решения владельца проект работает в режиме пересобираемого baseline:
**все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
только в `V1__init.sql`**.
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
### Языковые требования

View File

@@ -63,18 +63,22 @@
**Файлы:**
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265`
- `docs/README.md:45-54`
- `backend/src/main/resources/db/migration/V2__test_data.sql`
- `docs/README.md`
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
**Проблема:** схема и данные разделены: V1 теперь не содержит доменных записей, однако
`V2__test_data.sql` всё равно автоматически применяется общим Flyway location ко всем tenant-БД.
V2 создаёт администратора с публично документированным коротким паролем, несколько известных
demo-пользователей и большой демонстрационный набор. Пароли 150 массовых преподавателей случайны
и непубличны, но это не устраняет риск доступного администратора и загрязнения production-БД.
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
**Как исправить:**
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
- Исключать `V2__test_data.sql` из production Flyway locations либо включать её только явным
dev/test-профилем.
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
- Разнести demo/dev seed и обязательную production-схему.
- Ротировать пароли уже созданных tenant-БД.
## Высокий приоритет

676
UX_IMPROVEMENT_PLAN.md Normal file
View File

@@ -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/` — кабинет студента.

55
UX_SITE_CHECKLIST.md Normal file
View File

@@ -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` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий.
## Что пока не проверяем
Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**.

View File

@@ -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,46 @@ public class AcademicCalendarController {
.toList();
}
@GetMapping("/page")
@Transactional(readOnly = true)
public PageResponse<AcademicCalendarDto> getCalendarsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long academicYearId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
return PageResponse.from(calendarRepository.searchOptions(
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
.map(this::toCalendarDto));
}
@GetMapping("/options")
public List<AcademicCalendarDto> 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<AcademicCalendarDto> 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)

View File

@@ -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<ClassroomResponse> 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<ClassroomResponse> 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()) {

View File

@@ -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<GroupResponse> getGroupsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) List<Long> 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<StudentGroup> specification = groupSpecification(
query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived
);
Page<StudentGroup> groups = groupRepository.findAll(
specification,
PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction))
);
return PageResponse.from(groups.map(this::mapToResponse));
}
@GetMapping("/options")
public List<GroupResponse> 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<GroupResponse> 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<StudentGroup> groupSpecification(String query,
List<Long> educationFormIds,
Long departmentId,
Integer yearStartStudy,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> 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);

View File

@@ -2,6 +2,7 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService;
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
return scheduleRuleService.getAll(semesterId, groupId, versionId);
}
@GetMapping("/page")
public PageResponse<ScheduleRuleDto> getPage(
@RequestParam Long versionId,
@RequestParam(defaultValue = "") String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
return scheduleRuleService.getPage(versionId, query, page, size);
}
@GetMapping("/{id}")
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
return ResponseEntity.ok(scheduleRuleService.getById(id));

View File

@@ -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<SubgroupDto> getAll() {
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
public List<SubgroupDto> getAll(@RequestParam(required = false) List<Long> groupId) {
if (groupId != null && groupId.size() > 100) {
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп");
}
List<Subgroup> selected = groupId == null
? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()
: groupId.isEmpty()
? List.of()
: subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList());
return selected.stream()
.map(this::toDto)
.toList();
}

View File

@@ -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<SubjectResponse> 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<Subject> subjectSpecification(String query,
Long departmentId,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> 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) {

View File

@@ -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<TeacherCreationRequestResponse> 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<TeacherCreationRequest> requestSpecification(String query,
TeacherCreationRequestStatus status,
Long departmentId) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> 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) {

View File

@@ -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<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
public List<TeacherSubjectResponse> getAll(@RequestParam(required = false) List<Long> subjectId) {
if (subjectId != null && subjectId.size() > 100) {
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин");
}
List<TeacherSubject> 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()
);
}
}

View File

@@ -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<UserResponse> 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<UserResponse> 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<UserResponse> 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<UserResponse> 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<User> userSpecification(String query,
Role role,
Long departmentId,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> 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){

View File

@@ -0,0 +1,27 @@
package com.magistr.app.dto;
import org.springframework.data.domain.Page;
import java.util.List;
public record PageResponse<T>(
List<T> items,
int page,
int size,
long totalItems,
int totalPages,
boolean first,
boolean last
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast()
);
}
}

View File

@@ -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;
}

View File

@@ -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<AcademicCalendar, Long> {
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long>, JpaSpecificationExecutor<AcademicCalendar> {
long countByStudyFormId(Long studyFormId);
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
where calendar.id = :id
""")
Optional<AcademicCalendar> 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<AcademicCalendar> searchOptions(@Param("query") String query,
@Param("academicYearId") Long academicYearId,
@Param("specialtyId") Long specialtyId,
@Param("profileId") Long profileId,
@Param("studyFormId") Long studyFormId,
Pageable pageable);
}

View File

@@ -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<Classroom, Long> {
public interface ClassroomRepository extends JpaRepository<Classroom, Long>, JpaSpecificationExecutor<Classroom> {
Optional<Classroom> findByName(String name);
List<Classroom> 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<Classroom> searchActiveOptions(@Param("query") String query,
@Param("archivedStatus") String archivedStatus,
Pageable pageable);
}

View File

@@ -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<StudentGroup, Long> {
public interface GroupRepository extends JpaRepository<StudentGroup, Long>, JpaSpecificationExecutor<StudentGroup> {
List<StudentGroup> findByEducationFormId(Long educationFormId);

View File

@@ -3,6 +3,8 @@ package com.magistr.app.repository;
import com.magistr.app.model.ScheduleRule;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -13,6 +15,23 @@ import java.util.Collection;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
@Query("""
select r.id from ScheduleRule r
join r.scheduleVersion version
join r.subject subject
where r.status <> 'ARCHIVED'
and version.id = :versionId
and (:query = '' or str(r.id) = :query
or lower(subject.name) like concat('%', :query, '%')
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
order by r.id desc
""")
Page<Long> searchPageIds(
@Param("versionId") Long versionId, @Param("query") String query,
Pageable pageable);
@Query("""
select distinct r
from ScheduleRule r
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
where r.id = :id
""")
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
@Query("""
select distinct r
from ScheduleRule r
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.scheduleVersion
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where r.id in :ids
""")
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
}

View File

@@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
""")
List<Subgroup> 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<Subgroup> findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List<Long> groupIds);
@Query("""
select subgroup
from Subgroup subgroup

View File

@@ -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<Subject, Long> {
public interface SubjectRepository extends JpaRepository<Subject, Long>, JpaSpecificationExecutor<Subject> {
Optional<Subject> findByNameIgnoreCase(String name);
List<Subject> findByDepartmentId(Long departmentId);

View File

@@ -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<TeacherCreationRequest, Long> {
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long>, JpaSpecificationExecutor<TeacherCreationRequest> {
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);

View File

@@ -12,6 +12,16 @@ public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject,
List<TeacherSubject> findByUserId(Long userId);
List<TeacherSubject> 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<TeacherSubject> findBySubjectIdInWithDetails(@Param("subjectIds") List<Long> subjectIds);
@Query(value = """
select subject_link.user_id
from teacher_subjects subject_link

View File

@@ -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<User, Long> {
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
Optional<User> findByUsername(String username);
@@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> 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<User> 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<User> findByIdForUpdate(@Param("id") Long id);

View File

@@ -1,6 +1,7 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.Classroom;
@@ -31,6 +32,7 @@ import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
}
@Transactional(readOnly = true)
public PageResponse<ScheduleRuleDto> getPage(Long versionId, String query, int page, int size) {
var ids = scheduleRuleRepository.searchPageIds(versionId,
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100))));
if (ids.isEmpty()) {
return new PageResponse<>(List.of(), ids.getNumber(), ids.getSize(), ids.getTotalElements(),
ids.getTotalPages(), ids.isFirst(), ids.isLast());
}
var details = scheduleRuleRepository.findPageWithDetails(ids.getContent()).stream()
.collect(java.util.stream.Collectors.toMap(ScheduleRule::getId, this::toDto));
return PageResponse.from(ids.map(details::get));
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return getAll(semesterId, groupId, null);

View File

@@ -19,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
archive_reason TEXT
);
INSERT INTO departments (name, code) VALUES
('Кафедра ИБ', 1),
('Кафедра ВТ', 2),
('Кафедра КТ', 3);
CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
@@ -36,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
archive_reason TEXT
);
INSERT INTO specialties (name, specialty_code) VALUES
('Информационная безопасность', '10.03.01'),
('Информатика и вычислительная техника', '09.03.01'),
('Программная инженерия', '09.03.04')
ON CONFLICT (specialty_code) DO NOTHING;
CREATE TABLE IF NOT EXISTS specialty_profiles (
id BIGSERIAL PRIMARY KEY,
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
@@ -57,11 +46,6 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
);
INSERT INTO specialty_profiles (specialty_id, name)
SELECT id, 'Без профиля'
FROM specialties
ON CONFLICT (specialty_id, name) DO NOTHING;
-- ==========================================
-- Пользователи и роли
-- ==========================================
@@ -83,15 +67,6 @@ CREATE TABLE IF NOT EXISTS users (
archive_reason TEXT
);
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
ON CONFLICT (username) DO NOTHING;
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -189,16 +164,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
ON teacher_department_assignments(teacher_id, department_id)
WHERE valid_to IS NULL;
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u
WHERE u.role = 'TEACHER'
AND NOT EXISTS (
SELECT 1
FROM teacher_department_assignments existing
WHERE existing.teacher_id = u.id
);
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
@@ -243,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
archive_reason TEXT
);
INSERT INTO education_forms (name) VALUES
('Бакалавриат'),
('Магистратура'),
('Специалитет')
ON CONFLICT (name) DO NOTHING;
-- ==========================================
-- Учебные группы
-- ==========================================
@@ -272,19 +231,6 @@ CREATE TABLE IF NOT EXISTS student_groups (
archive_reason TEXT
);
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
FROM (VALUES
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
JOIN education_forms ef ON ef.name = data.education_form_name
JOIN departments dept ON dept.name = data.department_name
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
-- ==========================================
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
-- ==========================================
@@ -373,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
FOR EACH ROW
EXECUTE FUNCTION validate_subgroup_student_capacity();
INSERT INTO subgroups (group_id, name, student_capacity)
SELECT
sg.id,
data.name,
CASE
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
END
FROM student_groups sg
CROSS JOIN (VALUES
('Подгруппа 1', 1),
('Подгруппа 2', 2)
) AS data(name, part_number)
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, name) DO NOTHING;
-- ==========================================
-- Справочники
-- ==========================================
@@ -414,14 +344,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
COMMENT ON INDEX uq_subjects_name_ci IS
'Глобально уникальное название дисциплины без учёта регистра';
INSERT INTO subjects (name, department_id) VALUES
('Высшая математика', 1),
('Философия', 1),
('Информатика', 1),
('Базы данных', 1),
('Английский язык', 1)
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS subject_comments (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -441,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
duration_minutes INT DEFAULT 90
);
INSERT INTO lesson_types (name, color_code) VALUES
('Лекция', '#FF6B6B'),
('Практика', '#4ECDC4'),
('Лабораторная работа', '#45B7D1')
ON CONFLICT (name) DO NOTHING;
-- Оборудование
CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY,
@@ -461,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
archive_reason TEXT
);
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска'),
('Документ-камера'),
('Аудиосистема')
ON CONFLICT (name) DO NOTHING;
-- Аудитории
CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY,
@@ -488,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
archive_reason TEXT
);
INSERT INTO classrooms (name, capacity, building, floor) VALUES
('101 Ленинская', 120, 'Главный корпус', 1),
('202 IT Lab', 20, 'Корпус IT', 2),
('303 Обычная', 30, 'Главный корпус', 3)
ON CONFLICT (name) DO NOTHING;
-- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
@@ -503,20 +404,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
PRIMARY KEY (classroom_id, equipment_id)
);
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
SELECT c.id, e.id,
CASE
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
WHEN e.name = 'ПК' THEN 1
ELSE 1
END
FROM classrooms c, equipments e
WHERE
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
@@ -575,11 +462,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
ON time_slot_scopes(day_of_week)
WHERE apply_mode = 'WEEKDAY';
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS time_slots (
id BIGSERIAL PRIMARY KEY,
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
@@ -597,21 +479,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
FROM (VALUES
(1, '08:00', '09:30', 90),
(2, '09:40', '11:10', 90),
(3, '11:40', '13:10', 90),
(4, '13:20', '14:50', 90),
(5, '15:00', '16:30', 90),
(6, '16:50', '18:20', 90),
(7, '18:30', '20:00', 90)
) AS data(order_number, start_time, end_time, duration_minutes)
CROSS JOIN time_slot_scopes scope
WHERE scope.code IN ('default', 'saturday')
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
id BIGSERIAL PRIMARY KEY,
assignment_date DATE NOT NULL UNIQUE,
@@ -629,11 +496,6 @@ CREATE TABLE IF NOT EXISTS academic_years (
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
);
INSERT INTO academic_years (title, start_date, end_date) VALUES
('2024-2025', '2024-09-01', '2025-06-30'),
('2025-2026', '2025-09-01', '2026-06-30')
ON CONFLICT (title) DO NOTHING;
CREATE TABLE IF NOT EXISTS semesters (
id BIGSERIAL PRIMARY KEY,
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
@@ -645,16 +507,6 @@ CREATE TABLE IF NOT EXISTS semesters (
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
);
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
@@ -667,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
description TEXT
);
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendars (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
@@ -700,21 +536,6 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
SELECT
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
ay.id,
specialty.id,
profile.id,
study_form.id,
4
FROM academic_years ay
CROSS JOIN education_forms study_form
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
WHERE ay.title = '2025-2026'
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
@@ -754,24 +575,6 @@ AS $$
) + 1
$$;
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT
calendar.id,
course.course_number,
ay.start_date,
ay.end_date,
activity.id
FROM academic_calendars calendar
JOIN academic_years ay ON ay.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number);
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
@@ -800,21 +603,6 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
ON student_group_calendar_assignments(group_id, academic_year_id);
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
SELECT
sg.id,
ay.id,
calendar.id
FROM student_groups sg
JOIN academic_years ay ON ay.title = '2025-2026'
JOIN academic_calendars calendar
ON calendar.academic_year_id = ay.id
AND calendar.specialty_id = sg.specialty_id
AND calendar.specialty_profile_id = sg.specialty_profile_id
AND calendar.study_form_id = sg.education_form_id
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
CREATE TABLE IF NOT EXISTS schedule_rules (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -991,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
FOR EACH ROW
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
CREATE TEMP TABLE tmp_default_rules AS
SELECT
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
rule_key,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
FROM (
SELECT
data.rule_key,
subj.id AS subject_id,
sem.id AS semester_id,
data.lecture_academic_hours,
data.laboratory_academic_hours,
data.practice_academic_hours,
data.lecture_start_week,
data.laboratory_start_week,
data.practice_start_week
FROM (VALUES
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
) AS data(
rule_key,
subject_name,
year_title,
semester_type,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
)
JOIN subjects subj ON subj.name = data.subject_name
JOIN academic_years ay ON ay.title = data.year_title
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
) prepared_rules;
INSERT INTO schedule_rules (
id,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
)
SELECT
schedule_rule_id,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
FROM tmp_default_rules;
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
SELECT
rules.schedule_rule_id,
sg.id
FROM (VALUES
('stream_math', 'ИВТ-21-1'),
('stream_math', 'ИБ-41м'),
('ivt_informatics_lab', 'ИВТ-21-1'),
('ivt_databases_practice', 'ИВТ-21-1'),
('ib_philosophy', 'ИБ-41м')
) AS data(rule_key, group_name)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN student_groups sg ON sg.name = data.group_name
ON CONFLICT DO NOTHING;
INSERT INTO schedule_rule_slots (
schedule_rule_id,
day_of_week,
parity,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
)
SELECT
rules.schedule_rule_id,
data.day_of_week,
data.parity,
ts.id,
teacher.id,
classroom.id,
lt.id,
data.lesson_format
FROM (VALUES
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN time_slot_scopes scope ON scope.code = 'default'
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
JOIN users teacher ON teacher.username = data.teacher_username
JOIN classrooms classroom ON classroom.name = data.classroom_name
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
-- ==========================================
-- Функция обновления timestamp
-- ==========================================
@@ -1371,7 +1044,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
cancel_violations,
move_violations,
replace_violations,
@@ -1470,7 +1143,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
lecture_violations,
laboratory_violations,
practice_violations
@@ -1511,7 +1184,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23505',
MESSAGE = format(
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
duplicate_slot_groups
);
END IF;
@@ -1577,7 +1250,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
duration_violations,
overlap_violations,
scope_violations
@@ -1733,7 +1406,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
academic_year_overlap_violations,
semester_bounds_violations,
semester_overlap_violations
@@ -1864,7 +1537,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
assignment_violations,
calendar_period_violations,
calendar_subject_violations
@@ -2430,33 +2103,9 @@ CREATE UNIQUE INDEX uq_schedule_versions_published_semester
CREATE INDEX idx_schedule_versions_semester_history
ON schedule_versions(semester_id, version_number DESC);
INSERT INTO schedule_versions (
semester_id,
version_number,
name,
status,
created_at,
published_at
)
SELECT
semester.id,
1,
'Опубликованная версия 1',
'PUBLISHED',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM semesters semester;
ALTER TABLE schedule_rules
ADD COLUMN schedule_version_id BIGINT;
UPDATE schedule_rules rule
SET schedule_version_id = version.id,
version_group_id = COALESCE(rule.version_group_id, rule.id)
FROM schedule_versions version
WHERE version.semester_id = rule.semester_id
AND version.status = 'PUBLISHED';
ALTER TABLE schedule_rules
ALTER COLUMN schedule_version_id SET NOT NULL,
ADD CONSTRAINT fk_schedule_rules_version
@@ -2487,14 +2136,6 @@ CREATE TABLE schedule_version_history (
CREATE INDEX idx_schedule_version_history_version
ON schedule_version_history(version_id, created_at DESC, id DESC);
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
FROM schedule_versions;
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
FROM schedule_versions;
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';

View File

@@ -1,24 +0,0 @@
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT
calendar.id,
course.course_number,
academic_year.start_date,
academic_year.end_date,
theory_activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year
ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types theory_activity
ON theory_activity.code = 'Т'
CROSS JOIN LATERAL GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
WHERE NOT EXISTS (
SELECT 1
FROM academic_calendar_periods existing_period
WHERE existing_period.calendar_id = calendar.id
);

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,30 @@ import static org.mockito.Mockito.when;
class AcademicCalendarControllerTest {
@Test
@org.junit.jupiter.api.DisplayName("Страница графиков ограничивает размер и сохраняет фильтр учебного года")
void returnsBoundedCalendarPage() throws Exception {
AcademicCalendarRepository repository = mock(AcademicCalendarRepository.class);
var pageable = org.springframework.data.domain.PageRequest.of(0, 100,
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
.and(org.springframework.data.domain.Sort.by("title", "id")));
when(repository.searchOptions("профиль", 9L, null, null, null, pageable))
.thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(), pageable, 0));
var controller = new AcademicCalendarController(repository,
mock(AcademicCalendarPeriodRepository.class), mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class), mock(AcademicStructureService.class));
org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build()
.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.get("/api/admin/academic-calendars/page")
.param("query", " ПРОФИЛЬ ").param("academicYearId", "9")
.param("page", "-1").param("size", "1000"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.size").value(100))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.page").value(0))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isArray());
}
@Test
void createCalendarReturnsCalendarCreatedByTransactionalService() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);

View File

@@ -33,6 +33,20 @@ class ScheduleRuleAdminControllerTest {
.build();
}
@Test
@org.junit.jupiter.api.DisplayName("Страница правил принимает фильтры и возвращает метаданные пагинации")
void returnsRulesPage() throws Exception {
when(service.getPage(7L, "Математика", 2, 25)).thenReturn(
new com.magistr.app.dto.PageResponse<>(List.of(), 2, 25, 50, 2, false, true));
mockMvc.perform(get("/api/admin/schedule-rules/page")
.param("versionId", "7").param("query", "Математика").param("page", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.page").value(2))
.andExpect(jsonPath("$.size").value(25))
.andExpect(jsonPath("$.totalItems").value(50));
}
@Test
void returnsBadRequestWithRussianValidationMessage() throws Exception {
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(

View File

@@ -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);

View File

@@ -16,7 +16,7 @@ import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@DisplayName("Миграция начальных периодов календарного графика")
@DisplayName("Миграция тестовых данных и начальных периодов календарного графика")
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
@Container
@@ -33,6 +33,7 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
long emptyCalendarId;
long configuredCalendarId;
try (Connection connection = POSTGRES.createConnection("")) {
prepareCustomCalendarPrerequisites(connection);
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
insertConfiguredPeriod(connection, configuredCalendarId);
@@ -52,6 +53,51 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
}
}
@Test
@DisplayName("V1 создаёт только схему, а V2 загружает полный тестовый набор")
void migrationSeparatesSchemaAndTestData() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """
SELECT
(SELECT COUNT(*) FROM departments)
+ (SELECT COUNT(*) FROM users)
+ (SELECT COUNT(*) FROM student_groups)
+ (SELECT COUNT(*) FROM subjects)
+ (SELECT COUNT(*) FROM academic_years)
+ (SELECT COUNT(*) FROM schedule_rules)
"""))
.as("После V1 доменные таблицы должны быть пустыми")
.isZero();
}
Flyway allMigrations = flyway(null);
allMigrations.migrate();
assertThat(allMigrations.info().current()).isNotNull();
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, "SELECT COUNT(*) FROM departments")).isEqualTo(41);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM specialties")).isEqualTo(42);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM users WHERE role = 'TEACHER'"))
.isEqualTo(151);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_groups")).isEqualTo(202);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subgroups")).isEqualTo(404);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subjects")).isEqualTo(229);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM classrooms")).isEqualTo(80);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendars")).isEqualTo(132);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendar_periods")).isEqualTo(576);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_group_calendar_assignments"))
.isEqualTo(202);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rules")).isEqualTo(610);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rule_slots")).isEqualTo(610);
}
}
private Flyway flyway(String targetVersion) {
var configuration = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
@@ -75,10 +121,12 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
study_form_id,
course_count
)
SELECT ?, academic_year_id, specialty_id, specialty_profile_id, study_form_id, ?
FROM academic_calendars
ORDER BY id
LIMIT 1
SELECT ?, academic_year.id, specialty.id, profile.id, study_form.id, ?
FROM academic_years academic_year
JOIN specialties specialty ON specialty.specialty_code = '99.99.99'
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
JOIN education_forms study_form ON study_form.name = 'Тестовая форма'
WHERE academic_year.title = '2030-2031'
RETURNING id
""")) {
statement.setString(1, title);
@@ -90,6 +138,40 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
}
}
private void prepareCustomCalendarPrerequisites(Connection connection) throws SQLException {
try (var statement = connection.createStatement()) {
statement.executeUpdate("""
INSERT INTO departments (name, code)
VALUES ('Тестовая кафедра для V2', 9999)
""");
statement.executeUpdate("""
INSERT INTO specialties (name, specialty_code)
VALUES ('Тестовое направление для V2', '99.99.99')
""");
statement.executeUpdate("""
INSERT INTO specialty_profiles (specialty_id, name)
SELECT id, 'Тестовый профиль для V2'
FROM specialties
WHERE specialty_code = '99.99.99'
""");
statement.executeUpdate("""
INSERT INTO education_forms (name)
VALUES ('Тестовая форма')
""");
statement.executeUpdate("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES ('2030-2031', DATE '2030-09-01', DATE '2031-06-30')
""");
statement.executeUpdate("""
INSERT INTO academic_calendar_activity_types (
code, name, allow_schedule, color_code, display_order
) VALUES
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10),
('К', 'Каникулы', FALSE, '#38bdf8', 30)
""");
}
}
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_periods (
@@ -140,6 +222,14 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
}
}
private long queryLong(Connection connection, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
private record PeriodSummary(int count,
int minCourse,
int maxCourse,

View File

@@ -38,7 +38,7 @@ class AcademicStructureInvariantIntegrationTest {
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
long groupId = queryLong(
@@ -81,6 +81,7 @@ class AcademicStructureInvariantIntegrationTest {
"23514",
"сохранённые назначения календарных графиков станут несовместимыми"
);
deleteOtherCalendarAssignments(connection, calendarId, assignmentId);
assertSqlState(
() -> updateInt(
connection,
@@ -386,7 +387,7 @@ class AcademicStructureInvariantIntegrationTest {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.load();
}
@@ -541,6 +542,20 @@ class AcademicStructureInvariantIntegrationTest {
""");
}
private void deleteOtherCalendarAssignments(Connection connection,
long calendarId,
long preservedAssignmentId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
DELETE FROM student_group_calendar_assignments
WHERE calendar_id = ?
AND id <> ?
""")) {
statement.setLong(1, calendarId);
statement.setLong(2, preservedAssignmentId);
statement.executeUpdate();
}
}
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, value);

View File

@@ -37,8 +37,8 @@ class ScheduleOverrideMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V1")
.isEqualTo("1");
.as("Тестовые данные должны быть загружены миграцией V2")
.isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
@@ -149,7 +149,7 @@ class ScheduleOverrideMigrationIntegrationTest {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.load();
}

View File

@@ -38,8 +38,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия")
.isNotNull();
assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V1")
.isEqualTo("1");
.as("Тестовые данные должны быть загружены миграцией V2")
.isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
@@ -82,7 +82,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.load();
}

View File

@@ -93,7 +93,7 @@ class SubjectOwnershipInvariantIntegrationTest {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.load();
flyway.clean();

View File

@@ -180,7 +180,7 @@ class TeacherDepartmentInvariantIntegrationTest {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.load();
}

View File

@@ -30,13 +30,13 @@ class TeacherPreferencesMigrationIntegrationTest {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.load();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """
SELECT count(*)

View File

@@ -41,7 +41,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
flyway.migrate();
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
long manualScopeId = insertManualScope(connection, "migration_checks");
@@ -171,7 +171,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
}
private Flyway baselineFlyway() {
return flyway("1");
return flyway("2");
}
private Flyway flyway(String targetVersion) {

View File

@@ -47,6 +47,7 @@ import static org.mockito.Mockito.verify;
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
properties = {
"spring.jpa.open-in-view=false",
"spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true",
"management.endpoint.health.validate-group-membership=false"
}
)
@@ -86,6 +87,48 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
@Test
@org.junit.jupiter.api.DisplayName("Страницы правил не пересекаются, поиск работает вне первой страницы")
void pagesRulesAndSearchesAcrossWholeVersion() {
Long versionId = jdbcTemplate.queryForObject("""
SELECT schedule_version_id FROM schedule_rules
WHERE status <> 'ARCHIVED' GROUP BY schedule_version_id
ORDER BY COUNT(*) DESC LIMIT 1
""", Long.class);
var first = service.getPage(versionId, "", 0, 25);
var second = service.getPage(versionId, "", 1, 25);
assertThat(first.items()).hasSize(25);
assertThat(second.items()).hasSize(25);
assertThat(first.totalItems()).isGreaterThan(50);
assertThat(first.items()).extracting(ScheduleRuleDto::id)
.doesNotContainAnyElementsOf(second.items().stream().map(ScheduleRuleDto::id).toList());
var rule = second.items().get(0);
assertThat(rule.slots()).isNotEmpty();
assertThat(service.getPage(versionId, rule.id().toString(), 0, 25).items())
.extracting(ScheduleRuleDto::id).contains(rule.id());
assertThat(service.getPage(versionId, rule.subjectName().toUpperCase(java.util.Locale.ROOT), 0, 25).items())
.isNotEmpty().allMatch(item -> item.subjectName().equals(rule.subjectName()));
assertThat(service.getPage(versionId, rule.groupNames().get(0), 0, 25).items())
.isNotEmpty().allMatch(item -> item.groupNames().contains(rule.groupNames().get(0)));
assertThat(service.getPage(versionId, "НетТакойДисциплины", 0, 25).totalItems()).isZero();
assertThat(service.getPage(versionId, "", 100000, 25).items()).isEmpty();
assertThat(service.getPage(versionId, "", -1, 1000).size()).isEqualTo(100);
}
@Autowired
private com.magistr.app.repository.AcademicCalendarRepository calendarRepository;
@Test
@org.junit.jupiter.api.DisplayName("Календарные графики ищутся по году с ограничением страницы в БД")
void pagesCalendarsForFutureYears() {
var page = calendarRepository.searchOptions("2027-2028", null, null, null, null,
org.springframework.data.domain.PageRequest.of(0, 25,
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
.and(org.springframework.data.domain.Sort.by("title", "id"))));
assertThat(page.getTotalElements()).isEqualTo(44);
assertThat(page.getContent()).hasSize(25).allMatch(calendar -> calendar.getAcademicYear().getTitle().equals("2027-2028"));
}
@Test
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
assertThat(AopUtils.isAopProxy(service)).isTrue();

View File

@@ -25,6 +25,11 @@
Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам.
Список графиков в интерфейсе загружается страницами по 25 записей (также доступны 50 и 100),
с серверным поиском и фильтром учебного года. В редакторах «Сетки» и «Дисциплины» график
выбирается через поисковый комбобокс, который загружает до 20 результатов и выбранную запись.
В V2 предусмотрены по 44 демонстрационных графика на 2025–2026, 2026–2027 и 2027–2028 годы.
## Коды активностей
| Код | Значение | Обычные пары |
@@ -43,7 +48,8 @@
| `*` | Праздничный или нерабочий день | Нет |
| `=` | День вне учебного года или пустая ячейка графика | Нет |
Технически разрешение генерации задаётся полем `academic_calendar_activity_types.allow_schedule`. В seed-данных `true` установлен только для `Т`.
Технически разрешение генерации задаётся полем `academic_calendar_activity_types.allow_schedule`.
В тестовых данных `V2__test_data.sql` значение `true` установлено только для `Т`.
## Правило генерации расписания

View File

@@ -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,8 @@ 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/page` | Страница графиков: `query`, `academicYearId`, `page`, `size` |
| `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 +625,18 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
Отдельного пользовательского названия у календарного графика нет.
`GET /api/admin/academic-calendars/page` возвращает `PageResponse` с полями
`items`, `page`, `size`, `totalItems`, `totalPages`, `first`, `last`.
Нумерация страниц начинается с 0, размер по умолчанию 25, сервер ограничивает его диапазоном 1–100.
Поиск `query` без учёта регистра охватывает название графика, учебный год, код и название
специальности, профиль и форму обучения. Сортировка: начало учебного года по убыванию,
затем название и ID по возрастанию.
`GET /api/admin/academic-calendars/options` принимает `query`, `academicYearId`,
`specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск
выполняется по составному названию, учебному году, коду/названию специальности, профилю и
форме обучения. Фасетные параметры позволяют не показывать группе несовместимые графики.
`POST /api/admin/academic-calendars` создаёт график и начальную сетку одной транзакцией.
Для каждого курса из диапазона `1..courseCount` backend сохраняет один период с кодом `Т`
на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после
@@ -682,11 +726,18 @@ CRUD доступен по:
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
| `GET` | `/api/admin/schedule-rules/page` | Страница правил: обязательный `versionId`, параметры `query`, `page`, `size` |
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
| `POST` | `/api/admin/schedule-rules` | Создать правило |
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
`GET /api/admin/schedule-rules/page` возвращает тот же формат `PageResponse`;
`page` по умолчанию 0, `size` — 25 (ограничение 1–100). Правила выбранной версии,
кроме архивных, сортируются по ID по убыванию. `query` ищет по названию дисциплины,
названию группы или точному ID. Сначала БД выбирает страницу ID, затем загружаются
группы и слоты только этих правил; пагинация не выполняется в памяти над полной коллекцией.
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
@@ -1305,6 +1356,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 +1474,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 +1552,12 @@ legacy-alias для старых клиентов и исторически со
Список аудиторий с привязанным оборудованием.
### `GET /api/classrooms/options`
Асинхронный поиск доступных неархивных аудиторий по названию и корпусу. Параметры:
`query`, `selectedId`, `limit` (`1..50`). Выбранная запись добавляется в результат, если
она не попала в первую страницу поиска.
**Ответ:**
```json
[
@@ -1539,6 +1611,12 @@ legacy-alias для старых клиентов и исторически со
Список всех дисциплин.
### `GET /api/subjects/page`
Пагинированный реестр дисциплин. Параметры: `query`, `departmentId`, `includeArchived`,
`page`, `size`, `sort`, `direction`; сортировка разрешена по `name`, `code`,
`departmentId`, `status`. Роль `DEPARTMENT` всегда ограничивается своей кафедрой на backend.
```json
{
"name": "Физика",
@@ -1620,14 +1698,17 @@ legacy-alias для старых клиентов и исторически со
### `GET /api/teacher-subjects`
Список всех привязок.
Список привязок. Повторяемый параметр `subjectId` ограничивает результат указанными
дисциплинами; за один запрос принимается не более 100 ID. Без параметра сохраняется
совместимый режим получения всех доступных текущей роли привязок.
**Ответ:**
```json
[
{
"userId": 2,
"userName": "Тестовый преподаватель",
"username": "teacher2",
"fullName": "Тестовый преподаватель",
"subjectId": 1,
"subjectName": "Высшая математика"
}

View File

@@ -161,9 +161,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет
такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или
полностью настроенные сетки она не изменяет.
графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции
`V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам
без единого периода; частично или полностью настроенные сетки не изменяются.
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не

View File

@@ -953,8 +953,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
Пара `semester_id + version_number` уникальна. Частичный индекс
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после
семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
привязывает к ней тестовые правила. Для семестров, создаваемых через API после
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
`Основное расписание` и две записи истории: создание и публикацию.
@@ -1177,10 +1177,15 @@ CHECK фиксирует допустимую форму каждого типа
### Правила работы
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
триггеры и ограничения изменяются только в `V1__init.sql`.
3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
tenant-БД. Сброс выполняется только по прямой команде владельца.
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
пустой tenant-схеме
@@ -1188,19 +1193,46 @@ CHECK фиксирует допустимую форму каждого типа
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__backfill_default_theory_periods.sql` | Добавляет периоды `Т` на весь учебный год для каждого курса только в полностью пустых календарных графиках; уже настроенные графики не изменяет |
| `V1__init.sql` | Полная schema-only baseline: таблицы, связи, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия, пожелания и заявки преподавателей, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии. Постоянных доменных данных нет |
| `V2__test_data.sql` | Единый тестовый seed: прежние демонстрационные записи V1, справочники и полный набор связанных нагрузочных данных. Здесь же после создания графиков сохранена логика прежней V2: период `Т` на весь год добавляется каждому курсу только полностью пустого графика |
### Этап разработки
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
проекта объединены в baseline `V1`. После фиксации baseline новые изменения оформляются
отдельными инкрементальными миграциями; первой стала `V2__backfill_default_theory_periods.sql`.
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
непосредственно в V1.
Существующая tenant-БД с уже применённой V1 получает V2 без изменения контрольной суммы
baseline. Для развёртывания V1 с нуля по-прежнему требуется пустая tenant-схема.
Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
`V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый tenant, включая
production. Это сознательное ограничение текущего режима разработки; перед production-релизом
V2 необходимо сделать условной или исключить из production locations.
### Объём тестового набора V2
На чистой схеме V2 создаёт детерминированный связанный набор:
| Сущности | Количество |
|----------|-----------:|
| Кафедры / специальности / профили | 41 / 42 / 42 |
| Пользователи / преподаватели | 155 / 151 |
| Группы / подгруппы | 202 / 404 |
| Дисциплины / связи преподаватель–дисциплина / разрешённые типы занятий | 229 / 844 / 2532 |
| Аудитории / связи с оборудованием | 80 / 174 |
| Календарные графики / периоды / дисциплины графиков / назначения групп | 132 / 576 / 3456 / 202 |
| Версии / события истории | 8 / 16 |
| Правила / связи с группами / слоты / лабораторные подгруппы | 610 / 611 / 610 / 202 |
| Пожелания / отсутствия / комментарии к дисциплинам | 60 / 20 / 40 |
Учебные годы: 2024–2025, 2025–2026, 2026–2027 и 2027–2028. Для трёх последних
создаётся одинаковый набор из 44 графиков направлений с периодами теоретического обучения
и дисциплинами по семестрам. Назначения существующих групп сохраняются на 2025–2026 год.
Из 202 групп две (`ИВТ-21-1`, `ИБ-41м`) перенесены из прежней V1, ещё 200 созданы по
шифрам направлений ЮЗГУ. Источниками названий служат публичные страницы структуры,
приёмной кампании и перечней дисциплин ЮЗГУ; ФИО 150 массовых преподавателей синтетические
и имеют непубликуемый случайный пароль. Доступны для ручного входа только документированные
исторические demo-аккаунты.
### Полный сброс БД (локально)

View File

@@ -251,18 +251,18 @@ public class AbsenceController {
### Правила
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
4. После добавления — перезапустите backend для применения
1. До отдельного решения владельца вся постоянная схема изменяется только в `V1__init.sql`
2. `V2__test_data.sql` предназначена исключительно для тестовых и демонстрационных данных;
постоянный DDL в ней запрещён
3. Не создавайте `V3` и следующие миграции, пока владелец не отменит режим пересобираемого baseline
4. Любое изменение V1/V2 требует полного сброса затронутых tenant-БД из-за checksum mismatch
5. Не сбрасывайте tenant-БД без прямой команды владельца
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
`V1__init.sql` зафиксирован как baseline: по прямому решению владельца в него объединено
содержимое прежних разработческих V2–V7, а также таблицы отсутствий, решений по заменам,
пожеланий и заявок преподавателей. Новые изменения существующих tenant-БД оформляются только
отдельными инкрементальными миграциями. Текущая `V2__backfill_default_theory_periods.sql`
заполняет кодом `Т` полностью пустые календарные графики и не изменяет V1.
`V1__init.sql` содержит только структуру: таблицы, функции, триггеры, индексы, ограничения
и комментарии. `V2__test_data.sql` содержит все прежние seed-записи, большой связанный набор
и заполнение периодов `Т` для полностью пустых календарных графиков. Такое разделение позволяет
отдельно проверить schema-only V1, но обе миграции по-прежнему автоматически применяются ко
всем tenant-БД.
### Применение
@@ -270,6 +270,10 @@ public class AbsenceController {
# Локально — сброс и повтор всех миграций
docker compose down -v && docker compose up -d
# Интеграционная проверка V1/V2 на PostgreSQL через Testcontainers
cd backend
mvn -Dtest='com.magistr.app.migration.*IntegrationTest' test
# Продакшн — применить к существующим тенантам
kubectl rollout restart deployment backend -n magistr
```

View File

@@ -4,13 +4,17 @@
## Статус
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД.
Новая модель является базовой схемой проекта и создаётся в
`backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по
типам занятий уже включены в schema-only baseline, а все демонстрационные записи загружаются
отдельно из `V2__test_data.sql`. Применение рассчитано на полный сброс БД.
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
## База данных
`V1__init.sql` создаёт и заполняет:
`V1__init.sql` создаёт структуру следующих таблиц, а `V2__test_data.sql` заполняет их
связанным тестовым набором:
| Таблица | Назначение |
|---------|------------|

File diff suppressed because one or more lines are too long

View File

@@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик
| `admin` | `admin` | Администратор |
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
Эти и остальные прежние демонстрационные записи загружаются миграцией
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
### Полезные команды
```bash

View File

@@ -37,7 +37,8 @@
2. Для группы находится назначенный календарный учебный график.
3. По году начала обучения вычисляется текущий курс группы.
4. По курсу и дате находится код активности.
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`.
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в тестовых данных
`V2__test_data.sql` это `Т`.
6. Для разрешённого дня выбираются активные правила и слоты.
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.

View File

@@ -27,7 +27,8 @@
## Проверочный сценарий
1. Полностью сбросить локальную БД.
2. Запустить приложение и применить `V1__init.sql`.
2. Запустить приложение и применить schema-only `V1__init.sql`, затем тестовый набор
`V2__test_data.sql`.
3. Создать профиль специальности.
4. Создать календарный график и заполнить дневную сетку.
5. Назначить график группе.

View File

@@ -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;
}

View File

@@ -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; }

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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'));

View File

@@ -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 = `
<div class="async-combobox-control">
<input type="search" role="combobox" aria-autocomplete="list" aria-expanded="false"
autocomplete="off" placeholder="${escapeText(placeholder)}">
<button type="button" class="async-combobox-clear" aria-label="Очистить выбор" ${allowEmpty ? '' : 'hidden'}>×</button>
</div>
<div class="async-combobox-menu" role="listbox" hidden></div>
`;
select.insertAdjacentElement('afterend', this.root);
this.input = this.root.querySelector('input');
this.menu = this.root.querySelector('[role="listbox"]');
this.clearButton = this.root.querySelector('.async-combobox-clear');
this.input.setAttribute('aria-label', select.getAttribute('aria-label') || placeholder);
this.labels = select.id
? [...document.querySelectorAll('label[for]')].filter(label => label.htmlFor === select.id)
: [];
if (select.id) {
this.input.id = `${select.id}-search`;
this.labels.forEach(label => { label.htmlFor = this.input.id; });
}
const listId = `${select.id || 'async-combobox'}-listbox`;
this.menu.id = listId;
this.input.setAttribute('aria-controls', listId);
this.input.addEventListener('focus', () => this.search(this.input.value));
this.input.addEventListener('input', debounce(() => this.search(this.input.value), 320));
this.input.addEventListener('keydown', event => this.handleKeydown(event));
this.clearButton.addEventListener('click', () => this.clear());
this.menu.addEventListener('mousedown', event => event.preventDefault());
this.menu.addEventListener('keydown', event => this.handleMenuKeydown(event));
this.handleOutsideClick = event => {
if (!this.root.contains(event.target)) this.close();
};
document.addEventListener('click', this.handleOutsideClick);
this.syncFromSelect();
}
async search(query = '') {
return this.loadMatches(query, true);
}
async preload(query = '') {
return this.loadMatches(query, false);
}
async loadMatches(query, revealMenu) {
const requestNumber = ++this.requestNumber;
if (revealMenu) this.open();
this.menu.innerHTML = '<div class="async-combobox-state"><span class="state-spinner" aria-hidden="true"></span>Поиск...</div>';
try {
const items = await this.load(query.trim(), this.select.value || '');
if (requestNumber !== this.requestNumber) return;
this.items = Array.isArray(items) ? items : [];
this.renderItems();
} catch (error) {
if (requestNumber !== this.requestNumber) return;
this.menu.innerHTML = `<div class="async-combobox-state is-error">${escapeText(error.message || 'Не удалось выполнить поиск')}</div>`;
}
}
renderItems() {
if (!this.items.length) {
this.menu.innerHTML = `<div class="async-combobox-state">${escapeText(this.emptyLabel)}</div>`;
return;
}
this.menu.innerHTML = this.items.map((item, index) => {
const value = String(this.optionValue(item));
const description = this.optionDescription(item);
return `<button type="button" role="option" class="async-combobox-option" data-index="${index}"
aria-selected="${String(this.select.value) === value}">
<strong>${escapeText(this.optionLabel(item))}</strong>
${description ? `<small>${escapeText(description)}</small>` : ''}
</button>`;
}).join('');
this.menu.querySelectorAll('[data-index]').forEach(button => {
button.addEventListener('click', () => this.choose(this.items[Number(button.dataset.index)]));
});
}
choose(item) {
const value = String(this.optionValue(item));
const label = this.optionLabel(item);
this.select.innerHTML = `<option value="${escapeText(value)}" selected>${escapeText(label)}</option>`;
this.select.value = value;
this.input.value = label;
this.input.dataset.selectedValue = value;
this.select.dispatchEvent(new Event('change', { bubbles: true }));
this.close();
}
clear() {
this.select.innerHTML = '<option value="" selected></option>';
this.select.value = '';
this.input.value = '';
delete this.input.dataset.selectedValue;
this.select.dispatchEvent(new Event('change', { bubbles: true }));
this.input.focus();
this.search('');
}
syncFromSelect() {
const selected = this.select.selectedOptions?.[0];
this.input.value = selected?.value ? selected.textContent.trim() : '';
if (selected?.value) this.input.dataset.selectedValue = selected.value;
}
open() {
this.menu.hidden = false;
this.input.setAttribute('aria-expanded', 'true');
this.root.classList.add('is-open');
}
close() {
this.menu.hidden = true;
this.input.setAttribute('aria-expanded', 'false');
this.root.classList.remove('is-open');
this.syncFromSelect();
}
handleKeydown(event) {
const options = [...this.menu.querySelectorAll('[role="option"]')];
const current = options.indexOf(document.activeElement);
if (event.key === 'Escape') {
this.close();
} else if (event.key === 'ArrowDown' && options.length) {
event.preventDefault();
options[Math.min(current + 1, options.length - 1)].focus();
} else if (event.key === 'ArrowUp' && options.length) {
event.preventDefault();
options[Math.max(current - 1, 0)].focus();
}
}
handleMenuKeydown(event) {
const options = [...this.menu.querySelectorAll('[role="option"]')];
const current = options.indexOf(document.activeElement);
if (event.key === 'Escape') {
event.preventDefault();
this.close();
this.input.focus();
} else if (event.key === 'ArrowDown' && current >= 0) {
event.preventDefault();
options[Math.min(current + 1, options.length - 1)]?.focus();
} else if (event.key === 'ArrowUp' && current >= 0) {
event.preventDefault();
(current === 0 ? this.input : options[current - 1])?.focus();
}
}
destroy() {
document.removeEventListener('click', this.handleOutsideClick);
this.requestNumber += 1;
this.root.remove();
this.select.hidden = false;
this.labels.forEach(label => { label.htmlFor = this.select.id; });
}
}
function escapeText(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}

103
frontend/admin/js/dialog.js Normal file
View File

@@ -0,0 +1,103 @@
let activeDialog = null;
function ensureDialogRoot() {
let root = document.getElementById('project-dialog-root');
if (!root) {
root = document.createElement('div');
root.id = 'project-dialog-root';
document.body.appendChild(root);
}
return root;
}
export function showDialog({ title, message, confirmLabel = 'Подтвердить', cancelLabel = 'Отмена', danger = false, input = null }) {
activeDialog?.close(false);
const root = ensureDialogRoot();
const previousFocus = document.activeElement;
root.innerHTML = `
<div class="project-dialog-backdrop">
<section class="project-dialog" role="dialog" aria-modal="true" aria-labelledby="project-dialog-title" aria-describedby="project-dialog-message">
<header>
<h2 id="project-dialog-title">${escapeText(title || 'Подтвердите действие')}</h2>
<button type="button" class="btn btn-icon-md btn-ghost" data-dialog-cancel aria-label="Закрыть">×</button>
</header>
<p id="project-dialog-message">${escapeText(message || '')}</p>
${input ? `<label class="project-dialog-field"><span>${escapeText(input.label || 'Комментарий')}</span><textarea data-dialog-input rows="4" maxlength="${Number(input.maxlength || 2000)}" placeholder="${escapeText(input.placeholder || '')}">${escapeText(input.value || '')}</textarea></label>` : ''}
<div class="project-dialog-error" data-dialog-error role="alert"></div>
<footer>
${cancelLabel ? `<button type="button" class="btn btn-md btn-secondary" data-dialog-cancel>${escapeText(cancelLabel)}</button>` : ''}
<button type="button" class="btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}" data-dialog-confirm>${escapeText(confirmLabel)}</button>
</footer>
</section>
</div>
`;
const backdrop = root.firstElementChild;
const dialog = backdrop.querySelector('.project-dialog');
const inputElement = dialog.querySelector('[data-dialog-input]');
return new Promise(resolve => {
const close = result => {
document.removeEventListener('keydown', handleKeydown);
root.innerHTML = '';
activeDialog = null;
previousFocus?.focus?.();
resolve(result);
};
activeDialog = { close };
const cancel = () => close(input ? null : false);
const confirm = () => {
if (input?.required && !inputElement?.value.trim()) {
dialog.querySelector('[data-dialog-error]').textContent = input.requiredMessage || 'Заполните поле';
inputElement?.focus();
return;
}
close(input ? inputElement.value.trim() : true);
};
const handleKeydown = event => {
if (event.key === 'Escape') cancel();
if (event.key === 'Tab') trapFocus(event, dialog);
};
document.addEventListener('keydown', handleKeydown);
dialog.querySelectorAll('[data-dialog-cancel]').forEach(button => button.addEventListener('click', cancel));
dialog.querySelector('[data-dialog-confirm]').addEventListener('click', confirm);
backdrop.addEventListener('click', event => {
if (event.target === backdrop) cancel();
});
window.setTimeout(() => (inputElement || dialog.querySelector('[data-dialog-confirm]')).focus(), 0);
});
}
export function confirmAction(options) {
return showDialog(options);
}
export function promptAction(options) {
return showDialog({ ...options, input: options.input || { label: 'Комментарий' } });
}
export function alertAction(options) {
return showDialog({ ...options, cancelLabel: null, confirmLabel: options.confirmLabel || 'Понятно' });
}
function trapFocus(event, root) {
const focusable = [...root.querySelectorAll('button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')];
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function escapeText(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}

View File

@@ -0,0 +1,36 @@
import { confirmAction } from './dialog.js';
const scopes = new Map();
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', event => {
if (![...scopes.values()].some(scope => scope.dirty)) return;
event.preventDefault();
event.returnValue = '';
});
}
export function createDirtyScope(name, message = 'Есть несохранённые изменения. Покинуть страницу и потерять их?') {
const scope = { name, message, dirty: false };
scopes.set(name, scope);
return {
mark() { scope.dirty = true; document.dispatchEvent(new CustomEvent('magistr:dirty-change')); },
clear() { scope.dirty = false; document.dispatchEvent(new CustomEvent('magistr:dirty-change')); },
isDirty() { return scope.dirty; },
dispose() { scopes.delete(name); }
};
}
export async function confirmAbandonDirtyChanges() {
const dirty = [...scopes.values()].filter(scope => scope.dirty);
if (!dirty.length) return true;
const confirmed = await confirmAction({
title: 'Несохранённые изменения',
message: dirty[0].message,
confirmLabel: 'Покинуть без сохранения',
cancelLabel: 'Остаться',
danger: true
});
if (confirmed) dirty.forEach(scope => { scope.dirty = false; });
return confirmed;
}

View File

@@ -3,6 +3,8 @@ import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
import { initializeTelemetry } from '../../telemetry.js';
import { confirmAbandonDirtyChanges } from './dirty-state.js';
import { trackUxEvent } from '../../telemetry.js';
async function bootstrap() {
const session = await initializeSession();
@@ -224,6 +226,7 @@ navItems.forEach(item => {
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
if (currentTab && !(await confirmAbandonDirtyChanges())) return;
// UI Update
navItems.forEach(n => n.classList.remove('active'));
@@ -245,6 +248,7 @@ async function switchTab(tab) {
}
currentTab = tab;
trackUxEvent('раздел_открыт', { tab, role: currentRole });
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
@@ -281,7 +285,7 @@ async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
? Number((await api.get('/api/teacher-requests/page?status=PENDING&page=0&size=1')).totalItems || 0)
: countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));

View File

@@ -0,0 +1,34 @@
import { escapeHtml } from './utils.js';
export function renderPagination(container, pageData, { onPage, onSize, label = 'записей' } = {}) {
if (!container) return;
const totalItems = Number(pageData?.totalItems || 0);
const page = Number(pageData?.page || 0);
const size = Number(pageData?.size || 25);
const totalPages = Math.max(Number(pageData?.totalPages || 0), totalItems ? 1 : 0);
const start = totalItems ? page * size + 1 : 0;
const end = Math.min((page + 1) * size, totalItems);
container.innerHTML = `
<div class="pagination-summary" aria-live="polite">
Показано ${start}–${end} из ${totalItems} ${escapeHtml(label)}
</div>
<div class="pagination-actions">
<label class="pagination-size">
<span>На странице</span>
<select data-pagination-size data-native-select="true">
${[25, 50, 100].map(value => `<option value="${value}" ${value === size ? 'selected' : ''}>${value}</option>`).join('')}
</select>
</label>
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page - 1}" ${page <= 0 ? 'disabled' : ''}>Назад</button>
<span class="pagination-page">${totalPages ? page + 1 : 0} / ${totalPages}</span>
<button type="button" class="btn btn-sm btn-secondary" data-pagination-page="${page + 1}" ${page >= totalPages - 1 ? 'disabled' : ''}>Далее</button>
</div>
`;
container.querySelectorAll('[data-pagination-page]').forEach(button => {
button.addEventListener('click', () => onPage?.(Number(button.dataset.paginationPage)));
});
container.querySelector('[data-pagination-size]')?.addEventListener('change', event => {
onSize?.(Number(event.target.value));
});
}

View File

@@ -0,0 +1,34 @@
export function readUrlState(defaults = {}) {
const params = new URLSearchParams(window.location.search);
return Object.fromEntries(Object.entries(defaults).map(([key, fallback]) => [
key,
params.has(key) ? params.get(key) : fallback
]));
}
export function writeUrlState(changes, { replace = true } = {}) {
const url = new URL(window.location.href);
Object.entries(changes).forEach(([key, value]) => {
if (value === undefined || value === null || value === '' || value === false) {
url.searchParams.delete(key);
} else if (Array.isArray(value)) {
url.searchParams.delete(key);
value.forEach(item => url.searchParams.append(key, String(item)));
} else {
url.searchParams.set(key, String(value));
}
});
window.history[replace ? 'replaceState' : 'pushState']({}, '', `${url.pathname}${url.search}${url.hash}`);
}
export function readUrlValues(key) {
return new URLSearchParams(window.location.search).getAll(key);
}
export function debounce(callback, delay = 320) {
let timer = null;
return (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => callback(...args), delay);
};
}

View File

@@ -0,0 +1,24 @@
import { escapeHtml } from './utils.js';
export function renderTableLoading(tbody, columns, message = 'Загрузка данных...') {
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row"><span class="state-spinner" aria-hidden="true"></span>${escapeHtml(message)}</td></tr>`;
tbody.setAttribute('aria-busy', 'true');
}
export function renderTableEmpty(tbody, columns, message = 'Данных пока нет') {
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row view-state-empty">${escapeHtml(message)}</td></tr>`;
tbody.setAttribute('aria-busy', 'false');
}
export function renderTableError(tbody, columns, message, retry) {
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row view-state-error">
<span>${escapeHtml(message || 'Не удалось загрузить данные')}</span>
${retry ? '<button type="button" class="btn btn-sm btn-secondary" data-view-retry>Повторить</button>' : ''}
</td></tr>`;
tbody.setAttribute('aria-busy', 'false');
tbody.querySelector('[data-view-retry]')?.addEventListener('click', retry);
}
export function markTableReady(tbody) {
tbody.setAttribute('aria-busy', 'false');
}

View File

@@ -1,8 +1,14 @@
import { api } from '../api.js';
import { AsyncCombobox } from '../async-combobox.js';
import { renderPagination } from '../pagination.js';
import { debounce } from '../url-state.js';
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
import { confirmAction } from '../dialog.js';
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
import { trackUxEvent } from '../../../telemetry.js';
const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
@@ -206,6 +212,14 @@ export async function initAcademicCalendar() {
let studyForms = [];
let activityTypes = [];
let calendars = [];
let calendarPage = 0;
let calendarSize = 25;
let calendarRequest = 0;
const calendarCache = new Map();
const calendarComboboxes = new Map();
const calendarSearch = document.getElementById('academic-calendars-search');
const calendarYearFilter = document.getElementById('academic-calendars-year-filter');
const calendarPagination = document.getElementById('academic-calendars-pagination');
let subjects = [];
let calendarSubjectRows = [];
let timeSlotScopes = [];
@@ -219,6 +233,28 @@ export async function initAcademicCalendar() {
let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null;
let calendarDayTooltipPositionAnimation = null;
let loadedGridCalendarId = '';
let loadedSubjectCalendarId = '';
let subjectLoadRequest = 0;
let gridLoadRequest = 0;
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
[editorCalendarSelect, subjectCalendarSelect].filter(Boolean).forEach(select => {
select.innerHTML = '<option value="">Выберите график</option>';
calendarComboboxes.set(select, new AsyncCombobox(select, {
placeholder: 'Поиск по направлению, профилю, форме или году',
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title,
optionDescription: calendar => calendar.specialtyName || '',
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: 20 });
if (selectedId) params.set('selectedId', selectedId);
const items = await api.get(`/api/admin/academic-calendars/options?${params}`);
items.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
return items;
}
}));
});
bindEvents();
@@ -248,9 +284,20 @@ export async function initAcademicCalendar() {
].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
button.addEventListener('click', async () => {
if (!await confirmAbandonDirtyChanges()) return;
activateCalendarTab(button.dataset.calendarTab);
});
});
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarSearch?.addEventListener('input', debounce(() => {
calendarPage = 0;
loadCalendars();
}, 300));
calendarYearFilter?.addEventListener('change', () => {
calendarPage = 0;
loadCalendars();
});
calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
@@ -269,6 +316,7 @@ export async function initAcademicCalendar() {
if (event.target.matches('.calendar-day-activity')) {
updateCalendarDayVisual(event.target.closest('.calendar-day'));
renderCalendarTotals();
markGridDirty();
}
});
calendarGrid?.addEventListener('mousedown', (event) => {
@@ -320,6 +368,7 @@ export async function initAcademicCalendar() {
});
updateCalendarDayDialogSummaryForSelection(days);
renderCalendarTotals();
markGridDirty();
});
calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog);
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
@@ -351,6 +400,11 @@ export async function initAcademicCalendar() {
academicYears = await api.get('/api/admin/calendar/years');
renderAcademicYears();
populateYearSelects();
const selectedYear = calendarYearFilter.value;
calendarYearFilter.innerHTML = '<option value="">Все учебные годы</option>' + academicYears.map(year =>
`<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
calendarYearFilter.value = selectedYear;
refreshSelectVisuals(calendarYearFilter);
} catch (error) {
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error;
@@ -441,7 +495,13 @@ export async function initAcademicCalendar() {
}
if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return;
const confirmed = await confirmAction({
title: 'Удаление учебного года',
message: 'Удалить учебный год вместе с семестрами и календарными графиками?',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) return;
try {
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
@@ -548,13 +608,28 @@ export async function initAcademicCalendar() {
}
async function loadCalendars() {
const request = ++calendarRequest;
calendarPagination?.replaceChildren();
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try {
calendars = await api.get('/api/admin/academic-calendars');
const params = new URLSearchParams({ page: calendarPage, size: calendarSize, query: calendarSearch?.value.trim() || '' });
if (calendarYearFilter?.value) params.set('academicYearId', calendarYearFilter.value);
const page = await api.get(`/api/admin/academic-calendars/page?${params}`);
if (request !== calendarRequest) return;
if (calendarPage > 0 && calendarPage >= page.totalPages) {
calendarPage = Math.max(0, page.totalPages - 1);
return loadCalendars();
}
calendars = page.items;
calendars.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
renderCalendars();
populateEditorCalendarSelect();
populateSubjectCalendarSelect();
renderPagination(calendarPagination, page, {
label: 'графиков',
onPage: value => { calendarPage = value; loadCalendars(); },
onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); }
});
} catch (error) {
if (request !== calendarRequest) return;
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error;
}
@@ -562,7 +637,7 @@ export async function initAcademicCalendar() {
function renderCalendars() {
if (!calendars.length) {
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не созданы</td></tr>';
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не найдены</td></tr>';
return;
}
calendarsTbody.innerHTML = calendars.map(calendar => `
@@ -602,6 +677,10 @@ export async function initAcademicCalendar() {
const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload);
calendarCache.set(String(saved.id), saved);
calendarComboboxes.forEach((combobox, select) => {
if (select.value === String(saved.id)) setCalendarSelection(select, saved.id);
});
const savedTitle = buildAcademicCalendarTitle(saved);
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
resetCalendarForm();
@@ -636,7 +715,7 @@ export async function initAcademicCalendar() {
if (gridButton) {
activateCalendarTab('grid');
editorCalendarSelect.value = gridButton.dataset.id;
setCalendarSelection(editorCalendarSelect, gridButton.dataset.id);
refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -645,7 +724,7 @@ export async function initAcademicCalendar() {
if (subjectsButton) {
activateCalendarTab('subjects');
subjectCalendarSelect.value = subjectsButton.dataset.id;
setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id);
refreshSelectVisuals(subjectCalendarSelect);
await loadCalendarSubjects();
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -653,9 +732,30 @@ export async function initAcademicCalendar() {
}
if (deleteButton) {
if (!confirm('Удалить календарный график?')) return;
const confirmed = await confirmAction({
title: 'Удаление календарного графика',
message: 'Удалить календарный график? Связанные настройки больше не будут доступны.',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) return;
try {
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
const deletedId = deleteButton.dataset.id;
await api.delete('/api/admin/academic-calendars/' + deletedId);
calendarCache.delete(deletedId);
calendarComboboxes.forEach((combobox, select) => {
if (select.value === deletedId) {
setCalendarSelection(select, '');
if (select === editorCalendarSelect) {
loadedGridCalendarId = '';
calendarGrid.replaceChildren();
calendarTotals.replaceChildren();
clearGridDirty();
} else {
loadCalendarSubjects();
}
}
});
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
await loadCalendars();
} catch (error) {
@@ -689,20 +789,34 @@ export async function initAcademicCalendar() {
}
async function loadCalendarSubjects() {
const request = ++subjectLoadRequest;
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId);
refreshSelectVisuals(subjectCalendarSelect);
return;
}
if (request !== subjectLoadRequest) return;
populateSubjectSemesterSelect();
calendarSubjectRows = [];
loadedSubjectCalendarId = '';
if (!calendar) {
loadedSubjectCalendarId = '';
clearSubjectDirty();
renderCalendarSubjects();
return;
}
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return;
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
loadedSubjectCalendarId = String(calendar.id);
clearSubjectDirty();
renderCalendarSubjects();
} catch (error) {
if (request !== subjectLoadRequest) return;
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
}
@@ -711,6 +825,10 @@ export async function initAcademicCalendar() {
function addCalendarSubject() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
const semesterNumber = Number(subjectSemesterSelect.value);
const subjectId = Number(subjectSelect.value);
if (!calendar || !semesterNumber || !subjectId) {
@@ -741,12 +859,17 @@ export async function initAcademicCalendar() {
}
]);
renderCalendarSubjects();
markSubjectDirty();
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
}
async function saveCalendarSubjects() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
if (!calendar) {
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
return;
@@ -759,6 +882,8 @@ export async function initAcademicCalendar() {
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
calendarSubjectRows = sortCalendarSubjectRows(saved || []);
renderCalendarSubjects();
clearSubjectDirty();
trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length });
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
} catch (error) {
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
@@ -774,6 +899,7 @@ export async function initAcademicCalendar() {
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
);
renderCalendarSubjects();
markSubjectDirty();
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
}
@@ -818,8 +944,14 @@ export async function initAcademicCalendar() {
}
async function loadCalendarGrid() {
const request = ++gridLoadRequest;
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
setCalendarSelection(editorCalendarSelect, loadedGridCalendarId);
refreshSelectVisuals(editorCalendarSelect);
return;
}
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
@@ -827,10 +959,15 @@ export async function initAcademicCalendar() {
try {
await loadTimeSlotDateAssignments();
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
if (request !== gridLoadRequest || String(calendar.id) !== editorCalendarSelect.value) return;
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(gridRows);
loadedGridCalendarId = String(calendar.id);
clearGridDirty();
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) {
if (request !== gridLoadRequest) return;
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
}
}
@@ -842,6 +979,10 @@ export async function initAcademicCalendar() {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
if (String(calendar.id) !== loadedGridCalendarId) {
showAlert('calendar-editor-alert', 'Сначала загрузите сетку выбранного графика', 'error');
return;
}
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
calendarId: calendar.id,
courseNumber: Number(day.dataset.course),
@@ -856,6 +997,8 @@ export async function initAcademicCalendar() {
}
try {
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
clearGridDirty();
trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length });
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
await loadCalendarGrid();
} catch (error) {
@@ -895,9 +1038,30 @@ export async function initAcademicCalendar() {
}
});
renderCalendarTotals();
if (changed) markGridDirty();
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
}
function markGridDirty() {
gridDirtyScope.mark();
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку •';
}
function clearGridDirty() {
gridDirtyScope.clear();
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку';
}
function markSubjectDirty() {
subjectDirtyScope.mark();
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки •';
}
function clearSubjectDirty() {
subjectDirtyScope.clear();
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки';
}
function renderCalendarGrid(rows) {
activeCalendarDay = null;
selectedCalendarDayKey = null;
@@ -1467,31 +1631,12 @@ export async function initAcademicCalendar() {
syncSelects(fillActivitySelect);
}
function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value;
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('');
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current;
}
syncSelects(editorCalendarSelect);
}
function populateSubjectCalendarSelect() {
if (!subjectCalendarSelect) return;
const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('')
: '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
subjectCalendarSelect.value = current;
}
populateSubjectSemesterSelect();
syncSelects(subjectCalendarSelect);
function setCalendarSelection(select, id) {
const calendar = calendarCache.get(String(id));
select.innerHTML = calendar
? `<option value="${calendar.id}" selected>${escapeHtml(buildAcademicCalendarTitle(calendar) || calendar.title)}</option>`
: '<option value="">Выберите график</option>';
calendarComboboxes.get(select)?.syncFromSelect();
}
function populateSubjectSemesterSelect() {
@@ -1535,11 +1680,11 @@ export async function initAcademicCalendar() {
}
function selectedEditorCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
return calendarCache.get(String(editorCalendarSelect.value));
}
function selectedSubjectCalendar() {
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value));
return calendarCache.get(String(subjectCalendarSelect?.value));
}
function calendarSemesterCount(calendar) {
@@ -1727,6 +1872,7 @@ export async function initAcademicCalendar() {
function refreshSelectVisuals(...selects) {
selects.filter(Boolean).forEach(select => {
calendarComboboxes.get(select)?.syncFromSelect();
if (select.customSelectInstance) {
select.customSelectInstance.rebuildMenu();
}

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { alertAction, confirmAction } from '../dialog.js';
async function fetchEquipments() {
try {
@@ -131,11 +132,16 @@ export async function initClassrooms() {
const btnEdit = e.target.closest('.classroom-edit-action');
if (btnDelete) {
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
if (!await confirmAction({
title: 'Вывод аудитории из эксплуатации',
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
confirmLabel: 'Вывести из эксплуатации',
danger: true
})) return;
try {
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms();
} catch (err) { alert('Ошибка архивирования'); }
} catch (err) { await alertAction({ title: 'Ошибка архивирования', message: err.message || 'Не удалось архивировать аудиторию' }); }
}
const btnRestore = e.target.closest('.btn-restore-classroom');
@@ -143,7 +149,7 @@ export async function initClassrooms() {
try {
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
loadClassrooms();
} catch (err) { alert('Ошибка восстановления аудитории'); }
} catch (err) { await alertAction({ title: 'Ошибка восстановления', message: err.message || 'Не удалось восстановить аудиторию' }); }
}
if (btnToggleStatus) {
@@ -152,7 +158,7 @@ export async function initClassrooms() {
try {
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
loadClassrooms();
} catch (err) { alert('Ошибка изменения статуса'); }
} catch (err) { await alertAction({ title: 'Ошибка изменения статуса', message: err.message || 'Не удалось изменить доступность аудитории' }); }
}
if (btnEdit) {
@@ -178,7 +184,7 @@ export async function initClassrooms() {
hideAlert('edit-classroom-alert');
modalEditClassroom.classList.add('open');
} catch (e) {
alert('Ошибка загрузки данных аудитории');
await alertAction({ title: 'Ошибка загрузки', message: e.message || 'Не удалось загрузить данные аудитории' });
}
}
@@ -288,12 +294,17 @@ export async function initClassrooms() {
manageEquipmentTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete-equipment');
if (!btnDelete) return;
if (!confirm('Удалить оборудование из каталога?')) return;
if (!await confirmAction({
title: 'Удаление оборудования',
message: 'Удалить оборудование из каталога?',
confirmLabel: 'Удалить',
danger: true
})) return;
try {
await api.delete('/api/equipments/' + btnDelete.dataset.id);
await loadManageEquipments();
} catch (err) {
alert(err.message || 'Ошибка удаления');
await alertAction({ title: 'Ошибка удаления', message: err.message || 'Не удалось удалить оборудование' });
}
});

View File

@@ -1,6 +1,12 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
import { AsyncCombobox } from '../async-combobox.js';
import { alertAction, confirmAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, readUrlValues, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
async function fetchEducationForms() {
try {
@@ -24,6 +30,10 @@ export async function initGroups() {
const filterEfMenu = document.getElementById('filter-ef-menu');
const filterEfText = document.getElementById('filter-ef-text');
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
const searchInput = document.getElementById('groups-search');
const sortSelect = document.getElementById('groups-sort');
const filterResetButton = document.getElementById('groups-filter-reset');
const pagination = document.getElementById('groups-pagination');
// Модалка группы
const modalEditGroup = document.getElementById('modal-edit-group');
@@ -65,9 +75,19 @@ export async function initGroups() {
let specialties = [];
let profilesBySpecialty = new Map();
let academicYears = [];
let calendars = [];
let currentAssignments = [];
let activeSubgroupGroup = null;
let calendarCombobox = null;
const restored = readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' });
const pageState = {
query: restored.groupQuery,
page: Math.max(Number(restored.groupPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.groupSize)) ? Number(restored.groupSize) : 25,
sort: restored.groupSort,
educationFormIds: readUrlValues('groupEducationForm')
};
searchInput.value = pageState.query;
sortSelect.value = pageState.sort;
bindEvents();
await loadInitialData();
@@ -89,7 +109,30 @@ export async function initGroups() {
// Модалка календарей
manageCalendarForm.addEventListener('submit', saveAssignment);
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
manageCalendarYearSelect.addEventListener('change', () => {
calendarCombobox?.clear();
populateCalendarSelect();
});
searchInput.addEventListener('input', debounce(() => {
pageState.query = searchInput.value.trim();
pageState.page = 0;
loadGroups();
}));
sortSelect.addEventListener('change', () => {
pageState.sort = sortSelect.value;
pageState.page = 0;
loadGroups();
});
filterResetButton.addEventListener('click', () => {
pageState.query = '';
pageState.page = 0;
pageState.sort = 'name,asc';
pageState.educationFormIds = [];
searchInput.value = '';
sortSelect.value = pageState.sort;
renderFilterEfCheckboxes(educationForms);
loadGroups();
});
groupsTbody.addEventListener('click', handleGroupTableClick);
@@ -143,20 +186,19 @@ export async function initGroups() {
async function loadInitialData() {
try {
[educationForms, departments, specialties, academicYears, calendars] = await Promise.all([
[educationForms, departments, specialties, academicYears] = await Promise.all([
fetchEducationForms(),
api.get('/api/departments'),
api.get('/api/specialties'),
api.get('/api/admin/calendar/years'),
api.get('/api/admin/academic-calendars')
api.get('/api/admin/calendar/years')
]);
await loadProfiles();
populateEfSelects(educationForms);
populateDepartmentSelects(departments);
populateSpecialitySelects(specialties);
populateYearSelect();
initCalendarCombobox();
await loadGroups();
await loadSubgroups();
} catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
}
@@ -171,28 +213,50 @@ export async function initGroups() {
}
async function loadGroups() {
renderTableLoading(groupsTbody, 10, 'Загрузка групп...');
try {
allGroups = await api.get('/api/groups?includeArchived=true');
applyGroupFilter();
const [sort, direction] = pageState.sort.split(',');
const params = new URLSearchParams({
query: pageState.query,
page: String(pageState.page),
size: String(pageState.size),
sort,
direction,
includeArchived: 'true'
});
pageState.educationFormIds.forEach(id => params.append('educationFormId', id));
const result = await api.get(`/api/groups/page?${params}`);
allGroups = result.items || [];
if (!allGroups.length && result.totalItems > 0 && pageState.page > 0) {
pageState.page = Math.max(0, result.totalPages - 1);
return loadGroups();
}
renderGroups(allGroups);
renderPagination(pagination, result, {
label: 'групп',
onPage: page => { pageState.page = page; loadGroups(); },
onSize: size => { pageState.size = size; pageState.page = 0; loadGroups(); }
});
persistPageState();
trackUxEvent('реестр_групп_загружен', { total: Number(result.totalItems || 0), page: pageState.page });
} catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
renderTableError(groupsTbody, 10, error.message || 'Не удалось загрузить группы', loadGroups);
}
}
async function loadSubgroups() {
async function loadSubgroups(groupId) {
try {
subgroups = await api.get('/api/subgroups');
subgroups = groupId ? await api.get(`/api/groups/${groupId}/subgroups`) : [];
} catch (error) {
console.error('Не удалось загрузить подгруппы');
subgroups = [];
throw error;
}
}
function applyGroupFilter() {
const selectedIds = selectedFilterEducationFormIds();
const filtered = selectedIds.length
? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
: allGroups;
renderGroups(filtered);
pageState.educationFormIds = selectedFilterEducationFormIds();
pageState.page = 0;
loadGroups();
}
function handleFilterEfChange(event) {
@@ -247,7 +311,9 @@ export async function initGroups() {
function renderGroups(groups) {
if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
renderTableEmpty(groupsTbody, 10, pageState.query || pageState.educationFormIds.length
? 'По заданным условиям группы не найдены'
: 'Групп пока нет');
return;
}
groupsTbody.innerHTML = groups.map(group => `
@@ -273,6 +339,7 @@ export async function initGroups() {
</td>
</tr>
`).join('');
markTableReady(groupsTbody);
}
function populateEfSelects(forms) {
@@ -282,7 +349,7 @@ export async function initGroups() {
}
function renderFilterEfCheckboxes(forms) {
const selectedIds = new Set(selectedFilterEducationFormIds());
const selectedIds = new Set(pageState.educationFormIds);
const allChecked = !selectedIds.size;
filterEfCheckboxes.innerHTML = `
<label class="checkbox-item">
@@ -372,23 +439,40 @@ export async function initGroups() {
}
function populateCalendarSelect() {
const groupId = manageCalendarGroupIdInput.value;
const group = allGroups.find(g => g.id == groupId);
const yearId = manageCalendarYearSelect.value;
const matching = group && yearId
? calendars.filter(calendar =>
String(calendar.academicYearId) === String(yearId)
&& String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode)
&& String(calendar.specialtyProfileId) === String(group.specialtyProfileId)
&& String(calendar.studyFormId) === String(group.educationFormId)
)
: [];
manageCalendarSelect.innerHTML = matching.length
? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('')
: '<option value="">Нет подходящих графиков</option>';
syncSelects(manageCalendarSelect);
calendarCombobox?.search('');
}
function initCalendarCombobox() {
calendarCombobox = new AsyncCombobox(manageCalendarSelect, {
placeholder: 'Найдите совместимый график',
emptyLabel: 'Совместимых графиков не найдено',
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`,
optionDescription: calendar => `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`,
load: async (query, selectedId) => {
const group = allGroups.find(item => String(item.id) === String(manageCalendarGroupIdInput.value));
if (!group || !manageCalendarYearSelect.value) return [];
const params = new URLSearchParams({
query,
academicYearId: manageCalendarYearSelect.value,
specialtyId: String(group.specialtyId || group.specialityCode),
profileId: String(group.specialtyProfileId),
studyFormId: String(group.educationFormId),
limit: '30'
});
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/admin/academic-calendars/options?${params}`);
}
});
}
function persistPageState() {
writeUrlState({
groupQuery: pageState.query,
groupPage: pageState.page || '',
groupSize: pageState.size === 25 ? '' : pageState.size,
groupSort: pageState.sort === 'name,asc' ? '' : pageState.sort,
groupEducationForm: pageState.educationFormIds
});
}
async function createGroup(event) {
@@ -463,12 +547,17 @@ export async function initGroups() {
const btnCalendar = event.target.closest('.btn-manage-calendar');
if (btnDelete) {
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
if (!await confirmAction({
title: 'Архивировать группу?',
message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try {
await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups();
} catch (error) {
alert(error.message || 'Ошибка архивирования');
await alertAction({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' });
}
}
@@ -477,7 +566,7 @@ export async function initGroups() {
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
await loadGroups();
} catch (error) {
alert(error.message || 'Ошибка восстановления');
await alertAction({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' });
}
}
@@ -488,7 +577,7 @@ export async function initGroups() {
if (btnSubgroups) {
const groupId = btnSubgroups.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openSubgroupsModal(group);
if (group) await openSubgroupsModal(group);
}
if (btnCalendar) {
@@ -498,10 +587,10 @@ export async function initGroups() {
}
}
function openEditGroupModal(id) {
async function openEditGroupModal(id) {
const group = allGroups.find(item => item.id == id);
if (!group) {
alert('Группа не найдена');
await alertAction({ title: 'Группа не найдена', message: 'Обновите список и повторите действие.' });
return;
}
@@ -519,13 +608,20 @@ export async function initGroups() {
}
// --- Управление подгруппами ---
function openSubgroupsModal(group) {
async function openSubgroupsModal(group) {
activeSubgroupGroup = group;
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
manageSubgroupGroupIdInput.value = group.id;
resetSubgroupForm();
modalManageSubgroups.classList.add('open');
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка подгрупп...</td></tr>';
try {
await loadSubgroups(group.id);
resetSubgroupForm();
renderSubgroupsList(group.id);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Не удалось загрузить подгруппы', 'error');
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Не удалось загрузить подгруппы</td></tr>';
}
}
function renderSubgroupsList(groupId) {
@@ -633,7 +729,7 @@ export async function initGroups() {
try {
await syncSubgroups(groupId, existingSubgroups, capacities);
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
await loadSubgroups();
await loadSubgroups(groupId);
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
@@ -660,7 +756,7 @@ export async function initGroups() {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
await loadSubgroups();
await loadSubgroups(groupId);
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
@@ -727,12 +823,17 @@ export async function initGroups() {
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
return;
}
if (!confirm('Удалить подгруппу?')) return;
if (!await confirmAction({
title: 'Удалить подгруппу?',
message: 'Удаление возможно только если подгруппа не используется в расписании.',
confirmLabel: 'Удалить',
danger: true
})) return;
try {
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
resetSubgroupForm();
await loadSubgroups();
await loadSubgroups(groupId);
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
@@ -778,10 +879,7 @@ export async function initGroups() {
}
function assignmentCalendarTitle(assignment) {
const calendar = calendars.find(item =>
String(item.id) === String(assignment.calendarId)
);
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
return assignment.calendarTitle || '-';
}
function renderAssignmentSubjects(subjects) {
@@ -831,7 +929,12 @@ export async function initGroups() {
manageCalendarTbody.addEventListener('click', async (event) => {
const deleteButton = event.target.closest('.btn-delete-assignment');
if (!deleteButton) return;
if (!confirm('Удалить назначение графика?')) return;
if (!await confirmAction({
title: 'Удалить назначение графика?',
message: 'Группа потеряет связь с этим графиком на выбранный учебный год.',
confirmLabel: 'Удалить назначение',
danger: true
})) return;
const groupId = manageCalendarGroupIdInput.value;
try {

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { AsyncCombobox } from '../async-combobox.js';
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
const ACTION_LABELS = {
@@ -83,7 +84,30 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
};
registryButton.hidden = false;
fillReferenceSelects();
const teacherCombobox = new AsyncCombobox(teacherSelect, {
placeholder: 'Найдите преподавателя',
optionLabel: teacher => teacher.fullName || teacher.username,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
const items = await api.get(`/api/users/teachers/options?${params}`);
mergeDictionary('teachers', items);
return items;
}
});
const classroomCombobox = new AsyncCombobox(classroomSelect, {
placeholder: 'Найдите аудиторию',
optionLabel: classroomOptionLabel,
optionDescription: room => [room.building, room.floor ? `${room.floor} этаж` : null, room.capacity ? `${room.capacity} мест` : null].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
const items = await api.get(`/api/classrooms/options?${params}`);
mergeDictionary('classrooms', items);
return items;
}
});
bindEvents();
renderRegistry();
@@ -226,15 +250,6 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
};
}
function fillReferenceSelects() {
teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher =>
`<option value="${escapeHtml(teacher.id)}">${escapeHtml(teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`)}</option>`
).join('');
classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom =>
`<option value="${escapeHtml(classroom.id)}">${escapeHtml(classroomOptionLabel(classroom))}</option>`
).join('');
}
function setEditorValues(existing) {
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
@@ -250,6 +265,8 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
);
teacherSelect.value = String(effectiveTeacherId);
classroomSelect.value = String(effectiveClassroomId);
teacherCombobox.syncFromSelect();
classroomCombobox.syncFromSelect();
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
commentInput.value = existing?.comment || '';
}
@@ -515,6 +532,12 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
function mergeDictionary(key, items) {
const current = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
(items || []).forEach(item => current.set(String(item.id), item));
dictionaries[key] = [...current.values()];
}
}
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {

View File

@@ -1,13 +1,29 @@
import { api } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml } from '../utils.js';
import { trackUxEvent } from '../../../telemetry.js';
let qualityState = null;
export async function initScheduleQuality() {
const root = document.querySelector('.quality-lab');
if (!root) return;
qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false };
qualityState = {
root,
analysis: null,
semesters: [],
academicYearId: '',
versions: [],
visibleProblems: [],
selectedProblemId: null,
applying: false
};
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
root.querySelector('#quality-year')?.addEventListener('change', handleYearChange);
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
@@ -20,26 +36,18 @@ async function loadSemesters() {
const select = qualityState?.root.querySelector('#quality-semester');
if (!select) return;
try {
const semesters = await api.get('/api/schedule/semesters');
select.innerHTML = (semesters || []).map(semester => `
<option value="${escapeHtml(String(semester.id))}">
${escapeHtml(semesterLabel(semester))}
</option>
`).join('');
if (!semesters?.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
qualityState.semesters = await api.get('/api/schedule/semesters');
if (!qualityState.semesters?.length) {
renderPeriodSelects('', '');
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
return;
}
const today = localIsoDate(new Date());
const current = semesters.find(semester =>
semester.startDate <= today && semester.endDate >= today
);
select.value = String(current?.id ?? semesters[0].id);
const savedSemesterId = localStorage.getItem('magistr.quality.semesterId');
if (semesters.some(semester => String(semester.id) === savedSemesterId)) {
select.value = savedSemesterId;
}
const selected = resolveAcademicPeriod(qualityState.semesters, {
academicYearId: localStorage.getItem('magistr.quality.yearId'),
semesterId: localStorage.getItem('magistr.quality.semesterId'),
date: localIsoDate(new Date())
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
await loadVersions();
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
@@ -47,6 +55,39 @@ async function loadSemesters() {
}
}
async function handleYearChange() {
const yearId = qualityState?.root.querySelector('#quality-year')?.value || '';
const selected = resolveAcademicPeriod(qualityState.semesters, {
academicYearId: yearId,
date: localIsoDate(new Date())
});
renderPeriodSelects(yearId, selected.semesterId);
await loadVersions();
}
function renderPeriodSelects(academicYearId, semesterId) {
const yearSelect = qualityState?.root.querySelector('#quality-year');
const semesterSelect = qualityState?.root.querySelector('#quality-semester');
if (!yearSelect || !semesterSelect) return;
const years = academicYearsFromSemesters(qualityState.semesters);
qualityState.academicYearId = String(academicYearId || years[0]?.id || '');
yearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
yearSelect.value = qualityState.academicYearId;
const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId);
semesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| yearSemesters[0];
semesterSelect.value = String(selectedSemester?.id || '');
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
}
async function loadVersions() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const select = qualityState?.root.querySelector('#quality-version');
@@ -69,6 +110,7 @@ async function loadVersions() {
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
select.value = String((saved || published || qualityState.versions[0]).id);
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
localStorage.setItem('magistr.quality.semesterId', semesterId);
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
@@ -100,6 +142,11 @@ async function runAnalysis() {
);
qualityState.selectedProblemId = null;
renderAnalysis();
trackUxEvent('качество_расписания_проверено', {
semesterId,
versionId,
score: Number(qualityState.analysis.score || 0)
});
qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
@@ -261,6 +308,7 @@ function handleRulePanelClick(event) {
function openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
localStorage.setItem('magistr.schedule.semesterId', String(qualityState.analysis.semesterId));
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
@@ -291,7 +339,7 @@ export function filterQualityProblems(problems, severity = '', category = '') {
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches;
});
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
}
function problemCategory(type = '') {

View File

@@ -1,5 +1,11 @@
import { api } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml } from '../utils.js';
import { trackUxEvent } from '../../../telemetry.js';
let versionState = null;
@@ -9,6 +15,7 @@ export async function initScheduleVersions() {
versionState = {
root,
semesters: [],
academicYearId: '',
versions: [],
history: [],
modal: null,
@@ -20,6 +27,7 @@ export async function initScheduleVersions() {
function bindEvents() {
const root = versionState.root;
root.querySelector('#version-year')?.addEventListener('change', handleYearChange);
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
@@ -37,34 +45,62 @@ function bindEvents() {
}
async function loadSemesters() {
const select = versionState.root.querySelector('#version-semester');
try {
versionState.semesters = await api.get('/api/schedule/semesters');
select.innerHTML = versionState.semesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('');
if (!versionState.semesters.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
renderPeriodSelects('', '');
setStatus('Сначала создайте учебный год и семестр.', 'error');
return;
}
const savedSemester = localStorage.getItem('magistr.schedule.semesterId');
const today = localIsoDate(new Date());
const current = versionState.semesters.find(item => item.startDate <= today && item.endDate >= today);
const selected = versionState.semesters.find(item => String(item.id) === savedSemester)
|| current
|| versionState.semesters[0];
select.value = String(selected.id);
const selected = resolveAcademicPeriod(versionState.semesters, {
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
date: localIsoDate(new Date())
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function handleYearChange() {
const yearId = versionState.root.querySelector('#version-year')?.value || '';
const selected = resolveAcademicPeriod(versionState.semesters, {
academicYearId: yearId,
date: localIsoDate(new Date())
});
renderPeriodSelects(yearId, selected.semesterId);
await loadWorkspace();
}
function renderPeriodSelects(academicYearId, semesterId) {
const yearSelect = versionState.root.querySelector('#version-year');
const semesterSelect = versionState.root.querySelector('#version-semester');
const years = academicYearsFromSemesters(versionState.semesters);
versionState.academicYearId = String(academicYearId || years[0]?.id || '');
yearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
yearSelect.value = versionState.academicYearId;
const yearSemesters = semestersForAcademicYear(versionState.semesters, versionState.academicYearId);
semesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| yearSemesters[0];
semesterSelect.value = String(selectedSemester?.id || '');
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
}
async function loadWorkspace() {
const semesterId = selectedSemesterId();
if (!semesterId || versionState.loading) return;
versionState.loading = true;
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
localStorage.setItem('magistr.schedule.semesterId', semesterId);
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
try {
@@ -73,6 +109,7 @@ async function loadWorkspace() {
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
]);
renderWorkspace();
trackUxEvent('версии_расписания_загружены', { semesterId, total: versionState.versions.length });
setStatus('Контур версий синхронизирован.', 'success');
} catch (error) {
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
@@ -197,6 +234,7 @@ async function handleVersionAction(event) {
if (!version) return;
const action = button.dataset.action;
if (action === 'edit') {
localStorage.setItem('magistr.schedule.semesterId', String(version.semesterId));
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
navigateTo('schedule');
return;

View File

@@ -1,7 +1,15 @@
import { api, getSession } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { preferredScheduleSemester } from '../schedule-period.js';
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
import { AsyncCombobox } from '../async-combobox.js';
import { readUrlState, writeUrlState } from '../url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
const TARGET_GROUPS = 'groups';
@@ -62,7 +70,18 @@ export async function initScheduleView() {
const tables = document.getElementById('schedule-view-tables');
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
const departmentSelect = document.getElementById('schedule-view-department');
const academicYearSelect = document.getElementById('schedule-view-year');
const semesterSelect = document.getElementById('schedule-view-semester');
const restored = readUrlState({
scheduleTarget: TARGET_GROUPS,
scheduleEntity: '',
scheduleDate: '',
scheduleYear: '',
scheduleSemester: '',
scheduleSubject: '',
scheduleLessonType: '',
scheduleParity: ''
});
const state = {
dictionaries: {
@@ -83,6 +102,10 @@ export async function initScheduleView() {
const mobileMedia = window.matchMedia(MOBILE_QUERY);
setDefaultDate(dateInput);
if (restored.scheduleDate) dateInput.value = restored.scheduleDate;
if (targetSelect && role !== ROLE_DEPARTMENT) targetSelect.value = restored.scheduleTarget;
restoreEntitySelect(restored.scheduleTarget, restored.scheduleEntity);
const entityComboboxes = setupEntityComboboxes(state.dictionaries, role, userDepartmentId);
configureDepartmentTarget();
updatePeriodLabel();
updateEntityFieldVisibility();
@@ -92,6 +115,7 @@ export async function initScheduleView() {
clearInactiveEntityValues();
updateEntityFieldVisibility();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
loadButton?.addEventListener('click', loadData);
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
@@ -103,11 +127,22 @@ export async function initScheduleView() {
dateInput?.addEventListener('change', () => {
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
academicYearSelect?.addEventListener('change', () => {
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, academicYearSelect.value);
const selected = resolveAcademicPeriod(yearSemesters, { date: dateInput.value });
renderPeriodSelects(academicYearSelect.value, selected.semesterId);
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1));
@@ -132,8 +167,10 @@ export async function initScheduleView() {
}
try {
state.dictionaries = await loadDictionaries(role, userDepartmentId);
selectCurrentSemester();
Object.assign(state.dictionaries, await loadDictionaries(role, userDepartmentId));
mergeLoadedDictionaries(state.dictionaries, entityComboboxes);
restoreSimpleFilters(restored);
selectCurrentSemester(restored.scheduleYear, restored.scheduleSemester);
overridePanel = createScheduleOverridePanel({
role,
dictionaries: state.dictionaries,
@@ -149,6 +186,11 @@ export async function initScheduleView() {
resetResults(message);
}
['schedule-view-group', 'schedule-view-teacher', 'schedule-view-classroom', 'schedule-view-department',
'schedule-view-subject', 'schedule-view-lesson-type', 'schedule-view-parity'].forEach(id => {
document.getElementById(id)?.addEventListener('change', persistFilters);
});
function configureDepartmentTarget() {
if (role !== ROLE_DEPARTMENT) return;
targetSelect.value = TARGET_DEPARTMENTS;
@@ -187,8 +229,12 @@ export async function initScheduleView() {
].forEach(([fieldTarget, id]) => {
if (fieldTarget === target) return;
if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return;
if (entityComboboxes[fieldTarget]) {
entityComboboxes[fieldTarget].clear();
} else {
const select = document.getElementById(id);
if (select) select.value = '';
}
});
}
@@ -255,6 +301,8 @@ export async function initScheduleView() {
state.overrides = overrides;
overridePanel.setRegistry(overrides, range);
renderLessons(lessons, range, target, previousKey);
persistFilters();
trackUxEvent('расписание_найдено', { target, lessons: lessons.length });
} catch (error) {
const message = error.message || 'Ошибка загрузки расписания';
showAlert('schedule-view-alert', message, 'error');
@@ -894,17 +942,38 @@ export async function initScheduleView() {
.find(semester => String(semester.id) === String(semesterId)) || null;
}
function selectCurrentSemester() {
if (!semesterSelect) return;
const preferredSemester = preferredScheduleSemester(
state.dictionaries.semesters,
dateInput.value
);
semesterSelect.value = preferredSemester ? String(preferredSemester.id) : '';
function selectCurrentSemester(academicYearId = '', semesterId = '') {
if (!academicYearSelect || !semesterSelect) return;
const selected = resolveAcademicPeriod(state.dictionaries.semesters, {
academicYearId,
semesterId,
date: dateInput.value
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
synchronizeDateWithSemester();
updatePeriodLabel();
}
function renderPeriodSelects(academicYearId, semesterId) {
const years = academicYearsFromSemesters(state.dictionaries.semesters);
const selectedYearId = String(academicYearId || years[0]?.id || '');
academicYearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
academicYearSelect.value = selectedYearId;
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, selectedYearId);
semesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterOptionLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| preferredScheduleSemester(yearSemesters, dateInput.value)
|| yearSemesters[0];
semesterSelect.value = String(selectedSemester?.id || '');
}
function synchronizeDateWithSemester() {
const semester = selectedSemester();
if (!semester) {
@@ -924,6 +993,26 @@ export async function initScheduleView() {
if (!range) return 1;
return isoDay(dateInput.value || range.startDate);
}
function persistFilters() {
const target = activeTarget();
const entitySelectId = {
groups: 'schedule-view-group',
teachers: 'schedule-view-teacher',
classrooms: 'schedule-view-classroom',
departments: 'schedule-view-department'
}[target];
writeUrlState({
scheduleTarget: role === ROLE_DEPARTMENT ? '' : target,
scheduleEntity: entitySelectId ? valueOf(entitySelectId) : '',
scheduleDate: dateInput.value,
scheduleYear: academicYearSelect?.value || '',
scheduleSemester: semesterSelect?.value || '',
scheduleSubject: valueOf('schedule-view-subject'),
scheduleLessonType: valueOf('schedule-view-lesson-type'),
scheduleParity: valueOf('schedule-view-parity')
});
}
}
function createEmptyOverridePanel() {
@@ -935,37 +1024,111 @@ function createEmptyOverridePanel() {
}
async function loadDictionaries(role, userDepartmentId) {
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/departments'),
api.get('/api/subjects'),
api.get('/api/lesson-types'),
api.get('/api/schedule/semesters')
]);
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
? groups.filter(group => String(group.departmentId) === String(userDepartmentId))
: groups;
fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы');
fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели');
fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории');
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
return {
groups,
teachers,
classrooms,
groups: [],
teachers: [],
classrooms: [],
departments,
semesters: semesters || []
};
}
function setupEntityComboboxes(dictionaries, role, userDepartmentId) {
const definitions = {
groups: {
select: document.getElementById('schedule-view-group'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
if (role === ROLE_DEPARTMENT && userDepartmentId) params.set('departmentId', userDepartmentId);
return api.get(`/api/groups/options?${params}`);
},
label: item => item.name,
description: item => [item.specialtyCode, item.specialtyProfileName, item.educationFormName].filter(Boolean).join(' · ')
},
teachers: {
select: document.getElementById('schedule-view-teacher'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/users/teachers/options?${params}`);
},
label: item => item.fullName || item.username,
description: item => [item.jobTitle, item.departmentName].filter(Boolean).join(' · ')
},
classrooms: {
select: document.getElementById('schedule-view-classroom'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/classrooms/options?${params}`);
},
label: roomLabel,
description: item => [item.capacity ? `${item.capacity} мест` : null, item.building].filter(Boolean).join(' · ')
}
};
return Object.fromEntries(Object.entries(definitions).map(([key, definition]) => {
const combo = new AsyncCombobox(definition.select, {
placeholder: `Найдите ${key === 'groups' ? 'группу' : key === 'teachers' ? 'преподавателя' : 'аудиторию'}`,
optionLabel: definition.label,
optionDescription: definition.description,
load: async (query, selectedId) => {
const items = await definition.load(query, selectedId);
mergeItems(dictionaries, key, items);
return items;
}
});
combo.preload('');
return [key, combo];
}));
}
function mergeLoadedDictionaries(dictionaries, combos) {
Object.keys(combos).forEach(key => mergeItems(dictionaries, key, combos[key].items || []));
}
function mergeItems(dictionaries, key, items) {
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
(items || []).forEach(item => merged.set(String(item.id), item));
dictionaries[key] = [...merged.values()];
}
function restoreEntitySelect(target, value) {
if (!value) return;
const id = {
groups: 'schedule-view-group',
teachers: 'schedule-view-teacher',
classrooms: 'schedule-view-classroom',
departments: 'schedule-view-department'
}[target];
const select = document.getElementById(id);
if (select) select.innerHTML = `<option value="${escapeHtml(value)}" selected>Загрузка...</option>`;
}
function restoreSimpleFilters(restored) {
[
['schedule-view-subject', restored.scheduleSubject],
['schedule-view-lesson-type', restored.scheduleLessonType],
['schedule-view-parity', restored.scheduleParity]
].forEach(([id, value]) => {
const select = document.getElementById(id);
if (select && value && Array.from(select.options).some(option => option.value === String(value))) {
select.value = String(value);
}
});
}
function setDefaultDate(dateInput) {
const today = new Date();
dateInput.value = toIsoDate(today);

View File

@@ -1,5 +1,17 @@
import { api } from '../api.js';
import { renderPagination } from '../pagination.js';
import { refreshCustomDropdowns } from '../dropdown.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AsyncCombobox } from '../async-combobox.js';
import { debounce } from '../url-state.js';
const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник' },
@@ -61,6 +73,10 @@ export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить';
}
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
return !programmatic && !(type === 'input' && fromAsyncCombobox);
}
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
return available.find(version => String(version.id) === String(requestedVersionId))
@@ -100,11 +116,16 @@ const LESSON_TYPE_LIMITS = [
];
export async function initSchedule() {
const dirtyScope = createDirtyScope('schedule-constructor', 'В конструкторе есть несохранённые изменения правила. Покинуть раздел?');
const initialState = document.getElementById('schedule-initial-state');
const initialContent = document.getElementById('schedule-initial-content');
const ruleForm = document.getElementById('schedule-rule-form');
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
const ruleIdInput = document.getElementById('schedule-rule-id');
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
const scheduleVersionYearSelect = document.getElementById('schedule-version-year');
const scheduleVersionSemesterSelect = document.getElementById('schedule-version-semester');
const scheduleVersionSelect = document.getElementById('schedule-version-select');
const scheduleVersionContext = document.getElementById('schedule-version-context');
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
@@ -128,6 +149,7 @@ export async function initSchedule() {
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
const ruleGroupSearch = document.getElementById('schedule-rule-group-search');
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
const ruleResetButton = document.getElementById('schedule-rule-reset');
const slotAddButton = document.getElementById('schedule-slot-add');
@@ -168,10 +190,16 @@ export async function initSchedule() {
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
let rules = [];
let visualRules = [];
let rulesPage = 0;
let rulesSize = 25;
let rulesRequest = 0;
let visualRequest = 0;
let rulesVersionId = '';
const rulesSearch = document.getElementById('schedule-rules-search');
const rulesPagination = document.getElementById('schedule-rules-pagination');
let subjects = [];
let groups = [];
let teachers = [];
let classrooms = [];
let lessonTypes = [];
let subgroups = [];
let timeSlots = [];
@@ -190,6 +218,14 @@ export async function initSchedule() {
let visualContextMenuPositionAnimation = null;
let pendingSlotMove = null;
let creatingDraft = false;
let lastVersionYearValue = '';
let lastVersionSemesterValue = '';
let lastVersionValue = '';
let slotComboboxes = [];
let conflictComboboxes = [];
let groupSearchRequest = 0;
let subgroupRequest = 0;
let syncingRuleForm = false;
bindEvents();
@@ -201,13 +237,30 @@ export async function initSchedule() {
syncRuleSemesterWithVersion();
setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
await loadRules();
dirtyScope.clear();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
} finally {
initialContent?.classList.remove('is-loading');
initialContent?.setAttribute('aria-busy', 'false');
if (initialState) initialState.hidden = true;
}
function bindEvents() {
rulesSearch?.addEventListener('input', debounce(() => {
rulesPage = 0;
loadRules();
}, 300));
refreshRulesButton?.addEventListener('click', loadRules);
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
scheduleVersionSelect?.addEventListener('change', async () => {
const requestedValue = scheduleVersionSelect.value;
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
scheduleVersionSelect.value = lastVersionValue;
return;
}
lastVersionValue = requestedValue;
if (scheduleVersionSelect.value) {
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
}
@@ -227,7 +280,10 @@ export async function initSchedule() {
});
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
slotAddButton?.addEventListener('click', () => {
renderRuleSlots([...readRuleSlots(false), {}]);
dirtyScope.mark();
});
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
visualCloseButton?.addEventListener('click', closeVisualDrawer);
visualBackdrop?.addEventListener('click', closeVisualDrawer);
@@ -305,6 +361,7 @@ export async function initSchedule() {
const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]);
dirtyScope.mark();
return;
}
@@ -321,9 +378,11 @@ export async function initSchedule() {
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
ruleGroupCheckboxes.addEventListener('change', () => {
ruleGroupSearch?.addEventListener('input', debounce(() => searchRuleGroups(ruleGroupSearch.value), 320));
ruleGroupCheckboxes.addEventListener('change', async () => {
const slots = readRuleSlots(false);
updateRuleGroupsText();
renderRuleSlots(readRuleSlots(false));
if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots);
});
document.addEventListener('click', () => {
closeRuleGroupsMenu();
@@ -340,25 +399,39 @@ export async function initSchedule() {
updatePreferenceHints();
});
ruleForm.addEventListener('submit', saveRule);
ruleForm.addEventListener('input', event => {
if (shouldMarkRuleFormDirty({
programmatic: syncingRuleForm,
type: event.type,
fromAsyncCombobox: Boolean(event.target.closest('.async-combobox'))
})) dirtyScope.mark();
});
ruleForm.addEventListener('change', event => {
if (shouldMarkRuleFormDirty({ programmatic: syncingRuleForm, type: event.type })) dirtyScope.mark();
});
rulesTbody.addEventListener('click', handleRuleTableClick);
}
async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
[subjects, lessonTypes] = await Promise.all([
api.get('/api/subjects'),
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/lesson-types'),
api.get('/api/subgroups')
api.get('/api/lesson-types')
]);
subgroups = [];
populateSubjectSelects();
await searchRuleGroups('');
}
async function loadYears() {
academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects();
const selected = resolveAcademicPeriod(semesters, {
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
date: localIsoDate(new Date())
});
renderVersionPeriodSelects(selected.academicYearId, selected.semesterId);
selectDefaultVisualPeriod();
}
@@ -368,15 +441,27 @@ export async function initSchedule() {
}
async function loadScheduleVersions(preferredVersionId = null) {
const responses = await Promise.all(semesters.map(semester =>
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
));
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
const semesterId = scheduleVersionSemesterSelect?.value || defaultScheduleSemesterId();
if (!semesterId) {
scheduleVersions = [];
scheduleVersionSelect.innerHTML = '<option value="">Сначала выберите семестр</option>';
updateVersionContext();
setRuleEditorEnabled(false);
return;
}
localStorage.setItem('magistr.schedule.yearId', scheduleVersionYearSelect?.value || '');
localStorage.setItem('magistr.schedule.semesterId', semesterId);
scheduleVersions = (await api.get(
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
)).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
const requestedVersionId = preferredVersionId
|| localStorage.getItem('magistr.schedule.openVersionId')
|| localStorage.getItem('magistr.schedule.versionId')
|| '';
localStorage.removeItem('magistr.schedule.openVersionId');
const selected = chooseScheduleVersion(
scheduleVersions,
ruleSemesterSelect.value || defaultScheduleSemesterId(),
semesterId,
requestedVersionId
);
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
@@ -388,6 +473,7 @@ export async function initSchedule() {
].filter(Boolean).join('')
: '<option value="">Расписания не созданы</option>';
scheduleVersionSelect.value = selected ? String(selected.id) : '';
lastVersionValue = scheduleVersionSelect.value;
if (selected) {
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
}
@@ -395,6 +481,64 @@ export async function initSchedule() {
setRuleEditorEnabled(Boolean(selected));
}
async function handleVersionYearChange() {
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
return;
}
const yearId = scheduleVersionYearSelect?.value || '';
const yearSemesters = semestersForAcademicYear(semesters, yearId);
const selected = resolveAcademicPeriod(yearSemesters, { date: localIsoDate(new Date()) });
renderVersionPeriodSelects(yearId, selected.semesterId);
await applyVersionPeriodChange();
}
async function handleVersionSemesterChange() {
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
return;
}
await applyVersionPeriodChange();
}
async function applyVersionPeriodChange() {
lastVersionYearValue = scheduleVersionYearSelect?.value || '';
lastVersionSemesterValue = scheduleVersionSemesterSelect?.value || '';
localStorage.setItem('magistr.schedule.yearId', lastVersionYearValue);
localStorage.setItem('magistr.schedule.semesterId', lastVersionSemesterValue);
try {
await loadScheduleVersions();
resetRuleForm();
syncRuleSemesterWithVersion();
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Не удалось сменить учебный период', 'error');
}
}
function renderVersionPeriodSelects(academicYearId, semesterId) {
if (!scheduleVersionYearSelect || !scheduleVersionSemesterSelect) return;
const years = academicYearsFromSemesters(semesters);
const selectedYearId = String(academicYearId || years[0]?.id || '');
scheduleVersionYearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
scheduleVersionYearSelect.value = selectedYearId;
const yearSemesters = semestersForAcademicYear(semesters, selectedYearId);
scheduleVersionSemesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(shortSemesterLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| yearSemesters[0];
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
lastVersionYearValue = scheduleVersionYearSelect.value;
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
refreshCustomDropdowns(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
}
function versionOptionGroup(label, versions) {
if (!versions.length) return '';
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
@@ -405,12 +549,11 @@ export async function initSchedule() {
}
function defaultScheduleSemesterId() {
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId');
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId));
if (saved) return saved.id;
const today = localIsoDate(new Date());
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
return current?.id || semesters[0]?.id || '';
return resolveAcademicPeriod(semesters, {
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
date: localIsoDate(new Date())
}).semesterId;
}
function updateVersionContext() {
@@ -505,6 +648,11 @@ export async function initSchedule() {
}
async function loadRules() {
const request = ++rulesRequest;
++visualRequest;
visualRules = [];
rules = [];
rulesPagination?.replaceChildren();
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
const version = selectedScheduleVersion();
if (!version) {
@@ -513,26 +661,43 @@ export async function initSchedule() {
setVisualState('Нет выбранного расписания');
return;
}
if (rulesVersionId !== String(version.id)) {
rulesPage = 0;
rulesVersionId = String(version.id);
}
try {
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
const params = new URLSearchParams({ versionId: version.id, page: rulesPage, size: rulesSize, query: rulesSearch?.value.trim() || '' });
const page = await api.get(`/api/admin/schedule-rules/page?${params}`);
if (request !== rulesRequest) return;
if (rulesPage > 0 && rulesPage >= page.totalPages) {
rulesPage = Math.max(0, page.totalPages - 1);
return loadRules();
}
rules = page.items;
renderPagination(rulesPagination, page, {
label: 'правил',
onPage: value => { rulesPage = value; loadRules(); },
onSize: value => { rulesSize = value; rulesPage = 0; loadRules(); }
});
if (!visualPeriodTouched) {
selectDefaultVisualPeriod();
}
renderRules();
renderVisualSchedule();
openRequestedRule();
if (visualDrawer?.classList.contains('open')) await loadVisualRules();
await openRequestedRule();
} catch (error) {
if (request !== rulesRequest) return;
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
setVisualState('Ошибка загрузки правил расписания');
}
}
function openRequestedRule() {
async function openRequestedRule() {
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
if (!requestedRuleId) return;
localStorage.removeItem('magistr.schedule.openRuleId');
const rule = rules.find(item => String(item.id) === requestedRuleId);
if (!rule) {
const rule = await api.get(`/api/admin/schedule-rules/${encodeURIComponent(requestedRuleId)}`);
if (!rule || String(rule.scheduleVersionId) !== String(selectedScheduleVersion()?.id)) {
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
return;
}
@@ -541,7 +706,7 @@ export async function initSchedule() {
function renderRules() {
if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила не найдены</td></tr>';
renderVisualSchedule();
return;
}
@@ -580,6 +745,7 @@ export async function initSchedule() {
visualToggleButton?.setAttribute('aria-expanded', 'true');
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
if (visualBackdrop) visualBackdrop.hidden = false;
loadVisualRules();
}
function closeVisualDrawer() {
@@ -593,34 +759,49 @@ export async function initSchedule() {
if (visualBackdrop) visualBackdrop.hidden = true;
}
async function loadVisualRules() {
const version = selectedScheduleVersion();
if (!version) return;
const request = ++visualRequest;
setVisualState('Загрузка визуального расписания...');
try {
const loaded = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
if (request !== visualRequest) return;
visualRules = loaded;
renderVisualSchedule();
} catch (error) {
if (request === visualRequest) setVisualState(error.message || 'Ошибка загрузки визуального расписания');
}
}
function renderVisualSchedule() {
if (!visualStage || !visualSummary) return;
if (!visualStage || !visualSummary || !visualDrawer?.classList.contains('open')) return;
if (!selectedVisualSemesterId) {
syncVisualGroupFilter([]);
visualSummary.textContent = 'Выберите учебный год и семестр';
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
return;
}
if (!rules.length) {
if (!visualRules.length) {
syncVisualGroupFilter([]);
visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return;
}
const visualRules = visualRulesForSelectedPeriod();
const periodRules = visualRulesForSelectedPeriod();
const periodLabel = visualPeriodLabel();
if (!visualRules.length) {
if (!periodRules.length) {
syncVisualGroupFilter([]);
visualSummary.textContent = `${periodLabel} · правил нет`;
setVisualState('В выбранном семестре нет сохранённых правил расписания');
return;
}
const allGroups = collectVisualGroups(visualRules);
const allGroups = collectVisualGroups(periodRules);
syncVisualGroupFilter(allGroups);
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(periodRules);
if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий');
@@ -632,7 +813,7 @@ export async function initSchedule() {
return;
}
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, periodRules);
const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) {
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
@@ -640,7 +821,7 @@ export async function initSchedule() {
return;
}
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
visualSummary.textContent = `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
visualStage.innerHTML = `
<div class="schedule-visual-table-wrap">
<table class="schedule-visual-table">
@@ -900,7 +1081,7 @@ export async function initSchedule() {
}
function openVisualContextMenu(button) {
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(button.dataset.ruleId));
if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
@@ -945,7 +1126,7 @@ export async function initSchedule() {
if (!actionButton || !visualContextTarget) return;
const action = actionButton.dataset.action;
const target = { ...visualContextTarget };
const rule = rules.find(item => String(item.id) === String(target.ruleId));
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
if (!rule) {
closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
@@ -972,7 +1153,12 @@ export async function initSchedule() {
async function deleteRuleFromVisual(rule) {
closeVisualContextMenu();
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
if (!await confirmAction({
title: 'Удалить правило расписания?',
message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`,
confirmLabel: 'Удалить правило',
danger: true
})) return;
try {
await api.delete('/api/admin/schedule-rules/' + rule.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
@@ -983,7 +1169,7 @@ export async function initSchedule() {
}
function openMoveSlotModal(target) {
const rule = rules.find(item => String(item.id) === String(target.ruleId));
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
const slotIndex = findRuleSlotIndex(rule, target);
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
if (!rule || !slot) {
@@ -1026,7 +1212,7 @@ export async function initSchedule() {
event.preventDefault();
if (!pendingSlotMove) return;
hideAlert('schedule-move-slot-alert');
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
const rule = [...rules, ...visualRules].find(item => String(item.id) === pendingSlotMove.ruleId);
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
if (!rule || !slot) {
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
@@ -1196,7 +1382,7 @@ export async function initSchedule() {
function visualRulesForSelectedPeriod() {
if (!selectedVisualSemesterId) return [];
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
return visualRules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
}
function firstSemesterWithRules() {
@@ -1258,14 +1444,19 @@ export async function initSchedule() {
const deleteButton = event.target.closest('.btn-delete-rule');
if (editButton) {
const rule = rules.find(item => item.id == editButton.dataset.id)
const rule = [...rules, ...visualRules].find(item => item.id == editButton.dataset.id)
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
fillRuleForm(rule);
return;
}
if (deleteButton) {
if (!confirm('Удалить правило расписания?')) return;
if (!await confirmAction({
title: 'Удалить правило расписания?',
message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.',
confirmLabel: 'Удалить правило',
danger: true
})) return;
try {
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
@@ -1302,7 +1493,11 @@ export async function initSchedule() {
}
async function finishSuccessfulRuleSave(saved) {
const mode = ruleIdInput.value ? 'update' : 'create';
const versionId = scheduleVersionSelect.value;
dirtyScope.clear();
resetRuleForm();
trackUxEvent('правило_расписания_сохранено', { mode, versionId });
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
await loadRules();
}
@@ -1427,6 +1622,8 @@ export async function initSchedule() {
}
function renderConflictSlots(rule, conflictFields = []) {
conflictComboboxes.forEach(combobox => combobox.destroy());
conflictComboboxes = [];
const slots = rule.slots || [];
if (!conflictSlotsContainer) return;
if (!slots.length) {
@@ -1450,11 +1647,12 @@ export async function initSchedule() {
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" aria-label="Преподаватель конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" aria-label="Аудитория конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
</section>
`;
}).join('');
initializeEntityComboboxes(conflictSlotsContainer, '.conflict-slot-teacher', '.conflict-slot-classroom', conflictComboboxes);
}
function conflictFieldClass(field, fields) {
@@ -1578,7 +1776,7 @@ export async function initSchedule() {
function buildRulePayload() {
const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true);
const slots = readRuleSlots(true).map(ruleSlotToPayload);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
@@ -1597,17 +1795,22 @@ export async function initSchedule() {
};
}
function fillRuleForm(rule) {
async function fillRuleForm(rule) {
ruleFormTitle.textContent = 'Редактирование правила расписания';
ruleIdInput.value = rule.id;
ruleSubjectSelect.value = rule.subjectId || '';
ruleSemesterSelect.value = rule.semesterId || '';
fillTypeLimits(rule);
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
cacheRuleGroups(rule.groupIds || [], rule.groupNames || []);
renderRuleGroups(rule.groupIds || []);
searchRuleGroups(ruleGroupSearch?.value || '');
ruleSlotsContainer.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Загрузка подгрупп выбранных групп...</div>';
if (!await loadSubgroupsForGroups(rule.groupIds || [])) return;
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
syncRuleSemesterWithVersion();
hideAlert('schedule-rule-alert');
dirtyScope.clear();
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
@@ -1616,11 +1819,14 @@ export async function initSchedule() {
ruleFormTitle.textContent = 'Новое правило расписания';
ruleIdInput.value = '';
fillTypeLimits({});
subgroupRequest += 1;
subgroups = [];
renderRuleGroups([]);
renderRuleSlots([{}]);
syncRuleSemesterWithVersion();
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert');
dirtyScope.clear();
}
function renderRuleGroups(selectedIds) {
@@ -1640,7 +1846,60 @@ export async function initSchedule() {
updateRuleGroupsText();
}
async function searchRuleGroups(query = '') {
const request = ++groupSearchRequest;
const selectedBeforeSearch = selectedRuleGroupIds();
if (!groups.length) {
ruleGroupCheckboxes.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Поиск групп...</div>';
}
try {
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
const loaded = await api.get(`/api/groups/options?${params}`);
if (request !== groupSearchRequest) return;
const selectedIds = selectedRuleGroupIds().length ? selectedRuleGroupIds() : selectedBeforeSearch;
const selectedGroups = groups.filter(group => selectedIds.includes(String(group.id)));
groups = mergeEntitiesById([...selectedGroups, ...(loaded || [])]);
renderRuleGroups(selectedIds);
} catch (error) {
if (request !== groupSearchRequest) return;
ruleGroupCheckboxes.innerHTML = `<div class="loading-row view-state-error">${escapeHtml(error.message || 'Не удалось найти группы')}</div>`;
}
}
async function loadSubgroupsForGroups(groupIds) {
const request = ++subgroupRequest;
const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))];
if (!normalizedIds.length) {
subgroups = [];
return true;
}
try {
const params = new URLSearchParams();
normalizedIds.forEach(groupId => params.append('groupId', groupId));
const loaded = await api.get(`/api/subgroups?${params}`);
if (request !== subgroupRequest) return false;
subgroups = loaded || [];
return true;
} catch (error) {
if (request !== subgroupRequest) return false;
subgroups = [];
showAlert('schedule-rule-alert', error.message || 'Не удалось загрузить подгруппы выбранных групп', 'error');
return true;
}
}
function cacheRuleGroups(ids, names) {
const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` }));
groups = mergeEntitiesById([...groups, ...additions]);
}
function mergeEntitiesById(items) {
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
}
function renderRuleSlots(slots) {
slotComboboxes.forEach(combobox => combobox.destroy());
slotComboboxes = [];
const normalizedSlots = slots?.length ? slots : [{}];
const isSingleSlot = normalizedSlots.length === 1;
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
@@ -1650,8 +1909,8 @@ export async function initSchedule() {
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" aria-label="Преподаватель слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" aria-label="Аудитория слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
@@ -1659,6 +1918,7 @@ export async function initSchedule() {
<div class="schedule-preference-hint" data-preference-hint></div>
</div>
`).join('');
initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes);
updatePreferenceHints();
}
@@ -1721,6 +1981,8 @@ export async function initSchedule() {
subgroupIds,
teacherId: Number(row.querySelector('.slot-teacher').value),
classroomId: Number(row.querySelector('.slot-classroom').value),
teacherName: selectedOptionLabel(row.querySelector('.slot-teacher')),
classroomName: selectedOptionLabel(row.querySelector('.slot-classroom')),
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
lessonFormat: row.querySelector('.slot-format').value
};
@@ -2022,13 +2284,49 @@ export async function initSchedule() {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
}
function toTeacherOption(teacher) {
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
function initializeEntityComboboxes(container, teacherSelector, classroomSelector, collector) {
container.querySelectorAll(teacherSelector).forEach(select => {
collector.push(new AsyncCombobox(select, {
allowEmpty: false,
placeholder: 'Найдите преподавателя',
emptyLabel: 'Преподаватели не найдены',
optionLabel: teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/users/teachers/options?${params}`);
}
}));
});
container.querySelectorAll(classroomSelector).forEach(select => {
collector.push(new AsyncCombobox(select, {
allowEmpty: false,
placeholder: 'Найдите аудиторию',
emptyLabel: 'Аудитории не найдены',
optionLabel: classroom => classroom.name || `Аудитория ${classroom.id}`,
optionDescription: classroom => [
classroom.building,
classroom.floor != null ? `${classroom.floor} этаж` : '',
classroom.capacity ? `${classroom.capacity} мест` : ''
].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/classrooms/options?${params}`);
}
}));
});
}
function toClassroomOption(classroom) {
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
return { value: classroom.id, label: `${classroom.name}${capacity}` };
function selectedEntityOption(id, label) {
return id
? `<option value="${escapeHtml(String(id))}" selected>${escapeHtml(label || `ID ${id}`)}</option>`
: '<option value="" selected></option>';
}
function selectedOptionLabel(select) {
return select?.selectedOptions?.[0]?.textContent?.trim() || '';
}
function toLessonTypeOption(lessonType) {
@@ -2098,9 +2396,16 @@ export async function initSchedule() {
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
const targets = selects.filter(Boolean);
const previousSyncState = syncingRuleForm;
if (targets.some(select => ruleForm.contains(select))) syncingRuleForm = true;
try {
targets.forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
} finally {
syncingRuleForm = previousSyncState;
}
}
function visualCellKey(groupId, dayOfWeek, slotKey) {

View File

@@ -1,10 +1,20 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AsyncCombobox } from '../async-combobox.js';
export async function initSubjects() {
const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form');
const newSubjectDeptSelect = document.getElementById('new-subject-department');
const departmentFilter = document.getElementById('subjects-department-filter');
const searchInput = document.getElementById('subjects-search');
const sortSelect = document.getElementById('subjects-sort');
const pagination = document.getElementById('subjects-pagination');
// Модалка управления преподавателями
const modalManageTeachers = document.getElementById('modal-manage-teachers');
@@ -16,9 +26,19 @@ export async function initSubjects() {
const manageTsTbody = document.getElementById('manage-ts-tbody');
let allSubjects = [];
let allTeachers = [];
let allDepartments = [];
let teacherSubjects = [];
let teacherCombobox = null;
const restored = readUrlState({ subjectQuery: '', subjectDepartment: '', subjectPage: '0', subjectSize: '25', subjectSort: 'name,asc' });
const pageState = {
query: restored.subjectQuery,
departmentId: restored.subjectDepartment,
page: Math.max(Number(restored.subjectPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.subjectSize)) ? Number(restored.subjectSize) : 25,
sort: restored.subjectSort
};
searchInput.value = pageState.query;
sortSelect.value = pageState.sort;
bindEvents();
await loadInitialData();
@@ -34,15 +54,43 @@ export async function initSubjects() {
modalManageTeachers.addEventListener('click', (e) => {
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
});
searchInput.addEventListener('input', debounce(() => {
pageState.query = searchInput.value.trim();
pageState.page = 0;
loadSubjects();
}));
departmentFilter.addEventListener('change', () => {
pageState.departmentId = departmentFilter.value;
pageState.page = 0;
loadSubjects();
});
sortSelect.addEventListener('change', () => {
pageState.sort = sortSelect.value;
pageState.page = 0;
loadSubjects();
});
}
async function loadInitialData() {
try {
[allTeachers, allDepartments] = await Promise.all([
api.get('/api/users/teachers'),
api.get('/api/departments')
]);
allDepartments = await api.get('/api/departments');
teacherCombobox = new AsyncCombobox(manageTsTeacherSelect, {
placeholder: 'Найдите преподавателя по ФИО',
optionLabel: teacher => teacher.fullName || teacher.username,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
const teachers = await api.get(`/api/users/teachers/options?${params}`);
const subjectId = manageTsSubjectIdInput.value;
const assigned = new Set(teacherSubjects
.filter(item => String(item.subjectId) === String(subjectId))
.map(item => String(item.userId)));
return teachers.filter(teacher => !assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
}
});
populateDepartmentsSelect();
populateDepartmentFilter();
await loadSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
@@ -58,22 +106,58 @@ export async function initSubjects() {
}).join('');
}
async function loadSubjects() {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try {
[allSubjects, teacherSubjects] = await Promise.all([
api.get('/api/subjects'),
api.get('/api/teacher-subjects')
]);
renderSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
function populateDepartmentFilter() {
departmentFilter.innerHTML = '<option value="">Все кафедры</option>' + allDepartments.map(department =>
`<option value="${department.id}">${escapeHtml(department.departmentName || department.name || department.id)}</option>`
).join('');
departmentFilter.value = pageState.departmentId;
}
async function loadSubjects() {
renderTableLoading(subjectsTbody, 6, 'Загрузка дисциплин...');
hideAlert('subjects-alert');
try {
const [sort, direction] = pageState.sort.split(',');
const params = new URLSearchParams({
query: pageState.query,
page: String(pageState.page),
size: String(pageState.size),
sort,
direction
});
if (pageState.departmentId) params.set('departmentId', pageState.departmentId);
const page = await api.get(`/api/subjects/page?${params}`);
if (!(page.items || []).length && page.totalItems > 0 && pageState.page > 0) {
pageState.page = Math.max(0, page.totalPages - 1);
return loadSubjects();
}
allSubjects = page.items || [];
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
renderSubjects();
renderPagination(pagination, page, {
label: 'дисциплин',
onPage: nextPage => { pageState.page = nextPage; loadSubjects(); },
onSize: size => { pageState.size = size; pageState.page = 0; loadSubjects(); }
});
persistState();
trackUxEvent('реестр_дисциплин_загружен', { total: Number(page.totalItems || 0), page: pageState.page });
} catch (e) {
renderTableError(subjectsTbody, 6, e.message || 'Не удалось загрузить дисциплины', loadSubjects);
}
}
async function loadTeacherAssignments(subjectIds) {
if (!subjectIds.length) return [];
const params = new URLSearchParams();
subjectIds.forEach(subjectId => params.append('subjectId', subjectId));
return api.get(`/api/teacher-subjects?${params}`);
}
function renderSubjects() {
if (!allSubjects || !allSubjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
renderTableEmpty(subjectsTbody, 6, pageState.query || pageState.departmentId
? 'Дисциплины не найдены'
: 'Дисциплин пока нет');
return;
}
@@ -106,6 +190,7 @@ export async function initSubjects() {
</tr>
`;
}).join('');
markTableReady(subjectsTbody);
}
async function createSubject(e) {
@@ -139,12 +224,17 @@ export async function initSubjects() {
const btnTeachers = e.target.closest('.btn-manage-teachers');
if (btnDelete) {
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return;
if (!await confirmAction({
title: 'Архивировать дисциплину?',
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try {
await api.delete('/api/subjects/' + btnDelete.dataset.id);
await loadSubjects();
} catch (error) {
alert(error.message || 'Ошибка удаления');
showAlert('subjects-alert', error.message || 'Ошибка архивирования', 'error');
}
}
@@ -182,22 +272,10 @@ export async function initSubjects() {
}
function populateTeachersSelect(subjectId) {
// Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине
const assignedUserIds = new Set(
teacherSubjects
.filter(ts => String(ts.subjectId) === String(subjectId))
.map(ts => String(ts.userId))
);
const availableTeachers = allTeachers.filter(t => !assignedUserIds.has(String(t.id)));
if (!availableTeachers.length) {
manageTsTeacherSelect.innerHTML = '<option value="">Все преподаватели привязаны</option>';
return;
}
manageTsTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)} (${escapeHtml(t.username)})</option>`).join('');
manageTsTeacherSelect.innerHTML = '<option value=""></option>';
manageTsTeacherSelect.value = '';
teacherCombobox?.syncFromSelect();
teacherCombobox?.search('');
}
async function saveTeacherAssignment(e) {
@@ -219,7 +297,7 @@ export async function initSubjects() {
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
// Перезагрузим привязки и обновим интерфейс
teacherSubjects = await api.get('/api/teacher-subjects');
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
@@ -233,7 +311,12 @@ export async function initSubjects() {
async function handleTeacherAssignmentTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-ts');
if (!btnDelete) return;
if (!confirm('Удалить привязку преподавателя?')) return;
if (!await confirmAction({
title: 'Удалить привязку преподавателя?',
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
confirmLabel: 'Удалить привязку',
danger: true
})) return;
const subjectId = manageTsSubjectIdInput.value;
try {
@@ -243,14 +326,14 @@ export async function initSubjects() {
});
// Перезагрузим привязки
teacherSubjects = await api.get('/api/teacher-subjects');
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Обновим главную таблицу
renderSubjects();
} catch (error) {
alert(error.message || 'Ошибка удаления привязки');
showAlert('manage-ts-alert', error.message || 'Ошибка удаления привязки', 'error');
}
}
@@ -260,4 +343,14 @@ export async function initSubjects() {
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
function persistState() {
writeUrlState({
subjectQuery: pageState.query,
subjectDepartment: pageState.departmentId,
subjectPage: pageState.page || '',
subjectSize: pageState.size === 25 ? '' : pageState.size,
subjectSort: pageState.sort === 'name,asc' ? '' : pageState.sort
});
}
}

View File

@@ -1,8 +1,17 @@
import { api, getSession } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { alertAction } from '../dialog.js';
import { AsyncCombobox } from '../async-combobox.js';
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
const params = new URLSearchParams({ query, limit: '30' });
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
export async function initTeacherAbsences() {
const session = getSession();
const role = session?.role;
@@ -27,6 +36,20 @@ export async function initTeacherAbsences() {
let preferenceItems = [];
let changeItems = [];
new AsyncCombobox(teacherSelect, {
placeholder: 'Найдите преподавателя по ФИО',
optionLabel: teacher => teacher.fullName || teacher.username || `ID ${teacher.id}`,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
return api.get(teacherAbsenceTeacherOptionsUrl({
query,
selectedId,
role,
departmentId: session?.departmentId
}));
}
});
setDefaultPeriod(startInput, endInput);
form?.addEventListener('submit', createAbsence);
statusFilter?.addEventListener('change', loadAbsences);
@@ -46,21 +69,7 @@ export async function initTeacherAbsences() {
document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews);
changeReviewList?.addEventListener('click', handleChangeReview);
await Promise.all([loadTeachers(), loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
async function loadTeachers() {
try {
let teachers = await api.get('/api/users/teachers');
if (role === 'DEPARTMENT' && session?.departmentId) {
teachers = teachers.filter(item => String(item.departmentId) === String(session.departmentId));
}
teacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' + teachers.map(item => `
<option value="${escapeHtml(item.id)}">${escapeHtml(item.fullName || item.username || `ID ${item.id}`)}</option>
`).join('');
} catch (error) {
teacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Не удалось загрузить преподавателей')}</option>`;
}
}
await Promise.all([loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
async function loadAbsences() {
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
@@ -167,7 +176,7 @@ export async function initTeacherAbsences() {
await openWizard(id);
}
} catch (error) {
window.alert(error.message || 'Не удалось выполнить действие');
await alertAction({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
} finally {
button.disabled = false;
}
@@ -328,7 +337,7 @@ export async function initTeacherAbsences() {
const approved = button.dataset.preferenceReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
window.alert('При отклонении укажите комментарий');
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
button.disabled = true;
@@ -339,7 +348,7 @@ export async function initTeacherAbsences() {
});
await loadPreferenceReviews();
} catch (error) {
window.alert(error.message || 'Не удалось рассмотреть пожелание');
await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
button.disabled = false;
}
}
@@ -397,7 +406,7 @@ export async function initTeacherAbsences() {
const approved = button.dataset.changeReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
window.alert('При отклонении укажите комментарий');
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
button.disabled = true;
@@ -405,7 +414,7 @@ export async function initTeacherAbsences() {
await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) {
window.alert(error.message || 'Не удалось применить решение');
await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
button.disabled = false;
}
}

View File

@@ -1,36 +1,72 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js';
import { promptAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
const refreshButton = document.getElementById('teacher-requests-refresh');
const requestsCount = document.getElementById('teacher-requests-count');
const searchInput = document.getElementById('teacher-requests-search');
const statusSelect = document.getElementById('teacher-requests-status');
const pagination = document.getElementById('teacher-requests-pagination');
let departments = [];
const restored = readUrlState({ requestQuery: '', requestStatus: 'PENDING', requestPage: '0', requestSize: '25' });
const state = {
query: restored.requestQuery,
status: restored.requestStatus,
page: Math.max(Number(restored.requestPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.requestSize)) ? Number(restored.requestSize) : 25
};
searchInput.value = state.query;
statusSelect.value = state.status;
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
renderTableLoading(requestsTbody, 7, 'Загрузка заявок...');
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
const normalizedRequests = Array.isArray(requests) ? requests : [];
const params = new URLSearchParams({
query: state.query,
page: String(state.page),
size: String(state.size),
sort: 'createdAt',
direction: 'desc'
});
if (state.status) params.set('status', state.status);
const page = await api.get(`/api/teacher-requests/page?${params}`);
if (!(page.items || []).length && page.totalItems > 0 && state.page > 0) {
state.page = Math.max(0, page.totalPages - 1);
return loadRequests();
}
const normalizedRequests = page.items || [];
renderRequests(normalizedRequests);
updateCount(normalizedRequests.length);
updateCount(Number(page.totalItems || 0));
renderPagination(pagination, page, {
label: 'заявок',
onPage: nextPage => { state.page = nextPage; loadRequests(); },
onSize: size => { state.size = size; state.page = 0; loadRequests(); }
});
persistState();
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: normalizedRequests.length }
detail: { count: state.status === 'PENDING' && !state.query ? Number(page.totalItems || 0) : undefined }
}));
trackUxEvent('реестр_заявок_загружен', { total: Number(page.totalItems || 0), page: state.page });
} catch (error) {
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
renderTableError(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей', loadRequests);
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
renderTableEmpty(requestsTbody, 7, state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет');
return;
}
requestsTbody.innerHTML = requests.map(request => `
@@ -51,12 +87,15 @@ export async function initTeacherRequests() {
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
${request.status === 'PENDING' ? `
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
` : `<span class="badge">${escapeHtml(request.status === 'APPROVED' ? 'Одобрена' : 'Отклонена')}</span>`}
</div>
</td>
</tr>
`).join('');
markTableReady(requestsTbody);
}
function updateCount(count) {
@@ -98,7 +137,18 @@ export async function initTeacherRequests() {
}
async function rejectRequest(requestId) {
const reviewComment = prompt('Причина отклонения');
const reviewComment = await promptAction({
title: 'Отклонить заявку?',
message: 'Комментарий увидит кафедра, отправившая заявку.',
confirmLabel: 'Отклонить',
danger: true,
input: {
label: 'Причина отклонения',
placeholder: 'Укажите, что нужно исправить',
required: true,
requiredMessage: 'Укажите причину отклонения'
}
});
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
@@ -110,6 +160,16 @@ export async function initTeacherRequests() {
}
refreshButton?.addEventListener('click', loadRequests);
searchInput.addEventListener('input', debounce(() => {
state.query = searchInput.value.trim();
state.page = 0;
loadRequests();
}));
statusSelect.addEventListener('change', () => {
state.status = statusSelect.value;
state.page = 0;
loadRequests();
});
requestsTbody.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
@@ -133,4 +193,13 @@ export async function initTeacherRequests() {
} catch (error) {
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
}
function persistState() {
writeUrlState({
requestQuery: state.query,
requestStatus: state.status === 'PENDING' ? '' : state.status,
requestPage: state.page || '',
requestSize: state.size === 25 ? '' : state.size
});
}
}

View File

@@ -1,5 +1,8 @@
import { renderPagination } from '../pagination.js';
import { debounce } from '../url-state.js';
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
export async function initUniversityStructure() {
const deptTbody = document.getElementById('departments-tbody');
@@ -51,6 +54,29 @@ export async function initUniversityStructure() {
let specialties = [];
let currentProfiles = [];
let allProfiles = [];
const listStates = Object.fromEntries(['departments', 'specialties', 'profiles'].map(key => [key, { page: 0, size: 25 }]));
const listRenderers = { departments: renderDepartments, specialties: renderSpecialties, profiles: renderAllProfiles };
Object.keys(listStates).forEach(key => {
document.getElementById(`${key}-search`)?.addEventListener('input', debounce(() => {
listStates[key].page = 0;
listRenderers[key]();
}, 250));
});
function listPage(key, items, fields) {
const query = document.getElementById(`${key}-search`).value.trim().toLocaleLowerCase('ru');
const filtered = items.filter(item => fields(item).join(' ').toLocaleLowerCase('ru').includes(query));
const state = listStates[key];
const totalPages = Math.ceil(filtered.length / state.size);
state.page = Math.min(state.page, Math.max(0, totalPages - 1));
renderPagination(document.getElementById(`${key}-pagination`), {
...state, totalItems: filtered.length, totalPages
}, {
onPage: value => { state.page = value; listRenderers[key](); },
onSize: value => { state.size = value; state.page = 0; listRenderers[key](); }
});
return filtered.slice(state.page * state.size, (state.page + 1) * state.size);
}
// --- Логика переключения табов ---
const structureTabs = [
@@ -104,11 +130,12 @@ export async function initUniversityStructure() {
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
if (!visible.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return;
}
deptTbody.innerHTML = departments.map(d => `
deptTbody.innerHTML = visible.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
@@ -122,11 +149,12 @@ export async function initUniversityStructure() {
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
if (!visible.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return;
}
specTbody.innerHTML = specialties.map(s => `
specTbody.innerHTML = visible.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
@@ -179,7 +207,7 @@ export async function initUniversityStructure() {
}
if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return;
if (!await confirmAction({ title: 'Удаление кафедры', message: 'Удалить кафедру?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
@@ -252,7 +280,7 @@ export async function initUniversityStructure() {
}
if (deleteBtn) {
if (!confirm('Удалить специальность?')) return;
if (!await confirmAction({ title: 'Удаление специальности', message: 'Удалить специальность?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success');
@@ -390,7 +418,7 @@ export async function initUniversityStructure() {
}
if (deleteBtn) {
if (!confirm('Удалить профиль обучения?')) return;
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
showAlert('manage-profile-alert', 'Профиль удален', 'success');
@@ -427,11 +455,12 @@ export async function initUniversityStructure() {
}
function renderAllProfiles() {
if (!allProfiles || !allProfiles.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
if (!visible.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
return;
}
tabProfileTbody.innerHTML = allProfiles.map(p => `
tabProfileTbody.innerHTML = visible.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
@@ -517,7 +546,7 @@ export async function initUniversityStructure() {
if (deleteBtn) {
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
if (!profile) return;
if (!confirm('Удалить профиль обучения?')) return;
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
showAlert('tab-profile-alert', 'Профиль удален', 'success');

View File

@@ -1,5 +1,10 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
import { renderPagination } from '../pagination.js';
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
import { trackUxEvent } from '../../../telemetry.js';
const ROLE_LABELS = {
ADMIN: 'Администратор',
@@ -21,21 +26,53 @@ const ROLE_BADGE = {
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const searchInput = document.getElementById('users-search');
const roleFilter = document.getElementById('users-role-filter');
const sortSelect = document.getElementById('users-sort');
const pagination = document.getElementById('users-pagination');
const restored = readUrlState({ userQuery: '', userRole: '', userPage: '0', userSize: '25', userSort: 'fullName,asc' });
const state = {
query: restored.userQuery,
role: restored.userRole,
page: Math.max(Number(restored.userPage) || 0, 0),
size: [25, 50, 100].includes(Number(restored.userSize)) ? Number(restored.userSize) : 25,
sort: restored.userSort
};
searchInput.value = state.query;
roleFilter.value = state.role;
sortSelect.value = state.sort;
async function loadUsers() {
renderTableLoading(usersTbody, 7, 'Загрузка пользователей...');
hideAlert('users-alert');
try {
const users = await api.get('/api/users?includeArchived=true');
renderUsers(users);
const [sort, direction] = state.sort.split(',');
const params = new URLSearchParams({
query: state.query,
page: String(state.page),
size: String(state.size),
sort,
direction,
includeArchived: 'true'
});
if (state.role) params.set('role', state.role);
const result = await api.get(`/api/users/page?${params}`);
renderUsers(result.items || []);
renderPagination(pagination, result, {
label: 'пользователей',
onPage: page => { state.page = page; loadUsers(); },
onSize: size => { state.size = size; state.page = 0; loadUsers(); }
});
persistState();
trackUxEvent('реестр_пользователей_загружен', { total: Number(result.totalItems || 0), page: state.page });
} catch (e) {
usersTbody.innerHTML =
'<tr><td colspan="7" class="loading-row">Ошибка загрузки: ' +
escapeHtml(e.message) + '</td></tr>';
renderTableError(usersTbody, 7, e.message || 'Не удалось загрузить пользователей', loadUsers);
}
}
function renderUsers(users) {
if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
renderTableEmpty(usersTbody, 7, state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет');
return;
}
@@ -54,8 +91,25 @@ export async function initUsers() {
</td>
</tr>
`).join('');
markTableReady(usersTbody);
}
searchInput.addEventListener('input', debounce(() => {
state.query = searchInput.value.trim();
state.page = 0;
loadUsers();
}));
roleFilter.addEventListener('change', () => {
state.role = roleFilter.value;
state.page = 0;
loadUsers();
});
sortSelect.addEventListener('change', () => {
state.sort = sortSelect.value;
state.page = 0;
loadUsers();
});
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-alert');
@@ -97,12 +151,17 @@ export async function initUsers() {
const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return;
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
if (!await confirmAction({
title: 'Архивировать пользователя?',
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try {
await api.delete('/api/users/' + deleteBtn.dataset.id);
loadUsers();
} catch (err) {
alert(err.message || 'Ошибка архивирования');
showAlert('users-alert', err.message || 'Ошибка архивирования', 'error');
}
});
@@ -114,9 +173,19 @@ export async function initUsers() {
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
loadUsers();
} catch (err) {
alert(err.message || 'Ошибка восстановления');
showAlert('users-alert', err.message || 'Ошибка восстановления', 'error');
}
});
function persistState() {
writeUrlState({
userQuery: state.query,
userRole: state.role,
userPage: state.page || '',
userSize: state.size === 25 ? '' : state.size,
userSort: state.sort === 'fullName,asc' ? '' : state.sort
});
}
await loadUsers();
}

View File

@@ -1,5 +1,6 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
import { alertAction, confirmAction } from '../../../js/dialog.js';
export async function initDatabase() {
const tenantsTbody = document.getElementById('tenants-tbody');
@@ -140,14 +141,19 @@ export async function initDatabase() {
if (!btn) return;
const domain = btn.dataset.domain;
if (!confirm(`Удалить тенант "${domain}"? Пул соединений будет закрыт.`)) return;
if (!await confirmAction({
title: 'Удаление тенанта',
message: `Удалить тенант «${domain}»? Пул соединений будет закрыт.`,
confirmLabel: 'Удалить тенант',
danger: true
})) return;
try {
await api.delete(`/api/database/tenants/${domain}`);
loadTenants();
loadStatus();
} catch (e) {
alert('Не удалось удалить тенант');
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить тенант' });
}
});

View File

@@ -1,5 +1,6 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
import { alertAction, confirmAction } from '../../../js/dialog.js';
export let allEducationForms = [];
@@ -58,12 +59,12 @@ export async function initEduForms() {
efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete-edu-form');
if (!btn) return;
if (!confirm('Удалить форму обучения?')) return;
if (!await confirmAction({ title: 'Удаление формы обучения', message: 'Удалить форму обучения?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/education-forms/' + btn.dataset.id);
loadEF();
} catch (e) {
alert(e.message || 'Ошибка удаления');
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить форму обучения' });
}
});

View File

@@ -1,5 +1,6 @@
import { api } from '../../../js/api.js';
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
import { confirmAction } from '../../../js/dialog.js';
const APPLY_MODE_LABELS = {
DEFAULT: 'применяется по умолчанию',
@@ -185,7 +186,12 @@ export async function initTimeSlotsSettings() {
async function deleteActiveScope() {
const scope = selectedScope();
if (!scope || scope.systemScope) return;
if (!confirm(`Удалить сетку "${scope.name}" вместе с её слотами и ручными назначениями?`)) return;
if (!await confirmAction({
title: 'Удаление сетки времени',
message: `Удалить сетку «${scope.name}» вместе с её слотами и ручными назначениями?`,
confirmLabel: 'Удалить сетку',
danger: true
})) return;
try {
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
activeScopeId = defaultScope()?.id || null;
@@ -244,7 +250,7 @@ export async function initTimeSlotsSettings() {
}
if (deleteButton) {
if (!confirm('Удалить временной слот?')) return;
if (!await confirmAction({ title: 'Удаление временного слота', message: 'Удалить временной слот?', confirmLabel: 'Удалить', danger: true })) return;
try {
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
showAlert('time-slot-alert', 'Временной слот удалён', 'success');

View File

@@ -5,6 +5,109 @@
</div>
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
<div class="card">
<div class="card-header-row">
<h2>Учебные годы</h2>
<button class="btn btn-md btn-secondary" id="academic-years-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Период</th>
<th>Семестры</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-years-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
<button type="button" class="btn btn-md btn-ghost" id="academic-calendar-reset">Очистить</button>
</div>
<form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id">
<div class="form-row">
<div class="form-group">
<label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-specialty">Специальность</label>
<select id="academic-calendar-specialty" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-profile">Профиль</label>
<select id="academic-calendar-profile" required>
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-study-form">Форма обучения</label>
<select id="academic-calendar-study-form" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить график</button>
</div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Календарные графики</h2>
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
</div>
<div class="form-row">
<div class="form-group">
<label for="academic-calendars-search">Поиск графиков</label>
<input type="search" id="academic-calendars-search" placeholder="Направление, профиль, форма или год">
</div>
<div class="form-group">
<label for="academic-calendars-year-filter">Учебный год</label>
<select id="academic-calendars-year-filter" data-native-select="true"><option value="">Все учебные годы</option></select>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Форма</th>
<th>Курсов</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-calendars-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="academic-calendars-pagination"></div>
</div>
<div class="card create-card academic-year-builder-card">
<div class="card-header-row">
<div>
@@ -81,98 +184,6 @@
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Учебные годы</h2>
<button class="btn btn-md btn-secondary" id="academic-years-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Период</th>
<th>Семестры</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-years-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
<button type="button" class="btn btn-md btn-ghost" id="academic-calendar-reset">Очистить</button>
</div>
<form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id">
<div class="form-row">
<div class="form-group">
<label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-specialty">Специальность</label>
<select id="academic-calendar-specialty" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-profile">Профиль</label>
<select id="academic-calendar-profile" required>
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-study-form">Форма обучения</label>
<select id="academic-calendar-study-form" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить график</button>
</div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Календарные графики</h2>
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Форма</th>
<th>Курсов</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-calendars-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>
@@ -181,7 +192,7 @@
<div class="form-row">
<div class="form-group">
<label for="calendar-editor-calendar">График</label>
<select id="calendar-editor-calendar">
<select id="calendar-editor-calendar" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
@@ -226,7 +237,7 @@
<div class="form-row">
<div class="form-group">
<label for="calendar-subject-calendar">График</label>
<select id="calendar-subject-calendar">
<select id="calendar-subject-calendar" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>

View File

@@ -71,6 +71,23 @@
</div>
</div>
</div>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="groups-search">Поиск</label>
<input type="search" id="groups-search" placeholder="Название, код специальности, профиль">
</div>
<div class="form-group">
<label for="groups-sort">Сортировка</label>
<select id="groups-sort" data-native-select="true">
<option value="name,asc">По названию А–Я</option>
<option value="name,desc">По названию Я–А</option>
<option value="yearStartStudy,desc">Сначала новые наборы</option>
<option value="groupSize,desc">Сначала крупные</option>
<option value="status,asc">По статусу</option>
</select>
</div>
<button type="button" class="btn btn-md btn-ghost" id="groups-filter-reset">Сбросить фильтры</button>
</div>
<div class="table-wrap">
<table id="groups-table">
<thead>
@@ -94,6 +111,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="groups-pagination"></div>
</div>
<!-- Модальное окно редактирования группы -->
@@ -232,7 +250,7 @@
</div>
<div class="form-group" style="flex: 2; margin-bottom: 0;">
<label for="manage-calendar-id">Календарный график</label>
<select id="manage-calendar-id" required>
<select id="manage-calendar-id" data-native-select="true" required>
<option value="">Выберите учебный год</option>
</select>
</div>

View File

@@ -10,9 +10,13 @@
</p>
</div>
<div class="quality-run-panel">
<label for="quality-year">Учебный год</label>
<select id="quality-year" data-native-select="true">
<option value="">Загрузка годов…</option>
</select>
<label for="quality-semester">Семестр</label>
<select id="quality-semester" data-native-select="true">
<option value="">Загрузка семестров…</option>
<option value="">Сначала выберите год</option>
</select>
<label for="quality-version">Версия</label>
<select id="quality-version" data-native-select="true">

View File

@@ -6,10 +6,20 @@
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div class="version-hero-controls">
<div class="version-hero-period-grid">
<div>
<label for="version-year">Учебный год</label>
<select id="version-year" data-native-select="true">
<option value="">Загрузка годов…</option>
</select>
</div>
<div>
<label for="version-semester">Рабочий семестр</label>
<select id="version-semester" data-native-select="true">
<option value="">Загрузка семестров…</option>
<option value="">Сначала выберите год</option>
</select>
</div>
</div>
<button type="button" class="btn btn-md btn-primary" id="version-create">Новый черновик</button>
</div>
</header>

View File

@@ -21,21 +21,21 @@
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
<label for="schedule-view-group">Группа</label>
<select id="schedule-view-group">
<select id="schedule-view-group" data-native-select="true">
<option value="">Все группы</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
<label for="schedule-view-teacher">Преподаватель</label>
<select id="schedule-view-teacher">
<select id="schedule-view-teacher" data-native-select="true">
<option value="">Все преподаватели</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
<label for="schedule-view-classroom">Аудитория</label>
<select id="schedule-view-classroom">
<select id="schedule-view-classroom" data-native-select="true">
<option value="">Все аудитории</option>
</select>
</div>
@@ -53,10 +53,16 @@
<details class="schedule-view-extra-filters">
<summary>Дополнительные фильтры</summary>
<div class="schedule-view-extra-grid">
<div class="form-group">
<label for="schedule-view-year">Учебный год</label>
<select id="schedule-view-year">
<option value="">Загрузка годов…</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-semester">Семестр</label>
<select id="schedule-view-semester">
<option value="">По текущей дате</option>
<option value="">Сначала выберите год</option>
</select>
</div>
<div class="form-group">

View File

@@ -1,3 +1,9 @@
<div class="schedule-initial-state" id="schedule-initial-state" role="status" aria-live="polite">
<span class="state-spinner" aria-hidden="true"></span>
<span>Загружаем конструктор правил…</span>
</div>
<div class="schedule-initial-content is-loading" id="schedule-initial-content" aria-busy="true">
<section class="version-constructor-bar" id="schedule-version-context" data-status="PUBLISHED">
<div>
<span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
@@ -5,13 +11,29 @@
<p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
</div>
<div class="version-constructor-controls">
<div class="version-constructor-field">
<label for="schedule-version-year">Учебный год</label>
<select id="schedule-version-year" data-native-select="true">
<option value="">Загрузка годов…</option>
</select>
</div>
<div class="version-constructor-field">
<label for="schedule-version-semester">Семестр</label>
<select id="schedule-version-semester" data-native-select="true">
<option value="">Сначала выберите год</option>
</select>
</div>
<div class="version-constructor-field version-constructor-field-wide">
<label for="schedule-version-select">Версия расписания</label>
<select id="schedule-version-select" data-native-select="true">
<option value="">Загрузка расписаний…</option>
<option value="">Сначала выберите семестр</option>
</select>
</div>
<div class="version-constructor-actions">
<button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
</div>
</div>
</section>
<div class="version-modal schedule-draft-modal" id="schedule-draft-modal" hidden>
@@ -106,6 +128,8 @@
</svg>
</div>
<div class="dropdown-menu" id="schedule-rule-groups-menu">
<input type="search" id="schedule-rule-group-search" class="schedule-group-search"
placeholder="Найти группу" aria-label="Поиск групп" autocomplete="off">
<div id="schedule-rule-group-checkboxes" class="checkbox-group-vertical schedule-group-checkboxes"></div>
</div>
</div>
@@ -197,8 +221,21 @@
<h2>Правила динамического расписания</h2>
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
</div>
<div class="form-group">
<label for="schedule-rules-search">Поиск правил</label>
<input type="search" id="schedule-rules-search" placeholder="Дисциплина, группа или ID правила">
</div>
<div class="table-wrap">
<table id="schedule-table">
<colgroup>
<col class="schedule-rule-col-id">
<col class="schedule-rule-col-subject">
<col class="schedule-rule-col-semester">
<col class="schedule-rule-col-groups">
<col class="schedule-rule-col-load">
<col class="schedule-rule-col-slots">
<col class="schedule-rule-col-actions">
</colgroup>
<thead>
<tr>
<th>ID</th>
@@ -217,6 +254,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="schedule-rules-pagination"></div>
</div>
<button type="button"
@@ -272,3 +310,4 @@
<div class="loading-row schedule-visual-state">Загрузка...</div>
</div>
</aside>
</div>

View File

@@ -25,6 +25,26 @@
<div class="card">
<h2>Все дисциплины</h2>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="subjects-search">Поиск</label>
<input type="search" id="subjects-search" placeholder="Название или код дисциплины">
</div>
<div class="form-group">
<label for="subjects-department-filter">Кафедра</label>
<select id="subjects-department-filter" data-native-select="true"><option value="">Все кафедры</option></select>
</div>
<div class="form-group">
<label for="subjects-sort">Сортировка</label>
<select id="subjects-sort" data-native-select="true">
<option value="name,asc">По названию А–Я</option>
<option value="name,desc">По названию Я–А</option>
<option value="code,asc">По коду</option>
<option value="departmentId,asc">По кафедре</option>
</select>
</div>
</div>
<div class="form-alert" id="subjects-alert" role="alert"></div>
<div class="table-wrap">
<table id="subjects-table">
<thead>
@@ -44,6 +64,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="subjects-pagination"></div>
</div>
<!-- Модальное окно привязки Преподавателей -->
@@ -65,7 +86,7 @@
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-ts-teacher-select">Преподаватель</label>
<select id="manage-ts-teacher-select" required>
<select id="manage-ts-teacher-select" data-native-select="true" required>
<option value="">Загрузка...</option>
</select>
</div>

View File

@@ -23,7 +23,7 @@
<div class="absence-form-grid">
<div class="form-group absence-teacher-field">
<label for="teacher-absence-teacher">Преподаватель</label>
<select id="teacher-absence-teacher" required>
<select id="teacher-absence-teacher" data-native-select="true" required>
<option value="">Загрузка...</option>
</select>
</div>

View File

@@ -7,6 +7,21 @@
</div>
</div>
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="teacher-requests-search">Поиск</label>
<input type="search" id="teacher-requests-search" placeholder="ФИО, логин, должность или кафедра">
</div>
<div class="form-group">
<label for="teacher-requests-status">Статус</label>
<select id="teacher-requests-status" data-native-select="true">
<option value="PENDING">Ожидают рассмотрения</option>
<option value="APPROVED">Одобрены</option>
<option value="REJECTED">Отклонены</option>
<option value="">Все статусы</option>
</select>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
@@ -25,4 +40,5 @@
</tbody>
</table>
</div>
<div class="pagination" id="teacher-requests-pagination"></div>
</div>

View File

@@ -30,6 +30,10 @@
<div class="card-header-row">
<h2>Кафедры</h2>
</div>
<div class="form-group">
<label for="departments-search">Поиск по названию или коду кафедры</label>
<input type="search" id="departments-search" placeholder="Введите название или код">
</div>
<div class="table-wrap">
<table id="departments-table">
<thead>
@@ -47,6 +51,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="departments-pagination"></div>
</div>
</div>
@@ -74,6 +79,10 @@
<div class="card-header-row">
<h2>Специальности</h2>
</div>
<div class="form-group">
<label for="specialties-search">Поиск по названию или коду специальности</label>
<input type="search" id="specialties-search" placeholder="Введите название или код">
</div>
<div class="table-wrap">
<table id="specialties-table">
<thead>
@@ -91,6 +100,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="specialties-pagination"></div>
</div>
</div>
@@ -128,6 +138,10 @@
<div class="card-header-row">
<h2>Профили обучения</h2>
</div>
<div class="form-group">
<label for="profiles-search">Поиск по профилю, специальности или описанию</label>
<input type="search" id="profiles-search" placeholder="Введите название или код">
</div>
<div class="table-wrap">
<table id="profiles-table">
<thead>
@@ -146,6 +160,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="profiles-pagination"></div>
</div>
</div>
</div>

View File

@@ -42,6 +42,35 @@
<div class="card">
<h2>Все пользователи</h2>
<div class="registry-toolbar">
<div class="form-group registry-search">
<label for="users-search">Поиск</label>
<input type="search" id="users-search" placeholder="Логин, ФИО или должность">
</div>
<div class="form-group">
<label for="users-role-filter">Роль</label>
<select id="users-role-filter" data-native-select="true">
<option value="">Все роли</option>
<option value="ADMIN">Администратор</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="TEACHER">Преподаватель</option>
<option value="STUDENT">Студент</option>
</select>
</div>
<div class="form-group">
<label for="users-sort">Сортировка</label>
<select id="users-sort" data-native-select="true">
<option value="fullName,asc">По ФИО А–Я</option>
<option value="fullName,desc">По ФИО Я–А</option>
<option value="username,asc">По логину</option>
<option value="role,asc">По роли</option>
<option value="status,asc">По статусу</option>
</select>
</div>
</div>
<div class="form-alert" id="users-alert" role="alert"></div>
<div class="table-wrap">
<table id="users-table">
<thead>
@@ -62,4 +91,5 @@
</tbody>
</table>
</div>
<div class="pagination" id="users-pagination"></div>
</div>

View File

@@ -9,7 +9,7 @@
"build": "npm run build:vendor",
"build:vendor": "node scripts/build-vendor.mjs",
"test": "node --test tests/*.test.mjs",
"check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check admin/js/views/schedule.js && node --check admin/js/views/schedule-versions.js && node --check admin/js/views/teacher-absences.js && node --check admin/js/views/schedule-quality.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs"
"check": "node --check auth-session.js && node --check telemetry.js && node --check schedule-overview.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check admin/js/academic-period.js && node --check admin/js/async-combobox.js && node --check admin/js/dialog.js && node --check admin/js/dirty-state.js && node --check admin/js/pagination.js && node --check admin/js/url-state.js && node --check admin/js/view-state.js && node --check admin/js/views/groups.js && node --check admin/js/views/users.js && node --check admin/js/views/subjects.js && node --check admin/js/views/teacher-requests.js && node --check admin/js/views/academic-calendar.js && node --check admin/js/views/schedule.js && node --check admin/js/views/schedule-view.js && node --check admin/js/views/schedule-override-panel.js && node --check admin/js/views/schedule-versions.js && node --check admin/js/views/teacher-absences.js && node --check admin/js/views/schedule-quality.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs"
},
"devDependencies": {
"@opentelemetry/api": "1.9.0",

View File

@@ -0,0 +1,320 @@
.schedule-overview {
margin-bottom: 18px;
padding: 18px;
border: 1px solid var(--panel-border, var(--border));
border-radius: 16px;
background:
radial-gradient(circle at 92% 8%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 34%),
var(--panel, var(--card));
box-shadow: var(--shadow);
}
.schedule-overview-head,
.schedule-overview-card-head,
.schedule-overview-next,
.schedule-overview-lesson,
.schedule-overview-state {
display: flex;
align-items: center;
}
.schedule-overview-head,
.schedule-overview-card-head {
justify-content: space-between;
gap: 16px;
}
.schedule-overview-head {
margin-bottom: 14px;
}
.schedule-overview-kicker,
.schedule-overview-card-head span:first-child {
color: var(--accent);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.schedule-overview-head h2,
.schedule-overview-card h3,
.schedule-overview-next h4,
.schedule-overview-next p,
.schedule-overview-empty {
margin: 0;
}
.schedule-overview-head h2 {
margin-top: 3px;
font-size: 20px;
}
.schedule-overview-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
.schedule-overview-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--panel-border, var(--border));
border-radius: 12px;
background: color-mix(in srgb, var(--panel, var(--card)) 88%, transparent);
}
.schedule-overview-card.is-next {
border-color: color-mix(in srgb, var(--accent) 34%, var(--panel-border, var(--border)));
background: color-mix(in srgb, var(--accent) 7%, var(--panel, var(--card)));
}
.schedule-overview-card h3 {
margin-top: 3px;
font-size: 18px;
}
.schedule-overview-count,
.schedule-overview-badge,
.schedule-overview-type {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-weight: 800;
}
.schedule-overview-count {
min-width: 34px;
min-height: 34px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
color: var(--accent);
}
.schedule-overview-badge,
.schedule-overview-type {
padding: 5px 9px;
background: var(--accent);
color: #fff;
font-size: 11px;
}
.schedule-overview-list {
display: grid;
gap: 7px;
margin-top: 13px;
}
.schedule-overview-lesson {
gap: 10px;
padding-top: 7px;
border-top: 1px solid var(--panel-border, var(--border));
}
.schedule-overview-lesson time {
min-width: 46px;
color: var(--accent);
font-weight: 800;
}
.schedule-overview-lesson div {
min-width: 0;
}
.schedule-overview-lesson strong,
.schedule-overview-lesson small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-overview-lesson small,
.schedule-overview-next p,
.schedule-overview-empty {
color: var(--muted);
font-size: 12px;
}
.schedule-overview-next {
align-items: flex-start;
gap: 14px;
margin-top: 15px;
}
.schedule-overview-next-time {
min-width: 72px;
padding: 10px;
border-radius: 10px;
background: var(--accent);
color: #fff;
text-align: center;
}
.schedule-overview-next-time strong,
.schedule-overview-next-time span {
display: block;
}
.schedule-overview-next-time strong {
font-size: 18px;
}
.schedule-overview-next-time span {
margin-top: 2px;
font-size: 10px;
opacity: 0.82;
}
.schedule-overview-next h4 {
font-size: 18px;
}
.schedule-overview-next p {
margin: 5px 0 10px;
line-height: 1.4;
}
.schedule-overview-empty {
margin-top: 15px;
line-height: 1.5;
}
.schedule-overview-state {
justify-content: center;
gap: 10px;
min-height: 132px;
color: var(--muted);
text-align: center;
}
.schedule-overview-state.is-error {
color: var(--warning, #b45309);
}
.schedule-overview-spinner,
.state-spinner {
width: 18px;
height: 18px;
border: 2px solid color-mix(in srgb, var(--accent) 20%, transparent);
border-top-color: var(--accent);
border-radius: 50%;
animation: schedule-overview-spin 0.8s linear infinite;
}
@keyframes schedule-overview-spin {
to { transform: rotate(360deg); }
}
.async-combobox {
position: relative;
min-width: 0;
width: 100%;
}
.async-combobox-control {
position: relative;
}
.async-combobox-control input {
width: 100%;
min-height: 40px;
padding: 0 42px 0 12px;
border: 1px solid var(--panel-border, var(--border));
border-radius: 8px;
background: var(--panel, var(--card));
color: var(--text);
font: inherit;
}
.async-combobox-control input:focus {
border-color: var(--accent);
outline: 3px solid color-mix(in srgb, var(--accent) 16%, transparent);
}
.async-combobox-clear {
position: absolute;
top: 50%;
right: 8px;
width: 30px;
height: 30px;
padding: 0;
transform: translateY(-50%);
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.async-combobox-menu {
position: absolute;
z-index: 40;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 320px;
padding: 6px;
overflow-y: auto;
border: 1px solid var(--panel-border, var(--border));
border-radius: 10px;
background: var(--panel, var(--card));
box-shadow: var(--shadow);
}
.async-combobox-option {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
padding: 9px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.async-combobox-option:hover,
.async-combobox-option:focus,
.async-combobox-option[aria-selected="true"] {
background: color-mix(in srgb, var(--accent) 10%, transparent);
outline: none;
}
.async-combobox-option small,
.async-combobox-state {
color: var(--muted);
font-size: 12px;
}
.async-combobox-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
padding: 8px;
text-align: center;
}
.async-combobox-state.is-error {
color: var(--warning, #b45309);
}
@media (max-width: 720px) {
.schedule-overview {
padding: 14px;
}
.schedule-overview-head {
align-items: flex-start;
flex-direction: column;
}
.schedule-overview-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,161 @@
export function renderScheduleOverviewState(container, { kind = 'loading', message = '', onRetry = null } = {}) {
if (!container) return;
const labels = {
loading: message || 'Загружаем ближайшие занятия…',
empty: message || 'В ближайшие дни занятий нет',
error: message || 'Не удалось загрузить ближайшие занятия'
};
container.innerHTML = `
<div class="schedule-overview-state is-${escapeHtml(kind)}" role="${kind === 'error' ? 'alert' : 'status'}">
${kind === 'loading' ? '<span class="schedule-overview-spinner" aria-hidden="true"></span>' : ''}
<span>${escapeHtml(labels[kind] || message)}</span>
${kind === 'error' && onRetry ? '<button class="btn btn-md btn-secondary" type="button" data-overview-retry>Повторить</button>' : ''}
</div>
`;
container.querySelector('[data-overview-retry]')?.addEventListener('click', onRetry);
}
export function renderScheduleOverview(container, lessons, { role = 'student', now = new Date(), onGoToday = null } = {}) {
if (!container) return { todayCount: 0, hasNext: false };
const { todayIso, todayLessons, nextLesson } = buildScheduleOverviewModel(lessons, now);
container.innerHTML = `
<header class="schedule-overview-head">
<div>
<span class="schedule-overview-kicker">Быстрый обзор</span>
<h2>${escapeHtml(formatLongDate(now))}</h2>
</div>
<button class="btn btn-md btn-secondary" type="button" data-overview-today>Открыть текущую неделю</button>
</header>
<div class="schedule-overview-grid">
<article class="schedule-overview-card is-today">
<div class="schedule-overview-card-head">
<div>
<span>Сегодня</span>
<h3>${todayLessons.length ? pluralLessons(todayLessons.length) : 'Свободный день'}</h3>
</div>
<span class="schedule-overview-count">${todayLessons.length}</span>
</div>
<div class="schedule-overview-list">
${todayLessons.length
? todayLessons.map(lesson => renderCompactLesson(lesson, role)).join('')
: '<p class="schedule-overview-empty">На сегодня занятий нет.</p>'}
</div>
</article>
<article class="schedule-overview-card is-next">
<div class="schedule-overview-card-head">
<div>
<span>Следующая пара</span>
<h3>${nextLesson ? escapeHtml(relativeDate(nextLesson.date, todayIso)) : 'Пока ничего не запланировано'}</h3>
</div>
${nextLesson ? `<span class="schedule-overview-badge">${lessonStart(nextLesson) <= now ? 'Идёт сейчас' : escapeHtml(trimTime(nextLesson.startTime))}</span>` : ''}
</div>
${nextLesson ? renderNextLesson(nextLesson, role) : '<p class="schedule-overview-empty">В ближайшие 14 дней занятий нет.</p>'}
</article>
</div>
`;
container.querySelector('[data-overview-today]')?.addEventListener('click', onGoToday || (() => {}));
return { todayCount: todayLessons.length, hasNext: Boolean(nextLesson) };
}
export function buildScheduleOverviewModel(lessons, now = new Date()) {
const ordered = [...(lessons || [])].sort((left, right) => lessonStart(left) - lessonStart(right));
const todayIso = toIsoDate(now);
return {
todayIso,
todayLessons: ordered.filter(lesson => lesson.date === todayIso),
nextLesson: ordered.find(lesson => lessonEnd(lesson) >= now) || null
};
}
function renderCompactLesson(lesson, role) {
return `
<div class="schedule-overview-lesson">
<time>${escapeHtml(trimTime(lesson.startTime))}</time>
<div>
<strong>${escapeHtml(lesson.subjectName || 'Занятие')}</strong>
<small>${escapeHtml(lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются')}</small>
</div>
</div>
`;
}
function renderNextLesson(lesson, role) {
return `
<div class="schedule-overview-next">
<div class="schedule-overview-next-time">
<strong>${escapeHtml(trimTime(lesson.startTime))}</strong>
<span>до ${escapeHtml(trimTime(lesson.endTime))}</span>
</div>
<div>
<h4>${escapeHtml(lesson.subjectName || 'Занятие')}</h4>
<p>${escapeHtml(lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются')}</p>
${lesson.lessonTypeName ? `<span class="schedule-overview-type">${escapeHtml(lesson.lessonTypeName)}</span>` : ''}
</div>
</div>
`;
}
function lessonMeta(lesson, role) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroup = Array.isArray(lesson.subgroupNames) ? lesson.subgroupNames.join(', ') : lesson.subgroupName;
return role === 'teacher'
? [groups || subgroup, lesson.classroomName].filter(Boolean)
: [lesson.teacherName, lesson.classroomName, subgroup].filter(Boolean);
}
function lessonStart(lesson) {
return new Date(`${lesson.date}T${normalizeTime(lesson.startTime)}`);
}
function lessonEnd(lesson) {
return new Date(`${lesson.date}T${normalizeTime(lesson.endTime || lesson.startTime)}`);
}
function normalizeTime(value) {
const text = String(value || '00:00:00');
return text.length === 5 ? `${text}:00` : text;
}
function relativeDate(value, todayIso) {
if (value === todayIso) return 'Сегодня';
const today = parseIsoDate(todayIso);
const target = parseIsoDate(value);
const dayDifference = Math.round((target - today) / 86400000);
if (dayDifference === 1) return 'Завтра';
return target.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' });
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatLongDate(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function pluralLessons(count) {
const mod10 = count % 10;
const mod100 = count % 100;
const noun = mod10 === 1 && mod100 !== 11 ? 'занятие' : mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'занятия' : 'занятий';
return `${count} ${noun}`;
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}

View File

@@ -4,6 +4,10 @@ import {
logoutSession,
restoreSession
} from '../auth-session.js';
import { AsyncCombobox } from '../admin/js/async-combobox.js';
import { readUrlState, writeUrlState } from '../admin/js/url-state.js';
import { renderScheduleOverview, renderScheduleOverviewState } from '../schedule-overview.js';
import { trackUxEvent } from '../telemetry.js';
(async () => {
const session = await restoreSession();
@@ -16,7 +20,10 @@ import {
const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker');
let weekStart = startOfWeek(new Date());
const scheduleOverview = document.getElementById('schedule-overview');
const pageState = readUrlState({ studentGroup: '', studentDate: '' });
let weekStart = startOfWeek(pageState.studentDate ? parseIsoDate(pageState.studentDate) : new Date());
let groupCombobox = null;
document.getElementById('logout-link').addEventListener('click', async (event) => {
event.preventDefault();
@@ -32,46 +39,96 @@ import {
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('today-week').addEventListener('click', goToCurrentWeek);
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
weekStart = startOfWeek(parseIsoDate(datePicker.value));
loadSchedule();
}
});
groupSelect.addEventListener('change', () => {
localStorage.setItem('studentGroupId', groupSelect.value);
writeUrlState({ studentGroup: groupSelect.value });
loadSchedule();
loadScheduleOverview();
trackUxEvent('student_group_selected', { groupId: groupSelect.value });
});
init();
async function init() {
await loadGroups();
await loadSchedule();
await Promise.all([loadSchedule(), loadScheduleOverview()]);
}
async function loadGroups() {
try {
const groups = await fetchJson('/api/groups');
const savedGroupId = pageState.studentGroup || localStorage.getItem('studentGroupId') || '';
const groups = await fetchGroupOptions('', savedGroupId);
if (!groups.length) {
groupSelect.innerHTML = '<option value="">Группы не найдены</option>';
renderScheduleOverviewState(scheduleOverview, { kind: 'empty', message: 'Нет доступных групп' });
return;
}
const savedGroupId = localStorage.getItem('studentGroupId');
groupSelect.innerHTML = groups.map(group => (
`<option value="${escapeHtml(group.id)}">${escapeHtml(group.name)}</option>`
)).join('');
groupSelect.value = savedGroupId && groups.some(group => String(group.id) === savedGroupId)
? savedGroupId
: String(groups[0].id);
const selected = groups.find(group => String(group.id) === String(savedGroupId)) || groups[0];
groupSelect.innerHTML = `<option value="${escapeHtml(selected.id)}" selected>${escapeHtml(selected.name)}</option>`;
groupSelect.value = String(selected.id);
localStorage.setItem('studentGroupId', groupSelect.value);
writeUrlState({ studentGroup: groupSelect.value });
groupCombobox = new AsyncCombobox(groupSelect, {
allowEmpty: false,
placeholder: 'Найдите группу по названию или специальности',
emptyLabel: 'Подходящие группы не найдены',
load: fetchGroupOptions,
optionDescription: group => [
group.specialtyCode,
group.specialtyProfileName,
group.course ? `${group.course} курс` : ''
].filter(Boolean).join(' · ')
});
} catch (error) {
groupSelect.innerHTML = '<option value="">Ошибка загрузки групп</option>';
showStatus(error.message, true);
renderScheduleOverviewState(scheduleOverview, { kind: 'error', message: error.message, onRetry: init });
}
}
function fetchGroupOptions(query = '', selectedId = '') {
const params = new URLSearchParams({ query, limit: '20' });
if (selectedId) params.set('selectedId', selectedId);
return fetchJson(`/api/groups/options?${params}`);
}
function goToCurrentWeek() {
weekStart = startOfWeek(new Date());
loadSchedule();
}
async function loadScheduleOverview() {
const groupId = groupSelect.value;
if (!groupId) {
renderScheduleOverviewState(scheduleOverview, { kind: 'empty', message: 'Выберите группу, чтобы увидеть ближайшие занятия' });
return;
}
renderScheduleOverviewState(scheduleOverview, { kind: 'loading' });
const now = new Date();
const startDate = toIsoDate(now);
const endDate = toIsoDate(addDays(now, 14));
try {
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
const overview = renderScheduleOverview(scheduleOverview, lessons, {
role: 'student',
now,
onGoToday: goToCurrentWeek
});
trackUxEvent('student_schedule_overview_loaded', { ...overview, groupId });
} catch (error) {
renderScheduleOverviewState(scheduleOverview, {
kind: 'error',
message: error.message,
onRetry: loadScheduleOverview
});
}
}
@@ -81,6 +138,7 @@ import {
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
writeUrlState({ studentDate: startDate });
renderEmptyWeek('Загрузка...');
if (!groupId) {
@@ -93,6 +151,7 @@ import {
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
trackUxEvent('student_schedule_loaded', { lessons: lessons.length, startDate, groupId });
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
@@ -187,6 +246,11 @@ import {
return copy;
}
function parseIsoDate(value) {
const [year, month, day] = String(value || '').split('-').map(Number);
return year && month && day ? new Date(year, month - 1, day) : new Date();
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
@@ -219,4 +283,3 @@ import {
.replaceAll("'", '&#039;');
}
})();

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание студента</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="../schedule-overview.css">
</head>
<body>
<main class="shell">
@@ -43,6 +44,7 @@
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
</section>
<section class="schedule-overview" id="schedule-overview" aria-live="polite"></section>
<div class="status" id="status-line"></div>
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
</main>

View File

@@ -4,6 +4,9 @@ import {
logoutSession,
restoreSession
} from '../auth-session.js';
import { renderScheduleOverview, renderScheduleOverviewState } from '../schedule-overview.js';
import { readUrlState, writeUrlState } from '../admin/js/url-state.js';
import { trackUxEvent } from '../telemetry.js';
(async () => {
const session = await restoreSession();
@@ -16,6 +19,7 @@ import {
const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker');
const scheduleOverview = document.getElementById('schedule-overview');
const absenceForm = document.getElementById('teacher-absence-form');
const absenceList = document.getElementById('teacher-absence-list');
const absenceStatus = document.getElementById('teacher-absence-status');
@@ -45,7 +49,8 @@ import {
let scheduleLessons = new Map();
let activeRequestLesson = null;
let activeCandidates = null;
let weekStart = startOfWeek(new Date());
const pageState = readUrlState({ teacherTab: 'schedule', teacherDate: '' });
let weekStart = startOfWeek(pageState.teacherDate ? parseIsoDate(pageState.teacherDate) : new Date());
document.getElementById('logout-link').addEventListener('click', async (event) => {
event.preventDefault();
@@ -61,14 +66,11 @@ import {
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('today-week').addEventListener('click', goToCurrentWeek);
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
weekStart = startOfWeek(parseIsoDate(datePicker.value));
loadSchedule();
}
});
@@ -109,7 +111,9 @@ import {
absenceStart.value = todayIso;
absenceEnd.value = todayIso;
switchWorkspace(['schedule', 'preferences', 'requests'].includes(pageState.teacherTab) ? pageState.teacherTab : 'schedule');
loadSchedule();
loadScheduleOverview();
loadAbsences();
loadPreferenceMeta();
loadChangeRequests();
@@ -123,6 +127,39 @@ import {
view.hidden = !active;
view.classList.toggle('active', active);
});
writeUrlState({ teacherTab: name });
trackUxEvent('teacher_workspace_opened', { section: name });
}
function goToCurrentWeek() {
weekStart = startOfWeek(new Date());
loadSchedule();
}
async function loadScheduleOverview() {
if (!teacherId) {
renderScheduleOverviewState(scheduleOverview, { kind: 'error', message: 'Не удалось определить преподавателя' });
return;
}
renderScheduleOverviewState(scheduleOverview, { kind: 'loading' });
const now = new Date();
const startDate = toIsoDate(now);
const endDate = toIsoDate(addDays(now, 14));
try {
const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`);
const overview = renderScheduleOverview(scheduleOverview, lessons, {
role: 'teacher',
now,
onGoToday: goToCurrentWeek
});
trackUxEvent('teacher_schedule_overview_loaded', overview);
} catch (error) {
renderScheduleOverviewState(scheduleOverview, {
kind: 'error',
message: error.message,
onRetry: loadScheduleOverview
});
}
}
async function loadPreferenceMeta() {
@@ -533,6 +570,7 @@ import {
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
writeUrlState({ teacherDate: startDate });
if (!teacherId) {
renderEmptyWeek('Не удалось определить преподавателя');
@@ -546,6 +584,7 @@ import {
scheduleLessons = new Map(lessons.map(lesson => [lessonKey(lesson), lesson]));
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
trackUxEvent('teacher_schedule_loaded', { lessons: lessons.length, startDate });
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
@@ -703,6 +742,11 @@ import {
return copy;
}
function parseIsoDate(value) {
const [year, month, day] = String(value || '').split('-').map(Number);
return year && month && day ? new Date(year, month - 1, day) : new Date();
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
@@ -807,4 +851,3 @@ import {
.replaceAll("'", '&#039;');
}
})();

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Кабинет преподавателя</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="../schedule-overview.css">
</head>
<body>
<main class="shell">
@@ -49,6 +50,7 @@
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
</section>
<section class="schedule-overview" id="schedule-overview" aria-live="polite"></section>
<div class="status" id="status-line"></div>
<section class="teacher-absence-panel">
<div class="teacher-absence-heading">

View File

@@ -1,13 +1,40 @@
let telemetryPromise = null;
const pendingUxEvents = [];
export function initializeTelemetry() {
if (['localhost', '127.0.0.1', '::1'].includes(window.location.hostname)) {
return Promise.resolve();
}
if (!telemetryPromise) {
telemetryPromise = import('/vendor/otel.js').catch(error => {
telemetryPromise = import('/vendor/otel.js').then(() => flushPendingUxEvents()).catch(error => {
console.warn('Не удалось инициализировать браузерную телеметрию');
});
}
return telemetryPromise;
}
export function trackUxEvent(name, attributes = {}) {
const event = {
name: String(name || 'неизвестное_событие'),
attributes: sanitizeAttributes(attributes),
timestamp: new Date().toISOString()
};
document.dispatchEvent(new CustomEvent('magistr:ux-event', { detail: event }));
if (typeof window.__magistrTrackUx === 'function') {
window.__magistrTrackUx(event.name, event.attributes);
} else {
pendingUxEvents.push(event);
if (pendingUxEvents.length > 100) pendingUxEvents.shift();
}
}
export function flushPendingUxEvents() {
if (typeof window.__magistrTrackUx !== 'function') return;
pendingUxEvents.splice(0).forEach(event => window.__magistrTrackUx(event.name, event.attributes));
}
function sanitizeAttributes(attributes) {
return Object.fromEntries(Object.entries(attributes || {})
.filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value))
.map(([key, value]) => [String(key), value]));
}

View File

@@ -6,6 +6,7 @@ import { resourceFromAttributes } from '@opentelemetry/resources';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { trace } from '@opentelemetry/api';
const exporter = new OTLPTraceExporter({
url: `${window.location.origin}/otel/v1/traces`
@@ -38,3 +39,10 @@ registerInstrumentations({
});
console.info('Браузерная телеметрия OpenTelemetry инициализирована');
const uxTracer = trace.getTracer('magistr-ux');
window.__magistrTrackUx = (name, attributes = {}) => {
const span = uxTracer.startSpan(`ux.${name}`, { attributes });
span.end();
};
document.dispatchEvent(new CustomEvent('magistr:telemetry-ready'));

View File

@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { scheduleSlotActionLabel } from '../admin/js/views/schedule.js';
import {
scheduleSlotActionLabel,
shouldMarkRuleFormDirty
} from '../admin/js/views/schedule.js';
const readFrontendFile = relativePath => readFile(
new URL(`../${relativePath}`, import.meta.url),
@@ -15,6 +18,20 @@ test('одиночный слот очищается, а несколько сл
assert.equal(scheduleSlotActionLabel(5), 'Удалить');
});
test('конструктор считает изменениями только действия пользователя', () => {
assert.equal(shouldMarkRuleFormDirty({ programmatic: true, type: 'change' }), false);
assert.equal(shouldMarkRuleFormDirty({ type: 'input', fromAsyncCombobox: true }), false);
assert.equal(shouldMarkRuleFormDirty({ type: 'change' }), true);
assert.equal(shouldMarkRuleFormDirty({ type: 'input' }), true);
});
test('поисковые списки просмотра расписаний загружаются свёрнутыми', async () => {
const scheduleView = await readFrontendFile('admin/js/views/schedule-view.js');
assert.match(scheduleView, /combo\.preload\(''\)/);
assert.doesNotMatch(scheduleView, /combo\.search\(''\)/);
});
test('в общих заголовках админ-панели и настроек нет бургер-меню', async () => {
const files = await Promise.all([
readFrontendFile('admin/index.html'),
@@ -104,3 +121,43 @@ test('таблица календарных графиков не содержи
assert.match(calendarTable, /colspan="6"/);
assert.doesNotMatch(calendarScript, /title:\s*buildCalendarTitleFromForm/);
});
test('конструктор правил показывает готовую компоновку без скачков колонок', async () => {
const [scheduleView, scheduleScript, components] = await Promise.all([
readFrontendFile('admin/views/schedule.html'),
readFrontendFile('admin/js/views/schedule.js'),
readFrontendFile('admin/css/components.css')
]);
assert.match(scheduleView, /id="schedule-initial-state"/);
assert.match(scheduleView, /id="schedule-initial-content"[^>]*class="schedule-initial-content is-loading"|class="schedule-initial-content is-loading"[^>]*id="schedule-initial-content"/);
assert.match(scheduleView, /<table id="schedule-table">\s*<colgroup>/s);
assert.match(scheduleScript, /finally\s*\{[\s\S]*initialContent\?\.classList\.remove\('is-loading'\)/);
assert.match(components, /#schedule-table\s*\{[^}]*table-layout:\s*fixed/s);
assert.match(components, /#schedule-table tbody tr\s*\{[^}]*animation:\s*none/s);
});
test('форма учебного года расположена после рабочих блоков календарного графика', async () => {
const calendarView = await readFrontendFile('admin/views/academic-calendar.html');
const calendarsRegistryIndex = calendarView.indexOf('id="academic-calendars-tbody"');
const academicYearBuilderIndex = calendarView.indexOf('academic-year-builder-card');
const managePanelEndIndex = calendarView.indexOf('</section>', academicYearBuilderIndex);
assert.ok(calendarsRegistryIndex >= 0);
assert.ok(academicYearBuilderIndex > calendarsRegistryIndex);
assert.ok(managePanelEndIndex > academicYearBuilderIndex);
});
test('поисковые и диалоговые списки имеют размытие и светлую тему', async () => {
const [components, absences, absenceView] = await Promise.all([
readFrontendFile('admin/css/components.css'),
readFrontendFile('admin/css/teacher-absences.css'),
readFrontendFile('admin/views/teacher-absences.html')
]);
assert.match(absenceView, /id="teacher-absence-teacher"[^>]*data-native-select="true"/);
assert.match(components, /\.async-combobox-menu\s*\{[^}]*backdrop-filter:\s*blur\(20px\)/s);
assert.match(components, /\[data-theme="light"\] \.async-combobox-menu\s*\{[^}]*background:\s*rgba\(255, 255, 255, 0\.96\)/s);
assert.match(absences, /\.teacher-dialogue-toolbar \.custom-select-menu\s*\{[^}]*backdrop-filter:\s*blur\(20px\)/s);
assert.match(absences, /\[data-theme="light"\] \.teacher-dialogue-toolbar \.custom-select-menu/);
});

Some files were not shown because too many files have changed in this diff Show More