7 Commits

146 changed files with 9065 additions and 1271 deletions

View File

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

@@ -7,8 +7,9 @@ import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService; import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import org.springframework.http.ResponseEntity; 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.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -19,6 +20,8 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.ArrayList;
import java.util.Locale;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -28,10 +31,6 @@ public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository; private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService; private final AcademicCalendarGridService calendarGridService;
@@ -40,10 +39,6 @@ public class AcademicCalendarController {
public AcademicCalendarController(AcademicCalendarRepository calendarRepository, public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository, SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository, AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService, AcademicCalendarGridService calendarGridService,
@@ -51,10 +46,6 @@ public class AcademicCalendarController {
AcademicStructureService academicStructureService) { AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarPeriodRepository = calendarPeriodRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository; this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService; this.calendarGridService = calendarGridService;
@@ -71,6 +62,46 @@ public class AcademicCalendarController {
.toList(); .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}") @GetMapping("/{id}")
public ResponseEntity<?> getCalendar(@PathVariable Long id) { public ResponseEntity<?> getCalendar(@PathVariable Long id) {
return calendarRepository.findByIdWithDetails(id) return calendarRepository.findByIdWithDetails(id)
@@ -81,10 +112,7 @@ public class AcademicCalendarController {
@PostMapping @PostMapping
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) { public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
try { try {
AcademicCalendar calendar = new AcademicCalendar(); return ResponseEntity.ok(toCalendarDto(academicStructureService.createCalendar(request)));
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
} }
@@ -153,41 +181,6 @@ public class AcademicCalendarController {
} }
} }
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
}
AcademicYear year = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId())
.orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден"));
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
throw new IllegalArgumentException("Профиль не относится к выбранной специальности");
}
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm));
calendar.setAcademicYear(year);
calendar.setSpeciality(speciality);
calendar.setSpecialtyProfile(profile);
calendar.setStudyForm(studyForm);
calendar.setCourseCount(request.courseCount());
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) { private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) { if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика"); throw new IllegalArgumentException("Слишком много дисциплин для одного графика");

View File

@@ -10,11 +10,15 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.ClassroomRepository; import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository; import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.ArrayList;
import java.util.Locale;
@RestController @RestController
@RequestMapping("/api/classrooms") @RequestMapping("/api/classrooms")
@@ -40,6 +44,26 @@ public class ClassroomController {
.toList(); .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 @PostMapping
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) { public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
if (request.getName() == null || request.getName().isBlank()) { if (request.getName() == null || request.getName().isBlank()) {

View File

@@ -1,10 +1,13 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto; import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse; 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.AcademicCalendarSubject;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality; import com.magistr.app.model.Speciality;
@@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collections; import java.util.Collections;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; 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}") @GetMapping("/{departmentId}")
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) { public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", 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.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto; import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException; import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService; import com.magistr.app.service.ScheduleRuleService;
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
return scheduleRuleService.getAll(semesterId, groupId, versionId); 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}") @GetMapping("/{id}")
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) { public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
return ResponseEntity.ok(scheduleRuleService.getById(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.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT}) @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController { public class SubgroupController {
@@ -36,8 +39,16 @@ public class SubgroupController {
} }
@GetMapping("/api/subgroups") @GetMapping("/api/subgroups")
public List<SubgroupDto> getAll() { public List<SubgroupDto> getAll(@RequestParam(required = false) List<Long> groupId) {
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream() 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) .map(this::toDto)
.toList(); .toList();
} }

View File

@@ -1,8 +1,10 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse; import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.Subject; import com.magistr.app.model.Subject;
@@ -11,9 +13,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
@RestController @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}") @GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) { 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.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse; import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse; import com.magistr.app.dto.UserResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository; 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.repository.UserRepository;
import com.magistr.app.service.BusinessTimeService; import com.magistr.app.service.BusinessTimeService;
import org.springframework.http.ResponseEntity; 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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
@RestController @RestController
@@ -55,6 +61,63 @@ public class TeacherCreationRequestController {
.toList(); .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") @PostMapping("/{id}/approve")
@Transactional @Transactional
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) { 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.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequestMapping("/api/teacher-subjects") @RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) @RequireRoles({Role.ADMIN, Role.DEPARTMENT})
@@ -53,15 +56,18 @@ public class TeacherSubjectController {
} }
@GetMapping @GetMapping
public List<TeacherSubjectResponse> getAll() { public List<TeacherSubjectResponse> getAll(@RequestParam(required = false) List<Long> subjectId) {
return teacherSubjectRepository.findAll().stream() 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())) .filter(ts -> canManage(ts.getUser(), ts.getSubject()))
.map(ts -> new TeacherSubjectResponse( .map(this::toResponse)
ts.getUserId(),
ts.getUser().getUsername(),
ts.getSubjectId(),
ts.getSubject().getName()
))
.toList(); .toList();
} }
@@ -136,4 +142,14 @@ public class TeacherSubjectController {
) )
&& departmentId.equals(subject.getDepartmentId()); && 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.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto; import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
import com.magistr.app.dto.UserResponse; import com.magistr.app.dto.UserResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Department; import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role; 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.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
@RestController @RestController
@@ -80,6 +86,23 @@ public class UserController {
return response; 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") @GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() { public List<UserResponse> getTeachers() {
@@ -91,6 +114,92 @@ public class UserController {
return response; 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}") @GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){ public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){

View File

@@ -53,7 +53,7 @@ public class WorkloadController {
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation( List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate effectiveDepartmentId, null, startDate, endDate
); );
return summarizeTeacherWorkload(lessons, startDate, endDate); return summarizeTeacherWorkload(lessons, startDate, endDate, effectiveDepartmentId);
} }
@GetMapping("/classrooms") @GetMapping("/classrooms")
@@ -98,15 +98,19 @@ public class WorkloadController {
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons, private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
LocalDate startDate, LocalDate startDate,
LocalDate endDate) { LocalDate endDate,
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = Long scopedDepartmentId) {
assignmentsByTeacher(lessons, startDate, endDate); Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = scopedDepartmentId == null
? assignmentsByTeacher(lessons, startDate, endDate)
: Map.of();
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream() Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> lesson.teacherId() != null) .filter(lesson -> lesson.teacherId() != null)
.collect(Collectors.groupingBy( .collect(Collectors.groupingBy(
lesson -> new TeacherDepartmentKey( lesson -> new TeacherDepartmentKey(
lesson.teacherId(), lesson.teacherId(),
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null) scopedDepartmentId != null
? scopedDepartmentId
: departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
), ),
LinkedHashMap::new, LinkedHashMap::new,
Collectors.toList() Collectors.toList()

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 Long userId;
private String username; private String username;
private String fullName;
private Long subjectId; private Long subjectId;
private String subjectName; 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.userId = userId;
this.username = username; this.username = username;
this.fullName = fullName;
this.subjectId = subjectId; this.subjectId = subjectId;
this.subjectName = subjectName; this.subjectName = subjectName;
} }
@@ -22,6 +24,10 @@ public class TeacherSubjectResponse {
return username; return username;
} }
public String getFullName() {
return fullName;
}
public Long getSubjectId() { public Long getSubjectId() {
return subjectId; return subjectId;
} }

View File

@@ -3,14 +3,17 @@ package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendar;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; 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.Lock;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; 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.List;
import java.util.Optional; import java.util.Optional;
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long> { public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long>, JpaSpecificationExecutor<AcademicCalendar> {
long countByStudyFormId(Long studyFormId); long countByStudyFormId(Long studyFormId);
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
where calendar.id = :id where calendar.id = :id
""") """)
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long 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 com.magistr.app.model.Classroom;
import org.springframework.data.jpa.repository.JpaRepository; 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.Optional;
import java.util.List; 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); Optional<Classroom> findByName(String name);
List<Classroom> findByStatusNot(String status); 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 com.magistr.app.model.StudentGroup;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; 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.Lock;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; 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.List;
import java.util.Optional; 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); List<StudentGroup> findByEducationFormId(Long educationFormId);

View File

@@ -3,6 +3,8 @@ package com.magistr.app.repository;
import com.magistr.app.model.ScheduleRule; import com.magistr.app.model.ScheduleRule;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; 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.Lock;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -13,6 +15,23 @@ import java.util.Collection;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> { 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(""" @Query("""
select distinct r select distinct r
from ScheduleRule r from ScheduleRule r
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
where r.id = :id where r.id = :id
""") """)
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long 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); 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(""" @Query("""
select subgroup select subgroup
from Subgroup subgroup from Subgroup subgroup

View File

@@ -2,11 +2,12 @@ package com.magistr.app.repository;
import com.magistr.app.model.Subject; import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List; import java.util.List;
import java.util.Optional; 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); Optional<Subject> findByNameIgnoreCase(String name);
List<Subject> findByDepartmentId(Long departmentId); 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.TeacherCreationRequest;
import com.magistr.app.model.TeacherCreationRequestStatus; import com.magistr.app.model.TeacherCreationRequestStatus;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List; 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); 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> findByUserId(Long userId);
List<TeacherSubject> findBySubjectId(Long subjectId); 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 = """ @Query(value = """
select subject_link.user_id select subject_link.user_id
from teacher_subjects subject_link from teacher_subjects subject_link

View File

@@ -4,14 +4,18 @@ import com.magistr.app.model.Role;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; 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.Lock;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; 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.List;
import java.util.Optional; 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); 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); 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) @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from User user where user.id = :id") @Query("select user from User user where user.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id); Optional<User> findByIdForUpdate(@Param("id") Long id);

View File

@@ -7,6 +7,7 @@ import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionHistory; import com.magistr.app.model.ScheduleVersionHistory;
import com.magistr.app.model.ScheduleVersionStatus; import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester; import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.repository.AcademicYearRepository; import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository; import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository; import com.magistr.app.repository.ScheduleVersionRepository;
@@ -19,13 +20,20 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Set;
@Service @Service
public class AcademicPeriodService { public class AcademicPeriodService {
private static final String YEAR_CONFLICT_MESSAGE = private static final String YEAR_CONFLICT_MESSAGE =
"Название или период учебного года конфликтует с существующим учебным годом"; "Учебный год или его период конфликтует с существующим учебным годом";
private static final String SEMESTER_CONFLICT_MESSAGE = private static final String SEMESTER_CONFLICT_MESSAGE =
"Тип или период семестра конфликтует с существующим семестром учебного года"; "Тип или период семестра конфликтует с существующим семестром учебного года";
@@ -49,48 +57,53 @@ public class AcademicPeriodService {
@Transactional @Transactional
public AcademicYear createYear(AcademicYearDto request) { public AcademicYear createYear(AcademicYearDto request) {
validateYear(request); YearPeriod period = resolveYearPeriod(request);
String title = request.title().trim(); List<SemesterDto> semesterRequests = validateRequestedSemesters(
request.semesters(), period.startDate(), period.endDate(), true
);
String title = academicYearTitle(period.startDate(), period.endDate());
if (academicYearRepository.existsByTitle(title) if (academicYearRepository.existsByTitle(title)
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) { || academicYearRepository.existsOverlapping(period.startDate(), period.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE); throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
} }
AcademicYear year = new AcademicYear(); AcademicYear year = new AcademicYear();
applyYear(year, title, request.startDate(), request.endDate()); applyYear(year, title, period.startDate(), period.endDate());
AcademicYear saved = saveYear(year); AcademicYear saved = saveYear(year);
for (SemesterDto semesterRequest : semesterRequests) {
Semester semester = new Semester();
semester.setAcademicYear(saved);
applySemester(semester, semesterRequest);
Semester savedSemester = saveSemester(semester);
createInitialPublishedSchedule(savedSemester);
}
clearScheduleCacheAfterCommit(); clearScheduleCacheAfterCommit();
return saved; return saved;
} }
@Transactional @Transactional
public AcademicYear updateYear(Long id, AcademicYearDto request) { public AcademicYear updateYear(Long id, AcademicYearDto request) {
validateYear(request); YearPeriod period = resolveYearPeriod(request);
AcademicYear year = lockYear(id); AcademicYear year = lockYear(id);
String title = request.title().trim(); String title = academicYearTitle(period.startDate(), period.endDate());
if (academicYearRepository.existsByTitleAndIdNot(title, id) if (academicYearRepository.existsByTitleAndIdNot(title, id)
|| academicYearRepository.existsOverlappingExcluding( || academicYearRepository.existsOverlappingExcluding(
id, request.startDate(), request.endDate())) { id, period.startDate(), period.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE); throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
} }
boolean semesterOutsideNewRange = semesterRepository
.findByAcademicYearIdOrderByStartDateAsc(id)
.stream()
.anyMatch(semester -> !contains(
request.startDate(),
request.endDate(),
semester.getStartDate(),
semester.getEndDate()
));
if (semesterOutsideNewRange) {
throw new IllegalArgumentException(
"Новые границы учебного года не включают все его семестры"
);
}
applyYear(year, title, request.startDate(), request.endDate()); List<Semester> existingSemesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(id);
List<SemesterDto> requestedSemesters = request.semesters() == null
? List.of()
: request.semesters();
List<SemesterDto> effectiveSemesterRequests = mergeSemesterRequests(
existingSemesters, requestedSemesters, period.startDate(), period.endDate()
);
applyYear(year, title, period.startDate(), period.endDate());
AcademicYear saved = saveYear(year); AcademicYear saved = saveYear(year);
synchronizeSemesters(saved, existingSemesters, effectiveSemesterRequests, requestedSemesters);
clearScheduleCacheAfterCommit(); clearScheduleCacheAfterCommit();
return saved; return saved;
} }
@@ -139,19 +152,15 @@ public class AcademicPeriodService {
return saved; return saved;
} }
private void validateYear(AcademicYearDto request) { private YearPeriod resolveYearPeriod(AcademicYearDto request) {
if (request == null) { if (request == null) {
throw new IllegalArgumentException("Данные учебного года обязательны"); throw new IllegalArgumentException("Данные учебного года обязательны");
} }
if (request.title() == null || request.title().isBlank()) { if (request.startDate() == null) {
throw new IllegalArgumentException("Название учебного года обязательно"); throw new IllegalArgumentException("Дата начала учебного года обязательна");
} }
validateDateRange( LocalDate endDate = LocalDate.of(request.startDate().getYear() + 1, 6, 30);
request.startDate(), return new YearPeriod(request.startDate(), endDate);
request.endDate(),
"Даты начала и окончания учебного года обязательны",
"Дата окончания учебного года не может быть раньше даты начала"
);
} }
private void validateSemester(SemesterDto request) { private void validateSemester(SemesterDto request) {
@@ -189,6 +198,110 @@ public class AcademicPeriodService {
} }
} }
private List<SemesterDto> validateRequestedSemesters(List<SemesterDto> requests,
LocalDate yearStart,
LocalDate yearEnd,
boolean required) {
List<SemesterDto> semesters = requests == null ? List.of() : new ArrayList<>(requests);
if (required && semesters.isEmpty()) {
throw new IllegalArgumentException("Учебный год нельзя создать без семестров");
}
Set<SemesterType> types = new HashSet<>();
Set<Long> ids = new HashSet<>();
for (SemesterDto semester : semesters) {
validateSemester(semester);
if (!types.add(semester.semesterType())) {
throw new ScheduleConflictException("Типы семестров учебного года не должны повторяться");
}
if (semester.id() != null && !ids.add(semester.id())) {
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
}
if (!contains(yearStart, yearEnd, semester.startDate(), semester.endDate())) {
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
}
}
List<SemesterDto> sorted = new ArrayList<>(semesters);
sorted.sort(Comparator.comparing(SemesterDto::startDate));
for (int index = 1; index < sorted.size(); index++) {
SemesterDto previous = sorted.get(index - 1);
SemesterDto current = sorted.get(index);
if (!previous.endDate().isBefore(current.startDate())) {
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
}
}
return sorted;
}
private List<SemesterDto> mergeSemesterRequests(List<Semester> existingSemesters,
List<SemesterDto> requestedSemesters,
LocalDate yearStart,
LocalDate yearEnd) {
Map<Long, Semester> existingById = new HashMap<>();
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
Map<Long, SemesterDto> requestedById = new HashMap<>();
List<SemesterDto> newRequests = new ArrayList<>();
for (SemesterDto request : requestedSemesters) {
if (request == null) {
throw new IllegalArgumentException("Данные семестра обязательны");
}
if (request.id() == null) {
newRequests.add(request);
continue;
}
if (!existingById.containsKey(request.id())) {
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
}
if (requestedById.put(request.id(), request) != null) {
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
}
}
List<SemesterDto> effective = new ArrayList<>();
for (Semester semester : existingSemesters) {
effective.add(requestedById.getOrDefault(semester.getId(), toSemesterRequest(semester)));
}
effective.addAll(newRequests);
return validateRequestedSemesters(effective, yearStart, yearEnd, true);
}
private void synchronizeSemesters(AcademicYear year,
List<Semester> existingSemesters,
List<SemesterDto> effectiveRequests,
List<SemesterDto> requestedSemesters) {
if (effectiveRequests.isEmpty() || requestedSemesters.isEmpty()) {
return;
}
Map<Long, Semester> existingById = new HashMap<>();
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
for (SemesterDto request : requestedSemesters) {
Semester semester = request.id() == null ? new Semester() : existingById.get(request.id());
if (semester == null) {
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
}
boolean created = semester.getId() == null;
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
if (created) {
createInitialPublishedSchedule(saved);
}
}
}
private SemesterDto toSemesterRequest(Semester semester) {
return new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
);
}
private boolean contains(LocalDate outerStart, private boolean contains(LocalDate outerStart,
LocalDate outerEnd, LocalDate outerEnd,
LocalDate innerStart, LocalDate innerStart,
@@ -226,6 +339,10 @@ public class AcademicPeriodService {
year.setEndDate(endDate); year.setEndDate(endDate);
} }
private String academicYearTitle(LocalDate startDate, LocalDate endDate) {
return startDate.getYear() + "-" + endDate.getYear();
}
private void applySemester(Semester semester, SemesterDto request) { private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType()); semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate()); semester.setStartDate(request.startDate());
@@ -283,4 +400,7 @@ public class AcademicPeriodService {
} }
}); });
} }
private record YearPeriod(LocalDate startDate, LocalDate endDate) {
}
} }

View File

@@ -4,12 +4,15 @@ import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.model.AcademicYear; import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm; import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality; import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment; import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository;
@@ -28,14 +31,18 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
@Service @Service
public class AcademicStructureService { public class AcademicStructureService {
private static final String THEORY_ACTIVITY_CODE = "Т";
private final AcademicCalendarRepository calendarRepository; private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository; private final SpecialtiesRepository specialtiesRepository;
@@ -48,6 +55,7 @@ public class AcademicStructureService {
public AcademicStructureService(AcademicCalendarRepository calendarRepository, public AcademicStructureService(AcademicCalendarRepository calendarRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository, AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicYearRepository academicYearRepository, AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository, SpecialtiesRepository specialtiesRepository,
@@ -59,6 +67,7 @@ public class AcademicStructureService {
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarPeriodRepository = calendarPeriodRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarSubjectRepository = calendarSubjectRepository; this.calendarSubjectRepository = calendarSubjectRepository;
this.academicYearRepository = academicYearRepository; this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
@@ -82,6 +91,41 @@ public class AcademicStructureService {
return saved; return saved;
} }
@Transactional
public AcademicCalendar createCalendar(AcademicCalendarDto request) {
validateCalendarRequest(request);
CalendarDimensions dimensions = resolveCalendarDimensions(request);
AcademicCalendarActivityType theoryActivity = activityTypeRepository.findByCode(THEORY_ACTIVITY_CODE)
.filter(activity -> Boolean.TRUE.equals(activity.getAllowSchedule()))
.orElseThrow(() -> new IllegalArgumentException(
"Код активности «Т», разрешающий обычные занятия, не найден"
));
AcademicCalendar calendar = new AcademicCalendar();
calendar.setTitle(AcademicCalendarTitleBuilder.build(
dimensions.academicYear(),
dimensions.speciality(),
dimensions.profile(),
dimensions.studyForm()
));
applyCalendarDimensions(calendar, dimensions, request.courseCount());
AcademicCalendar saved = saveCalendar(calendar);
List<AcademicCalendarPeriod> defaultPeriods = new ArrayList<>(request.courseCount());
for (int courseNumber = 1; courseNumber <= request.courseCount(); courseNumber++) {
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
period.setAcademicCalendar(saved);
period.setCourseNumber(courseNumber);
period.setStartDate(dimensions.academicYear().getStartDate());
period.setEndDate(dimensions.academicYear().getEndDate());
period.setActivityType(theoryActivity);
defaultPeriods.add(period);
}
calendarPeriodRepository.saveAll(defaultPeriods);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional @Transactional
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) { public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
validateCalendarRequest(request); validateCalendarRequest(request);

View File

@@ -24,7 +24,7 @@ public class ScheduleGeneratorService {
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class); private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
private static final int ACADEMIC_HOURS_PER_SLOT = 2; private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final long MAX_RANGE_DAYS = 120; private static final long MAX_RANGE_DAYS = 200;
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY"; private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private final ScheduleRuleRepository scheduleRuleRepository; private final ScheduleRuleRepository scheduleRuleRepository;
@@ -328,7 +328,7 @@ public class ScheduleGeneratorService {
} }
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1; long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) { if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
} }
} }

View File

@@ -42,6 +42,8 @@ import java.util.stream.Collectors;
@Service @Service
public class ScheduleOverrideService { public class ScheduleOverrideService {
private static final long MAX_RANGE_DAYS = 200;
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE"); private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн"); private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
@@ -706,8 +708,8 @@ public class ScheduleOverrideService {
if (endDate.isBefore(startDate)) { if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
} }
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) { if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней"); throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 200 дней");
} }
} }

View File

@@ -3,6 +3,7 @@ package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto; import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.ScheduleOverride; import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository; import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -17,32 +18,45 @@ import java.util.stream.Collectors;
public class ScheduleQueryService { public class ScheduleQueryService {
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50; private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
private static final long MAX_RANGE_DAYS = 120; private static final long MAX_RANGE_DAYS = 200;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository; private final ScheduleOverrideRepository scheduleOverrideRepository;
private final StudentGroupLifecycleService groupLifecycleService; private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicDateService academicDateService; private final AcademicDateService academicDateService;
private final TeacherDepartmentService teacherDepartmentService;
@Autowired @Autowired
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository, GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository, ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService, StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) { AcademicDateService academicDateService,
TeacherDepartmentService teacherDepartmentService) {
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository; this.scheduleOverrideRepository = scheduleOverrideRepository;
this.groupLifecycleService = groupLifecycleService; this.groupLifecycleService = groupLifecycleService;
this.academicDateService = academicDateService; this.academicDateService = academicDateService;
this.teacherDepartmentService = teacherDepartmentService;
}
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) {
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
groupLifecycleService, academicDateService, null);
} }
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository, GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository, ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) { StudentGroupLifecycleService groupLifecycleService) {
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository, groupLifecycleService, null); this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
groupLifecycleService, null, null);
} }
/** /**
@@ -192,10 +206,19 @@ public class ScheduleQueryService {
.findByLessonDateBetweenWithDetails(startDate, endDate); .findByLessonDateBetweenWithDetails(startDate, endDate);
generatedLessons = new ArrayList<>(generatedLessons); generatedLessons = new ArrayList<>(generatedLessons);
addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups); addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream() List<RenderedLessonDto> effectiveLessons = applyOverrides(generatedLessons, null, overrideSnapshot);
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(effectiveLessons, departmentId, startDate, endDate);
List<RenderedLessonDto> lessons = effectiveLessons.stream()
.filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate)) .filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate))
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId)) .filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId)) .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> departmentId == null || teacherDepartmentService
.hasDepartmentAssignmentAtDate(
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
departmentId,
lesson.date()
))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId)) .filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
.filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId)) .filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId))
.filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId)) .filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId))
@@ -204,6 +227,29 @@ public class ScheduleQueryService {
return deduplicate(lessons); return deduplicate(lessons);
} }
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
List<RenderedLessonDto> lessons,
Long departmentId,
LocalDate startDate,
LocalDate endDate
) {
if (departmentId == null) {
return Map.of();
}
if (teacherDepartmentService == null) {
throw new IllegalStateException("Сервис кафедральных связей преподавателей недоступен");
}
Set<Long> teacherIds = lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
return teacherDepartmentService.findAssignmentsForTeachersBetween(
teacherIds,
startDate,
endDate
);
}
private void addOverrideSourceOccurrences( private void addOverrideSourceOccurrences(
List<RenderedLessonDto> generatedLessons, List<RenderedLessonDto> generatedLessons,
Collection<ScheduleOverride> overrideSnapshot, Collection<ScheduleOverride> overrideSnapshot,
@@ -380,7 +426,7 @@ public class ScheduleQueryService {
} }
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1; long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) { if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
} }
} }
@@ -395,12 +441,10 @@ public class ScheduleQueryService {
.map(List::of) .map(List::of)
.orElse(List.of()); .orElse(List.of());
} }
List<StudentGroup> groups; // Кафедральный scope определяется по преподавателю на дату занятия,
if (departmentId != null) { // а не по выпускающей кафедре учебной группы. Фильтрация выполняется
groups = groupRepository.findByDepartmentId(departmentId); // после генерации и применения точечных изменений.
} else { List<StudentGroup> groups = groupRepository.findAll();
groups = groupRepository.findAll();
}
groups = groups.stream() groups = groups.stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate)) .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.toList(); .toList();

View File

@@ -1,6 +1,7 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto; import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.dto.ScheduleRuleSlotDto; import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.Classroom; 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.TimeSlotRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Objects; import java.util.Objects;
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; 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) @Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) { public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return getAll(semesterId, groupId, null); return getAll(semesterId, groupId, null);

View File

@@ -136,6 +136,23 @@ public class TeacherDepartmentService {
.findFirst(); .findFirst();
} }
public boolean hasDepartmentAssignmentAtDate(
Collection<TeacherDepartmentAssignment> assignments,
Long departmentId,
LocalDate date
) {
if (assignments == null || departmentId == null || date == null) {
return false;
}
return assignments.stream()
.filter(Objects::nonNull)
.filter(assignment -> isActiveAt(assignment, date))
.map(TeacherDepartmentAssignment::getDepartment)
.filter(Objects::nonNull)
.map(Department::getId)
.anyMatch(departmentId::equals);
}
@Transactional @Transactional
public TeacherDepartmentAssignment transferTeacher(Long teacherId, public TeacherDepartmentAssignment transferTeacher(Long teacherId,
DepartmentTransferRequest request, DepartmentTransferRequest request,

View File

@@ -19,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
archive_reason TEXT archive_reason TEXT
); );
INSERT INTO departments (name, code) VALUES
('Кафедра ИБ', 1),
('Кафедра ВТ', 2),
('Кафедра КТ', 3);
CREATE TABLE IF NOT EXISTS specialties ( CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -36,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
archive_reason TEXT 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 ( CREATE TABLE IF NOT EXISTS specialty_profiles (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE, 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) 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 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 ( CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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) ON teacher_department_assignments(teacher_id, department_id)
WHERE valid_to IS NULL; 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 ( CREATE TABLE IF NOT EXISTS teacher_creation_requests (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id), department_id BIGINT NOT NULL REFERENCES departments(id),
@@ -243,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
archive_reason TEXT 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 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") -- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
-- ========================================== -- ==========================================
@@ -373,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION validate_subgroup_student_capacity(); 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 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 ( CREATE TABLE IF NOT EXISTS subject_comments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id), subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -441,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
duration_minutes INT DEFAULT 90 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 ( CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -461,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
archive_reason TEXT archive_reason TEXT
); );
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска'),
('Документ-камера'),
('Аудиосистема')
ON CONFLICT (name) DO NOTHING;
-- Аудитории -- Аудитории
CREATE TABLE IF NOT EXISTS classrooms ( CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -488,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
archive_reason TEXT 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) -- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments ( CREATE TABLE IF NOT EXISTS classroom_equipments (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, 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) 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_departments_status ON departments(status);
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(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); 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) ON time_slot_scopes(day_of_week)
WHERE apply_mode = 'WEEKDAY'; 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 ( CREATE TABLE IF NOT EXISTS time_slots (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE, 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); 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 ( CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
assignment_date DATE NOT NULL UNIQUE, 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) 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 ( CREATE TABLE IF NOT EXISTS semesters (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, 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) 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 INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types ( CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
@@ -667,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
description TEXT 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 ( CREATE TABLE IF NOT EXISTS academic_calendars (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL, 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 CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id); 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 ( CREATE TABLE IF NOT EXISTS academic_calendar_periods (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
@@ -754,24 +575,6 @@ AS $$
) + 1 ) + 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 ( CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, 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 CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
ON student_group_calendar_assignments(group_id, academic_year_id); 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 ( CREATE TABLE IF NOT EXISTS schedule_rules (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id), subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -991,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups(); 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 -- Функция обновления timestamp
-- ========================================== -- ==========================================
@@ -1371,7 +1044,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
cancel_violations, cancel_violations,
move_violations, move_violations,
replace_violations, replace_violations,
@@ -1470,7 +1143,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
lecture_violations, lecture_violations,
laboratory_violations, laboratory_violations,
practice_violations practice_violations
@@ -1511,7 +1184,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23505', ERRCODE = '23505',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
duplicate_slot_groups duplicate_slot_groups
); );
END IF; END IF;
@@ -1577,7 +1250,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
duration_violations, duration_violations,
overlap_violations, overlap_violations,
scope_violations scope_violations
@@ -1733,7 +1406,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
academic_year_overlap_violations, academic_year_overlap_violations,
semester_bounds_violations, semester_bounds_violations,
semester_overlap_violations semester_overlap_violations
@@ -1864,7 +1537,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
assignment_violations, assignment_violations,
calendar_period_violations, calendar_period_violations,
calendar_subject_violations calendar_subject_violations
@@ -2430,33 +2103,9 @@ CREATE UNIQUE INDEX uq_schedule_versions_published_semester
CREATE INDEX idx_schedule_versions_semester_history CREATE INDEX idx_schedule_versions_semester_history
ON schedule_versions(semester_id, version_number DESC); 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 ALTER TABLE schedule_rules
ADD COLUMN schedule_version_id BIGINT; 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 TABLE schedule_rules
ALTER COLUMN schedule_version_id SET NOT NULL, ALTER COLUMN schedule_version_id SET NOT NULL,
ADD CONSTRAINT fk_schedule_rules_version ADD CONSTRAINT fk_schedule_rules_version
@@ -2487,14 +2136,6 @@ CREATE TABLE schedule_version_history (
CREATE INDEX idx_schedule_version_history_version CREATE INDEX idx_schedule_version_history_version
ON schedule_version_history(version_id, created_at DESC, id DESC); 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 TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED'; COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик'; COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,6 @@ import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.service.AcademicCalendarGridService; import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.AcademicStructureService;
@@ -24,22 +20,41 @@ import org.junit.jupiter.api.Test;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class AcademicCalendarControllerTest { class AcademicCalendarControllerTest {
@Test @Test
void createCalendarBuildsTitleWithoutClientValue() { @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); AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class); AcademicStructureService academicStructureService = mock(AcademicStructureService.class);
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
AcademicYear year = new AcademicYear(); AcademicYear year = new AcademicYear();
year.setId(1L); year.setId(1L);
@@ -55,26 +70,6 @@ class AcademicCalendarControllerTest {
studyForm.setId(4L); studyForm.setId(4L);
studyForm.setName("Очная"); studyForm.setName("Очная");
when(academicYearRepository.findById(1L)).thenReturn(Optional.of(year));
when(specialtiesRepository.findById(2L)).thenReturn(Optional.of(speciality));
when(profileRepository.findById(3L)).thenReturn(Optional.of(profile));
when(educationFormRepository.findById(4L)).thenReturn(Optional.of(studyForm));
when(calendarRepository.save(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
mock(AcademicCalendarPeriodRepository.class),
academicYearRepository,
specialtiesRepository,
profileRepository,
educationFormRepository,
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
mock(AcademicStructureService.class)
);
AcademicCalendarDto request = new AcademicCalendarDto( AcademicCalendarDto request = new AcademicCalendarDto(
null, null,
null, null,
@@ -91,13 +86,31 @@ class AcademicCalendarControllerTest {
null, null,
4 4
); );
AcademicCalendar saved = new AcademicCalendar();
saved.setTitle("09.03.04 — Разработка ПО — Очная — 2026-2027");
saved.setAcademicYear(year);
saved.setSpeciality(speciality);
saved.setSpecialtyProfile(profile);
saved.setStudyForm(studyForm);
saved.setCourseCount(4);
when(academicStructureService.createCalendar(request)).thenReturn(saved);
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
mock(AcademicCalendarPeriodRepository.class),
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
academicStructureService
);
var response = controller.createCalendar(request); var response = controller.createCalendar(request);
assertThat(response.getStatusCode().value()).isEqualTo(200); assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class); assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class);
assertThat(((AcademicCalendarDto) response.getBody()).title()) assertThat(((AcademicCalendarDto) response.getBody()).title())
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027"); .isEqualTo(saved.getTitle());
} }
@Test @Test
@@ -134,10 +147,6 @@ class AcademicCalendarControllerTest {
AcademicCalendarController controller = new AcademicCalendarController( AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository, calendarRepository,
periodRepository, periodRepository,
mock(AcademicYearRepository.class),
mock(SpecialtiesRepository.class),
mock(SpecialtyProfileRepository.class),
mock(EducationFormRepository.class),
mock(SubjectRepository.class), mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class), mock(AcademicCalendarGridService.class),

View File

@@ -33,6 +33,20 @@ class ScheduleRuleAdminControllerTest {
.build(); .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 @Test
void returnsBadRequestWithRussianValidationMessage() throws Exception { void returnsBadRequestWithRussianValidationMessage() throws Exception {
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException( 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.Clock;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -74,6 +75,52 @@ class TeacherSubjectControllerTest {
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId()); 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() { private User teacher() {
User teacher = new User(); User teacher = new User();
teacher.setId(10L); teacher.setId(10L);

View File

@@ -82,6 +82,36 @@ class WorkloadControllerTest {
.containsOnly(1L); .containsOnly(1L);
} }
@Test
void departmentTeacherWorkloadUsesRequestedAdditionalDepartment() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 31);
RenderedLessonDto lesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 5, 15), 1L);
when(scheduleQueryService.searchForAggregation(7L, null, startDate, endDate))
.thenReturn(List.of(lesson));
when(departmentRepository.findById(7L))
.thenReturn(Optional.of(new Department(7L, "Кафедра ИБ", 7L)));
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
departmentRepository,
teacherDepartmentService
);
authenticate(Role.DEPARTMENT, 7L);
List<WorkloadSummaryDto> result = controller.teacherWorkload(startDate, endDate, null);
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.id()).isEqualTo(10L);
assertThat(item.departmentId()).isEqualTo(7L);
assertThat(item.lessonCount()).isEqualTo(1L);
});
verifyNoInteractions(teacherDepartmentService);
}
@Test @Test
void freeClassroomsSkipsNullAvailabilityWithoutNpe() { void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);

View File

@@ -0,0 +1,238 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@DisplayName("Миграция тестовых данных и начальных периодов календарного графика")
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V2 заполняет кодом Т только полностью пустые графики")
void migrationBackfillsOnlyEmptyCalendars() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
long emptyCalendarId;
long configuredCalendarId;
try (Connection connection = POSTGRES.createConnection("")) {
prepareCustomCalendarPrerequisites(connection);
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
insertConfiguredPeriod(connection, configuredCalendarId);
}
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(readSummary(connection, emptyCalendarId))
.isEqualTo(new PeriodSummary(3, 1, 3, true));
assertThat(readSummary(connection, configuredCalendarId))
.isEqualTo(new PeriodSummary(1, 1, 1, false));
}
}
@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())
.locations("classpath:db/migration")
.cleanDisabled(false);
if (targetVersion != null) {
configuration.target(MigrationVersion.fromVersion(targetVersion));
}
return configuration.load();
}
private long insertCalendarFromSeed(Connection connection,
String title,
int courseCount) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendars (
title,
academic_year_id,
specialty_id,
specialty_profile_id,
study_form_id,
course_count
)
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);
statement.setInt(2, courseCount);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
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 (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT calendar.id, 1, academic_year.start_date, academic_year.start_date, activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'К'
WHERE calendar.id = ?
""")) {
statement.setLong(1, calendarId);
assertThat(statement.executeUpdate()).isEqualTo(1);
}
}
private PeriodSummary readSummary(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT
COUNT(*),
MIN(period.course_number),
MAX(period.course_number),
BOOL_AND(
activity.code = 'Т'
AND period.start_date = academic_year.start_date
AND period.end_date = academic_year.end_date
)
FROM academic_calendar_periods period
JOIN academic_calendars calendar ON calendar.id = period.calendar_id
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.id = period.activity_type_id
WHERE period.calendar_id = ?
""")) {
statement.setLong(1, calendarId);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return new PeriodSummary(
resultSet.getInt(1),
resultSet.getInt(2),
resultSet.getInt(3),
resultSet.getBoolean(4)
);
}
}
}
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,
boolean fullYearTheory) {
}
}

View File

@@ -1,6 +1,7 @@
package com.magistr.app.migration; package com.magistr.app.migration;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
@@ -30,6 +31,7 @@ class AcademicCalendarWeekNumberBaselineIntegrationTest {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,18 +73,38 @@ class AcademicPeriodServiceTest {
} }
@Test @Test
void createsNonOverlappingYearWithTrimmedTitle() { void createsYearWithAutomaticTitleEndDateAndSemesters() {
AcademicYear saved = service.createYear(yearRequest( AcademicYear saved = service.createYear(yearRequest(
" 2026-2027 ", "Произвольное название",
YEAR_START, YEAR_START,
YEAR_END YEAR_END.minusMonths(1)
)); ));
assertThat(saved.getTitle()).isEqualTo("2026-2027"); assertThat(saved.getTitle()).isEqualTo("2026-2027");
assertThat(saved.getStartDate()).isEqualTo(YEAR_START); assertThat(saved.getStartDate()).isEqualTo(YEAR_START);
assertThat(saved.getEndDate()).isEqualTo(YEAR_END);
verify(semesterRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(Semester.class));
verify(scheduleVersionRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(ScheduleVersion.class));
verify(scheduleGeneratorService).clearCache(); verify(scheduleGeneratorService).clearCache();
} }
@Test
void rejectsYearWithoutSemesters() {
AcademicYearDto request = new AcademicYearDto(
null,
null,
YEAR_START,
null,
List.of()
);
assertThatThrownBy(() -> service.createYear(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Учебный год нельзя создать без семестров");
verify(academicYearRepository, never()).saveAndFlush(any());
}
@Test @Test
void rejectsDuplicateTitleOrOverlappingYear() { void rejectsDuplicateTitleOrOverlappingYear() {
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true); when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
@@ -95,7 +115,7 @@ class AcademicPeriodServiceTest {
YEAR_END YEAR_END
))) )))
.isInstanceOf(ScheduleConflictException.class) .isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом"); .hasMessage("Учебный год или его период конфликтует с существующим учебным годом");
verify(academicYearRepository, never()).saveAndFlush(any()); verify(academicYearRepository, never()).saveAndFlush(any());
} }
@@ -112,17 +132,49 @@ class AcademicPeriodServiceTest {
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID)) when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
.thenReturn(List.of(autumn)); .thenReturn(List.of(autumn));
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest( assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequestWithoutSemesters(YEAR_START.plusDays(1))))
"2026-2027",
YEAR_START.plusDays(1),
YEAR_END
)))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Новые границы учебного года не включают все его семестры"); .hasMessage("Семестр должен полностью находиться в границах учебного года");
verify(academicYearRepository, never()).saveAndFlush(any()); verify(academicYearRepository, never()).saveAndFlush(any());
} }
@Test
void updatesYearAndItsSemestersTogether() {
Semester autumn = semester(
SEMESTER_ID,
SemesterType.autumn,
YEAR_START,
LocalDate.of(2027, 1, 31)
);
Semester spring = semester(
SEMESTER_ID + 1,
SemesterType.spring,
LocalDate.of(2027, 2, 1),
YEAR_END
);
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
.thenReturn(List.of(autumn, spring));
AcademicYear saved = service.updateYear(YEAR_ID, new AcademicYearDto(
YEAR_ID,
null,
YEAR_START,
null,
List.of(
semesterRequest(SEMESTER_ID, SemesterType.autumn, YEAR_START, LocalDate.of(2027, 1, 25)),
semesterRequest(SEMESTER_ID + 1, SemesterType.spring, LocalDate.of(2027, 1, 26), YEAR_END)
)
));
assertThat(saved.getTitle()).isEqualTo("2026-2027");
assertThat(autumn.getEndDate()).isEqualTo(LocalDate.of(2027, 1, 25));
assertThat(spring.getStartDate()).isEqualTo(LocalDate.of(2027, 1, 26));
verify(semesterRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(Semester.class));
verify(scheduleVersionRepository, never()).saveAndFlush(any());
}
@Test @Test
void rejectsSemesterOutsideAcademicYear() { void rejectsSemesterOutsideAcademicYear() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year)); when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
@@ -218,19 +270,33 @@ class AcademicPeriodServiceTest {
YEAR_END YEAR_END
))) )))
.isInstanceOf(ScheduleConflictException.class) .isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом") .hasMessage("Учебный год или его период конфликтует с существующим учебным годом")
.hasNoCause(); .hasNoCause();
} }
private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) { private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) {
return new AcademicYearDto(null, title, startDate, endDate, List.of()); return new AcademicYearDto(null, title, startDate, endDate, List.of(
semesterRequest(SemesterType.autumn, startDate, LocalDate.of(2027, 1, 31)),
semesterRequest(SemesterType.spring, LocalDate.of(2027, 2, 1), YEAR_END)
));
}
private AcademicYearDto yearRequestWithoutSemesters(LocalDate startDate) {
return new AcademicYearDto(null, null, startDate, null, List.of());
} }
private SemesterDto semesterRequest(SemesterType semesterType, private SemesterDto semesterRequest(SemesterType semesterType,
LocalDate startDate, LocalDate startDate,
LocalDate endDate) { LocalDate endDate) {
return semesterRequest(null, semesterType, startDate, endDate);
}
private SemesterDto semesterRequest(Long id,
SemesterType semesterType,
LocalDate startDate,
LocalDate endDate) {
return new SemesterDto( return new SemesterDto(
null, id,
YEAR_ID, YEAR_ID,
"2026-2027", "2026-2027",
semesterType, semesterType,

View File

@@ -4,12 +4,15 @@ import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.model.AcademicYear; import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm; import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality; import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment; import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository;
@@ -23,6 +26,7 @@ import com.magistr.app.repository.SubgroupRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.junit.jupiter.MockitoSettings;
@@ -56,6 +60,8 @@ class AcademicStructureServiceTest {
@Mock @Mock
private AcademicCalendarPeriodRepository calendarPeriodRepository; private AcademicCalendarPeriodRepository calendarPeriodRepository;
@Mock @Mock
private AcademicCalendarActivityTypeRepository activityTypeRepository;
@Mock
private AcademicCalendarSubjectRepository calendarSubjectRepository; private AcademicCalendarSubjectRepository calendarSubjectRepository;
@Mock @Mock
private AcademicYearRepository academicYearRepository; private AcademicYearRepository academicYearRepository;
@@ -88,6 +94,7 @@ class AcademicStructureServiceTest {
service = new AcademicStructureService( service = new AcademicStructureService(
calendarRepository, calendarRepository,
calendarPeriodRepository, calendarPeriodRepository,
activityTypeRepository,
calendarSubjectRepository, calendarSubjectRepository,
academicYearRepository, academicYearRepository,
specialtiesRepository, specialtiesRepository,
@@ -118,12 +125,54 @@ class AcademicStructureServiceTest {
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L); when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L);
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class))) when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
AcademicCalendarActivityType theoryActivity = new AcademicCalendarActivityType();
theoryActivity.setId(80L);
theoryActivity.setCode("Т");
theoryActivity.setName("Теоретическое обучение");
theoryActivity.setAllowSchedule(true);
when(activityTypeRepository.findByCode("Т")).thenReturn(Optional.of(theoryActivity));
when(groupRepository.saveAndFlush(any(StudentGroup.class))) when(groupRepository.saveAndFlush(any(StudentGroup.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class))) when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
} }
@Test
void createsCalendarWithTheoryPeriodForEveryCourseAcrossWholeAcademicYear() {
AcademicCalendar saved = service.createCalendar(calendarRequest(3, FORM_ID));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarPeriod>> periodsCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarPeriodRepository).saveAll(periodsCaptor.capture());
assertThat(saved.getTitle())
.isEqualTo("09.03.01 — Без профиля — Бакалавриат — 2025-2026");
assertThat(periodsCaptor.getValue())
.extracting(AcademicCalendarPeriod::getCourseNumber)
.containsExactly(1, 2, 3);
assertThat(periodsCaptor.getValue()).allSatisfy(period -> {
assertThat(period.getAcademicCalendar()).isSameAs(saved);
assertThat(period.getStartDate()).isEqualTo(academicYear.getStartDate());
assertThat(period.getEndDate()).isEqualTo(academicYear.getEndDate());
assertThat(period.getActivityType().getCode()).isEqualTo("Т");
assertThat(period.getActivityType().getAllowSchedule()).isTrue();
});
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsCalendarCreationWhenSchedulingTheoryActivityIsMissing() {
when(activityTypeRepository.findByCode("Т")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.createCalendar(calendarRequest(4, FORM_ID)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Код активности «Т», разрешающий обычные занятия, не найден");
verify(calendarRepository, never()).saveAndFlush(any());
verify(calendarPeriodRepository, never()).saveAll(any());
verify(scheduleGeneratorService, never()).clearCache();
}
@Test @Test
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() { void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID)) when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))

View File

@@ -145,8 +145,8 @@ class ScheduleGenerationQueryCountTest {
} }
@Test @Test
@DisplayName("Генератор отклоняет 121 включительную дату") @DisplayName("Генератор отклоняет 201 включительную дату")
void rejects121InclusiveDates() { void rejects201InclusiveDates() {
ScheduleGeneratorService generator = new ScheduleGeneratorService( ScheduleGeneratorService generator = new ScheduleGeneratorService(
mock(ScheduleRuleRepository.class), mock(ScheduleRuleRepository.class),
mock(GroupRepository.class), mock(GroupRepository.class),
@@ -161,10 +161,10 @@ class ScheduleGenerationQueryCountTest {
assertThatThrownBy(() -> generator.buildScheduleForGroups( assertThatThrownBy(() -> generator.buildScheduleForGroups(
List.of(), List.of(),
startDate, startDate,
startDate.plusDays(120) startDate.plusDays(200)
)) ))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон расписания не может превышать 120 дней"); .hasMessage("Диапазон расписания не может превышать 200 дней");
} }
private AcademicYear academicYear() { private AcademicYear academicYear() {

View File

@@ -181,6 +181,15 @@ class ScheduleOverrideServiceTest {
verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE); verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE);
} }
@Test
void rejectsRegistryRangeLongerThanTwoHundredDays() {
assertThatThrownBy(() -> service.getAll(DATE, DATE.plusDays(200)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон точечных изменений не может превышать 200 дней");
verify(scheduleOverrideRepository, never()).findForRegistry(any(LocalDate.class), any(LocalDate.class));
}
@Test @Test
void availabilityContainsOnlyStudyDatesOfSourceSemester() { void availabilityContainsOnlyStudyDatesOfSourceSemester() {
Semester semester = configureRuleForTarget(); Semester semester = configureRuleForTarget();

View File

@@ -1,5 +1,7 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository; import com.magistr.app.repository.ScheduleOverrideRepository;
@@ -9,6 +11,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -86,8 +90,8 @@ class ScheduleQueryServiceTest {
groupLifecycleService groupLifecycleService
); );
LocalDate startDate = LocalDate.of(2026, 1, 1); LocalDate startDate = LocalDate.of(2026, 1, 1);
LocalDate allowedEndDate = startDate.plusDays(119); LocalDate allowedEndDate = startDate.plusDays(199);
LocalDate rejectedEndDate = startDate.plusDays(120); LocalDate rejectedEndDate = startDate.plusDays(200);
when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate)) when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate))
.thenReturn(List.of()); .thenReturn(List.of());
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate)) when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate))
@@ -101,7 +105,7 @@ class ScheduleQueryServiceTest {
null, null, null, null, null, null, null, null,
startDate, rejectedEndDate)) startDate, rejectedEndDate))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон расписания не может превышать 120 дней"); .hasMessage("Диапазон расписания не может превышать 200 дней");
} }
@ParameterizedTest @ParameterizedTest
@@ -138,6 +142,63 @@ class ScheduleQueryServiceTest {
verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate); verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate);
} }
@Test
void departmentSearchIncludesTeacherLessonsForGroupsFromAnotherDepartment() {
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
GroupRepository groupRepository = mock(GroupRepository.class);
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 7);
LocalDate lessonDate = LocalDate.of(2026, 5, 4);
StudentGroup externalGroup = group(20);
RenderedLessonDto ownTeacherLesson = mock(RenderedLessonDto.class);
RenderedLessonDto anotherDepartmentLesson = mock(RenderedLessonDto.class);
TeacherDepartmentAssignment ownAssignment = new TeacherDepartmentAssignment();
TeacherDepartmentAssignment anotherAssignment = new TeacherDepartmentAssignment();
when(ownTeacherLesson.teacherId()).thenReturn(10L);
when(ownTeacherLesson.date()).thenReturn(lessonDate);
when(anotherDepartmentLesson.teacherId()).thenReturn(11L);
when(anotherDepartmentLesson.date()).thenReturn(lessonDate);
when(groupRepository.findAll()).thenReturn(List.of(externalGroup));
when(groupLifecycleService.mayHaveScheduleInRange(externalGroup, startDate, endDate)).thenReturn(true);
when(generatorService.buildScheduleForGroups(any(), eq(startDate), eq(endDate)))
.thenReturn(List.of(ownTeacherLesson, anotherDepartmentLesson));
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
when(teacherDepartmentService.findAssignmentsForTeachersBetween(
Set.of(10L, 11L),
startDate,
endDate
)).thenReturn(Map.of(
10L, List.of(ownAssignment),
11L, List.of(anotherAssignment)
));
when(teacherDepartmentService.hasDepartmentAssignmentAtDate(
List.of(ownAssignment), 7L, lessonDate
)).thenReturn(true);
when(teacherDepartmentService.hasDepartmentAssignmentAtDate(
List.of(anotherAssignment), 7L, lessonDate
)).thenReturn(false);
ScheduleQueryService service = new ScheduleQueryService(
generatorService,
groupRepository,
overrideRepository,
groupLifecycleService,
mock(AcademicDateService.class),
teacherDepartmentService
);
var result = service.searchForAggregation(7L, null, startDate, endDate);
assertThat(result).singleElement().satisfies(lesson ->
assertThat(lesson.teacherId()).isEqualTo(10L)
);
verify(groupRepository).findAll();
verify(groupRepository, never()).findByDepartmentId(7L);
}
private StudentGroup group(int id) { private StudentGroup group(int id) {
StudentGroup group = new StudentGroup(); StudentGroup group = new StudentGroup();
group.setId((long) id); group.setId((long) id);

View File

@@ -47,6 +47,7 @@ import static org.mockito.Mockito.verify;
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class, classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
properties = { properties = {
"spring.jpa.open-in-view=false", "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" "management.endpoint.health.validate-group-membership=false"
} }
) )
@@ -86,6 +87,48 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); 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 @Test
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception { void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
assertThat(AopUtils.isAopProxy(service)).isTrue(); assertThat(AopUtils.isAopProxy(service)).isTrue();

View File

@@ -173,6 +173,28 @@ class TeacherDepartmentServiceTest {
)).contains(newDepartment.getId()); )).contains(newDepartment.getId());
} }
@Test
void departmentScopeAcceptsAdditionalAssignmentAlongsidePrimary() {
TeacherDepartmentAssignment primary = assignment(
oldDepartment,
LocalDate.of(2025, 9, 1),
null,
true
);
TeacherDepartmentAssignment additional = assignment(
newDepartment,
LocalDate.of(2026, 1, 1),
null,
false
);
assertThat(service.hasDepartmentAssignmentAtDate(
List.of(primary, additional),
newDepartment.getId(),
TODAY
)).isTrue();
}
private void prepareTransfer(TeacherDepartmentAssignment current) { private void prepareTransfer(TeacherDepartmentAssignment current) {
when(assignmentRepository.saveAndFlush(any(TeacherDepartmentAssignment.class))) when(assignmentRepository.saveAndFlush(any(TeacherDepartmentAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));

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`. `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` ### `GET /api/users/teachers`
Список только преподавателей (роль `TEACHER`). Список только преподавателей (роль `TEACHER`).
### `GET /api/users/teachers/options`
Серверный поиск преподавателей для асинхронных списков. Параметры: `query`,
`departmentId`, `selectedId`, `limit` (`1..50`). Поиск выполняется по логину, ФИО и
должности; архивные записи исключаются. Для роли `DEPARTMENT` backend принудительно
использует кафедру текущего пользователя и учитывает действующие основные и дополнительные
назначения из `teacher_department_assignments`. `selectedId` не обходит это ограничение.
### `GET /api/users/teachers/{departmentId}` ### `GET /api/users/teachers/{departmentId}`
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`. Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
@@ -309,6 +338,7 @@ Refresh-токен ротируется при каждом успешном о
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса | | `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}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку | | `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
@@ -365,10 +395,9 @@ Refresh-токен ротируется при каждом успешном о
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | | `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | | `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 200
календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля календарных дат с учётом обеих границ: 200 дат разрешены, 201 дата отклоняется. Если у
невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и группы нет назначения календарного графика на учебный год даты,
отклоняется. Если у группы нет назначения календарного графика на учебный год даты,
расписание для неё возвращается пустым списком. Время пары берётся из базового слота расписание для неё возвращается пустым списком. Время пары берётся из базового слота
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
`/api/admin/time-slots`. `/api/admin/time-slots`.
@@ -508,12 +537,30 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
**Тело учебного года:** **Тело учебного года:**
```json ```json
{ {
"title": "2026-2027",
"startDate": "2026-09-01", "startDate": "2026-09-01",
"semesters": [
{
"semesterType": "autumn",
"startDate": "2026-09-01",
"endDate": "2027-01-31"
},
{
"semesterType": "spring",
"startDate": "2027-02-01",
"endDate": "2027-06-30" "endDate": "2027-06-30"
} }
]
}
``` ```
Название и дата окончания учебного года вычисляются backend автоматически по дате начала:
для `2026-09-01` будут сохранены название `2026-2027` и окончание `2027-06-30`.
В административной форме пользователь вводит только год `2026`; frontend преобразует его
в `startDate: 2026-09-01` и сразу показывает вычисленный год окончания `2027`.
При создании требуется хотя бы один семестр; учебный год и все переданные семестры
сохраняются одной транзакцией. Поля `title` и `endDate`, переданные старым клиентом,
не влияют на вычисленные значения.
**Тело семестра:** **Тело семестра:**
```json ```json
{ {
@@ -526,10 +573,11 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного
года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий
период разрешено начать на следующий календарный день после окончания предыдущего. период разрешено начать на следующий календарный день после окончания предыдущего.
Повторное название года, повторный тип семестра или пересечение возвращаются как Повторный тип семестра или пересечение возвращаются как
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`. `409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
При сужении учебного года все существующие семестры должны оставаться внутри новых границ. При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
Создание семестра также создаёт для него пустую версию 1 со статусом `PUBLISHED` и названием Создание учебного года или отдельного семестра также создаёт для каждого нового семестра
пустую версию 1 со статусом `PUBLISHED` и названием
`Основное расписание`, поэтому конструктор сразу может работать в обычном режиме. `Основное расписание`, поэтому конструктор сразу может работать в обычном режиме.
**Тело создания/обновления кода активности:** **Тело создания/обновления кода активности:**
@@ -549,6 +597,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | | `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}` | Один график | | `GET` | `/api/admin/academic-calendars/{id}` | Один график |
| `POST` | `/api/admin/academic-calendars` | Создать график | | `POST` | `/api/admin/academic-calendars` | Создать график |
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | | `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
@@ -575,6 +625,26 @@ 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 сохраняет один период с кодом `Т`
на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после
создания и назначения графика группе; пользователь затем заменяет нужные даты на сессию,
каникулы, практику и другие активности. Если код `Т` отсутствует или не разрешает обычное
расписание, запрос возвращает `400`, а график не создаётся. Обновление существующего графика
через `PUT` не пересоздаёт и не перезаписывает его периоды.
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности, все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
@@ -656,11 +726,18 @@ CRUD доступен по:
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` | | `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
| `GET` | `/api/admin/schedule-rules/page` | Страница правил: обязательный `versionId`, параметры `query`, `page`, `size` |
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило | | `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
| `POST` | `/api/admin/schedule-rules` | Создать правило | | `POST` | `/api/admin/schedule-rules` | Создать правило |
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | | `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
| `DELETE` | `/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`). Субботняя и ручные сетки не выбираются в правиле напрямую. `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED` Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
@@ -752,6 +829,9 @@ ID существующих слотов сохраняются; удалени
| `timeSlotId` | Временной слот | | `timeSlotId` | Временной слот |
| `parity` | `BOTH`, `ODD`, `EVEN` | | `parity` | `BOTH`, `ODD`, `EVEN` |
Диапазон включает обе границы и не может превышать 200 календарных дней. Этого достаточно,
чтобы запросить осенний или весенний семестр целиком одним обращением.
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится
@@ -798,7 +878,7 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила | | `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила |
`startDate` и `endDate` у списка передаются только парой, включительно; максимальный `startDate` и `endDate` у списка передаются только парой, включительно; максимальный
диапазон — 120 дней. Ответ реестра содержит исходные дату, время, преподавателя, диапазон — 200 дней. Ответ реестра содержит исходные дату, время, преподавателя,
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания. или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
@@ -1276,6 +1356,21 @@ payload обрабатываются один раз, используется
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение. Параметр `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 ```json
[ [
@@ -1379,7 +1474,7 @@ legacy-alias для старых клиентов и исторически со
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/subgroups` | Список всех подгрупп | | `GET` | `/api/subgroups?groupId=1&groupId=2` | Подгруппы выбранных групп; без параметра — совместимый список всех подгрупп, не более 100 ID групп за запрос |
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы | | `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу | | `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу | | `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
@@ -1457,6 +1552,12 @@ legacy-alias для старых клиентов и исторически со
Список аудиторий с привязанным оборудованием. Список аудиторий с привязанным оборудованием.
### `GET /api/classrooms/options`
Асинхронный поиск доступных неархивных аудиторий по названию и корпусу. Параметры:
`query`, `selectedId`, `limit` (`1..50`). Выбранная запись добавляется в результат, если
она не попала в первую страницу поиска.
**Ответ:** **Ответ:**
```json ```json
[ [
@@ -1510,6 +1611,12 @@ legacy-alias для старых клиентов и исторически со
Список всех дисциплин. Список всех дисциплин.
### `GET /api/subjects/page`
Пагинированный реестр дисциплин. Параметры: `query`, `departmentId`, `includeArchived`,
`page`, `size`, `sort`, `direction`; сортировка разрешена по `name`, `code`,
`departmentId`, `status`. Роль `DEPARTMENT` всегда ограничивается своей кафедрой на backend.
```json ```json
{ {
"name": "Физика", "name": "Физика",
@@ -1591,14 +1698,17 @@ legacy-alias для старых клиентов и исторически со
### `GET /api/teacher-subjects` ### `GET /api/teacher-subjects`
Список всех привязок. Список привязок. Повторяемый параметр `subjectId` ограничивает результат указанными
дисциплинами; за один запрос принимается не более 100 ID. Без параметра сохраняется
совместимый режим получения всех доступных текущей роли привязок.
**Ответ:** **Ответ:**
```json ```json
[ [
{ {
"userId": 2, "userId": 2,
"userName": "Тестовый преподаватель", "username": "teacher2",
"fullName": "Тестовый преподаватель",
"subjectId": 1, "subjectId": 1,
"subjectName": "Высшая математика" "subjectName": "Высшая математика"
} }

View File

@@ -157,6 +157,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
При создании и изменении backend всегда формирует его из кода специальности, названия При создании и изменении backend всегда формирует его из кода специальности, названия
профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется. профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется.
Новый календарный график сохраняется вместе с начальной сеткой одной транзакцией. Для каждого
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции
`V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам
без единого периода; частично или полностью настроенные сетки не изменяются.
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
@@ -203,9 +211,10 @@ constraint. При чтении API разворачивает периоды о
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра. В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными Диапазон расписания включает обе переданные границы и ограничен ровно 200 календарными
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 200 дат
разрешены, 121 дата отклоняется до генерации. разрешены, 201 дата отклоняется до генерации. Один запрос поэтому покрывает учебный
семестр целиком.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`, Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и `departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
@@ -216,9 +225,13 @@ constraint. При чтении API разворачивает периоды о
замен полный список групп не загружается. замен полный список групп не загружается.
Отчёты workload и свободные аудитории не являются интерактивным поиском и используют Отчёты workload и свободные аудитории не являются интерактивным поиском и используют
отдельный `searchForAggregation`. Он обходит все активные группы разрешённого scope без отдельный `searchForAggregation`. Он обходит все активные группы без лимита 50, но
лимита 50, но переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию и
и дедупликацию. Поэтому tenant с 51 или 100 группами получает полный агрегат, а защита дедупликацию. Кафедральный scope применяется после генерации и точечных изменений по
любой действующей основной или дополнительной связи преподавателя с кафедрой на дату занятия,
а не по кафедре учебной группы.
Поэтому межкафедральные занятия входят в нагрузку преподавателя его кафедры. Tenant с 51
или 100 группами получает полный агрегат, а защита
широкого `/api/schedule/search` остаётся прежней. широкого `/api/schedule/search` остаётся прежней.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.

View File

@@ -887,6 +887,10 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
остаются строки старших курсов или дисциплины старших семестров. остаются строки старших курсов или дисциплины старших семестров.
При создании графика backend в той же транзакции добавляет по одному периоду `Т` на весь
учебный год для каждого курса `1..course_count`. Если создание периодов завершается ошибкой,
строка `academic_calendars` также откатывается.
#### `academic_calendar_periods` — Периоды активности календарного графика #### `academic_calendar_periods` — Периоды активности календарного графика
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
@@ -949,8 +953,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
Пара `semester_id + version_number` уникальна. Частичный индекс Пара `semester_id + version_number` уникальна. Частичный индекс
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на `uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после привязывает к ней тестовые правила. Для семестров, создаваемых через API после
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1 запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
`Основное расписание` и две записи истории: создание и публикацию. `Основное расписание` и две записи истории: создание и публикацию.
@@ -1173,10 +1177,15 @@ CHECK фиксирует допустимую форму каждого типа
### Правила работы ### Правила работы
1. Все миграции находятся в `backend/src/main/resources/db/migration/` 1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) 2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД. триггеры и ограничения изменяются только в `V1__init.sql`.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
5. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
tenant-БД. Сброс выполняется только по прямой команде владельца.
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью `V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
пустой tenant-схеме пустой tenant-схеме
@@ -1184,19 +1193,46 @@ CHECK фиксирует допустимую форму каждого типа
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | | `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 с отсутствиями Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
проекта объединены в baseline `V1`. В каталоге миграций остаётся один файл в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
`V1__init.sql`. tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
непосредственно в V1. `V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
Перед применением этой редакции требуется полностью пустая tenant-схема: для базы, где Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый tenant, включая
предыдущая V1 уже записана в `flyway_schema_history`, изменённая контрольная сумма вызовет 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,19 +251,18 @@ public class AbsenceController {
### Правила ### Правила
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя 1. До отдельного решения владельца вся постоянная схема изменяется только в `V1__init.sql`
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) 2. `V2__test_data.sql` предназначена исключительно для тестовых и демонстрационных данных;
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... постоянный DDL в ней запрещён
4. После добавления — перезапустите backend для применения 3. Не создавайте `V3` и следующие миграции, пока владелец не отменит режим пересобираемого baseline
4. Любое изменение V1/V2 требует полного сброса затронутых tenant-БД из-за checksum mismatch
5. Не сбрасывайте tenant-БД без прямой команды владельца
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch. `V1__init.sql` содержит только структуру: таблицы, функции, триггеры, индексы, ограничения
и комментарии. `V2__test_data.sql` содержит все прежние seed-записи, большой связанный набор
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца и заполнение периодов `Т` для полностью пустых календарных графиков. Такое разделение позволяет
содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о отдельно проверить schema-only V1, но обе миграции по-прежнему автоматически применяются ко
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД. всем tenant-БД.
Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних
вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в
проекте нет.
### Применение ### Применение
@@ -271,6 +270,10 @@ public class AbsenceController {
# Локально — сброс и повтор всех миграций # Локально — сброс и повтор всех миграций
docker compose down -v && docker compose up -d 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 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` и календарного учебного графика группы. Старый ручной слой занятий удалён. Динамическое расписание строится из правил `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

@@ -107,7 +107,9 @@ COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
tag и manifest digest. Первый этап frontend собирает зафиксированный tag и manifest digest. Первый этап frontend собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime- OpenTelemetry bundle; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`, файлы, без `node_modules`, тестов и build-исходников. В корень образа также копируются
`ui-foundation.css`, `schedule-overview.css` и `schedule-overview.js`: они необходимы
для общего шрифта, компонентов и обзора занятий в личных кабинетах. Apache подключает `mod_headers`,
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно `mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
маршрутизировался в tenant `default`. Перед копированием runtime-файлов устанавливаются маршрутизировался в tenant `default`. Перед копированием runtime-файлов устанавливаются
доступные исправления Alpine через `apk upgrade --no-cache`. Сервер доступные исправления Alpine через `apk upgrade --no-cache`. Сервер

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ COPY edu-office/ /usr/local/apache2/htdocs/edu-office/
COPY student/ /usr/local/apache2/htdocs/student/ COPY student/ /usr/local/apache2/htdocs/student/
COPY teacher/ /usr/local/apache2/htdocs/teacher/ COPY teacher/ /usr/local/apache2/htdocs/teacher/
COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/ COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
COPY ui-foundation.css schedule-overview.css schedule-overview.js /usr/local/apache2/htdocs/
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/ COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf

View File

@@ -630,7 +630,7 @@
} }
.schedule-view-extra-grid { .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; margin-top: 0.9rem;
} }

View File

@@ -219,6 +219,13 @@
align-content: flex-start; align-content: flex-start;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.25rem; margin-top: 0.25rem;
height: 180px;
min-height: 180px;
max-height: 180px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-right: 0.25rem;
} }
.dashboard-actions { .dashboard-actions {
@@ -689,6 +696,58 @@
letter-spacing: 0.04em; 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 { .schedule-group-select {
max-width: 520px; max-width: 520px;
} }
@@ -703,6 +762,23 @@
max-height: 220px; 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 { .schedule-hours-panel {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -1014,9 +1090,9 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97); background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
top: 50%; top: 0;
left: 50%; left: 0;
transform: translate(-50%, -50%); transform: none;
} }
.schedule-visual-context-menu[hidden] { .schedule-visual-context-menu[hidden] {
@@ -1325,6 +1401,152 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.academic-year-builder-card {
overflow: hidden;
}
.academic-year-builder-description {
margin: 0.28rem 0 0;
font-size: 0.84rem;
}
.academic-year-period-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 0.8rem;
margin-top: 1rem;
}
#academic-year-end[readonly] {
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-input) 82%, var(--accent) 5%);
cursor: default;
}
.academic-year-field-hint {
display: block;
margin-top: 0.4rem;
font-size: 0.72rem;
line-height: 1.35;
}
.academic-year-semester-builder {
margin-top: 1rem;
padding: 0.85rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-input) 78%, transparent);
}
.academic-year-semester-builder-head,
.academic-year-semester-card-title,
.academic-year-form-actions {
display: flex;
align-items: center;
}
.academic-year-semester-builder-head {
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.academic-year-semester-builder-head h3,
.academic-year-semester-builder-head p {
margin: 0;
}
.academic-year-semester-builder-head h3 {
font-size: 0.96rem;
}
.academic-year-semester-builder-head p {
margin-top: 0.2rem;
font-size: 0.78rem;
}
.academic-year-required-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent-hover);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.academic-year-semester-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.academic-year-semester-card {
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
}
.academic-year-semester-card-title {
gap: 0.5rem;
margin-bottom: 0.65rem;
color: var(--text-primary);
}
.academic-year-semester-dot {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 14%, transparent);
}
.academic-year-semester-dot-autumn {
color: #f59e0b;
background: #f59e0b;
}
.academic-year-semester-dot-spring {
color: #22c55e;
background: #22c55e;
}
.academic-year-semester-dates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.academic-year-form-actions {
justify-content: flex-end;
margin-top: 0.85rem;
}
@media (max-width: 900px) {
.academic-year-semester-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.academic-year-period-grid,
.academic-year-semester-dates {
grid-template-columns: 1fr;
}
.academic-year-semester-builder-head {
align-items: flex-start;
}
.academic-year-form-actions .btn {
width: 100%;
}
}
.schedule-inline-table { .schedule-inline-table {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -2148,6 +2370,20 @@
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition); transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition);
} }
.calendar-activity-color-10b981 { --activity-color: #10b981; }
.calendar-activity-color-f59e0b { --activity-color: #f59e0b; }
.calendar-activity-color-38bdf8 { --activity-color: #38bdf8; }
.calendar-activity-color-a78bfa { --activity-color: #a78bfa; }
.calendar-activity-color-fb7185 { --activity-color: #fb7185; }
.calendar-activity-color-f97316 { --activity-color: #f97316; }
.calendar-activity-color-22c55e { --activity-color: #22c55e; }
.calendar-activity-color-ef4444 { --activity-color: #ef4444; }
.calendar-activity-color-dc2626 { --activity-color: #dc2626; }
.calendar-activity-color-eab308 { --activity-color: #eab308; }
.calendar-activity-color-06b6d4 { --activity-color: #06b6d4; }
.calendar-activity-color-64748b { --activity-color: #64748b; }
.calendar-activity-color-475569 { --activity-color: #475569; }
.calendar-day:hover, .calendar-day:hover,
.calendar-day:focus-visible { .calendar-day:focus-visible {
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card)); background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card));
@@ -2311,7 +2547,7 @@
} }
.calendar-total-chip { .calendar-total-chip {
--chip-color: var(--accent); --chip-color: var(--activity-color, var(--accent));
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
@@ -3760,3 +3996,193 @@ tbody tr:hover {
white-space: nowrap; white-space: nowrap;
line-height: 1.2; 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

@@ -67,7 +67,7 @@ html {
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;

View File

@@ -66,7 +66,7 @@
.quality-hero h2 { .quality-hero h2 {
max-width: 790px; max-width: 790px;
margin: 0; margin: 0;
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: clamp(2rem, 4.5vw, 4.4rem); font-size: clamp(2rem, 4.5vw, 4.4rem);
font-weight: 500; font-weight: 500;
letter-spacing: -0.045em; letter-spacing: -0.045em;
@@ -229,7 +229,7 @@
.quality-score-inner strong { .quality-score-inner strong {
color: var(--text-primary); color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: 3.2rem; font-size: 3.2rem;
font-weight: 500; font-weight: 500;
line-height: 0.95; line-height: 0.95;
@@ -271,7 +271,7 @@
.quality-score-copy h3 { .quality-score-copy h3 {
margin: 0.55rem 0 0; margin: 0.55rem 0 0;
color: var(--text-primary); color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: clamp(1.6rem, 3vw, 2.5rem); font-size: clamp(1.6rem, 3vw, 2.5rem);
font-weight: 500; font-weight: 500;
} }
@@ -338,7 +338,7 @@
.quality-metric-index { .quality-metric-index {
color: color-mix(in srgb, var(--text-secondary) 65%, transparent); color: color-mix(in srgb, var(--text-secondary) 65%, transparent);
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: 0.78rem; font-size: 0.78rem;
} }
@@ -346,7 +346,7 @@
display: block; display: block;
margin-top: 1.25rem; margin-top: 1.25rem;
color: var(--text-primary); color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: 2rem; font-size: 2rem;
font-weight: 500; font-weight: 500;
} }
@@ -402,7 +402,7 @@
.quality-recommendations h3 { .quality-recommendations h3 {
margin: 0; margin: 0;
color: var(--text-primary); color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: 1.65rem; font-size: 1.65rem;
font-weight: 500; font-weight: 500;
} }
@@ -537,7 +537,7 @@
.quality-penalty { .quality-penalty {
color: var(--quality-red); color: var(--quality-red);
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: 1.15rem; font-size: 1.15rem;
} }
@@ -583,7 +583,7 @@
background: color-mix(in srgb, var(--quality-cyan) 10%, transparent); background: color-mix(in srgb, var(--quality-cyan) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--quality-cyan) 35%, transparent); border: 1px solid color-mix(in srgb, var(--quality-cyan) 35%, transparent);
border-radius: 50%; border-radius: 50%;
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: 1.6rem; font-size: 1.6rem;
} }

View File

@@ -30,7 +30,7 @@
right: -1.5rem; right: -1.5rem;
bottom: 1.2rem; bottom: 1.2rem;
color: rgba(255, 255, 255, .055); color: rgba(255, 255, 255, .055);
font-family: Georgia, serif; font-family: var(--ui-font-display);
font-size: clamp(4rem, 10vw, 9rem); font-size: clamp(4rem, 10vw, 9rem);
letter-spacing: -.08em; letter-spacing: -.08em;
pointer-events: none; pointer-events: none;
@@ -53,7 +53,7 @@
.version-hero h2 { .version-hero h2 {
max-width: 760px; max-width: 760px;
margin: 0; margin: 0;
font-family: Georgia, 'Times New Roman', serif; font-family: var(--ui-font-display);
font-size: clamp(2rem, 4.5vw, 4.5rem); font-size: clamp(2rem, 4.5vw, 4.5rem);
font-weight: 500; font-weight: 500;
letter-spacing: -.05em; letter-spacing: -.05em;
@@ -79,6 +79,8 @@
} }
.version-hero-controls label { .version-hero-controls label {
display: block;
margin-bottom: .35rem;
color: rgba(255, 255, 255, .62); color: rgba(255, 255, 255, .62);
font-size: .7rem; font-size: .7rem;
font-weight: 800; font-weight: 800;
@@ -98,6 +100,12 @@
.version-hero-controls option { color: #152536; background: #f7f4ed; } .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 { .version-status {
min-height: 2.8rem; min-height: 2.8rem;
padding: .8rem 1rem; padding: .8rem 1rem;
@@ -128,11 +136,15 @@
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card)); background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border)); border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border));
} }
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; } .version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.55rem; }
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; } .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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; 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-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-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 { .version-current {
display: grid; display: grid;
@@ -207,7 +219,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.version-section-head > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 2rem; } .version-section-head > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 2rem; }
.version-draft-list { display: grid; gap: .8rem; } .version-draft-list { display: grid; gap: .8rem; }
.version-card { .version-card {
@@ -225,7 +237,7 @@
right: .5rem; right: .5rem;
top: -.8rem; top: -.8rem;
color: color-mix(in srgb, var(--version-copper) 12%, transparent); color: color-mix(in srgb, var(--version-copper) 12%, transparent);
font-family: Georgia, serif; font-family: var(--ui-font-display);
font-size: 5rem; font-size: 5rem;
} }
@@ -240,7 +252,7 @@
place-content: center; place-content: center;
text-align: center; text-align: center;
} }
.version-empty > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 4rem; } .version-empty > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 4rem; }
.version-empty p { max-width: 360px; } .version-empty p { max-width: 360px; }
.version-diff-score { .version-diff-score {
@@ -250,7 +262,7 @@
margin: 1rem 0; margin: 1rem 0;
} }
.version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; } .version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; }
.version-diff-score strong { display: block; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.75rem; } .version-diff-score strong { display: block; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.75rem; }
.version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; } .version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; }
.version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; } .version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; }
@@ -280,7 +292,7 @@
} }
.version-modal[hidden] { display: none; } .version-modal[hidden] { display: none; }
.version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); } .version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 2rem; } .version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 2rem; }
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; } .version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; }
.version-modal-card form { display: grid; gap: .65rem; } .version-modal-card form { display: grid; gap: .65rem; }
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; } .version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }
@@ -304,5 +316,7 @@
.version-diff-score { grid-template-columns: 1fr; } .version-diff-score { grid-template-columns: 1fr; }
.version-history-row { grid-template-columns: 1fr; gap: .25rem; } .version-history-row { grid-template-columns: 1fr; gap: .25rem; }
.version-constructor-controls { grid-template-columns: 1fr; } .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); 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-create-card .card-header-row > div:first-child,
.absence-registry-head > div:first-child { .absence-registry-head > div:first-child {
display: flex; display: flex;
@@ -178,6 +187,7 @@
} }
.absence-registry-title { .absence-registry-title {
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
} }
@@ -211,21 +221,31 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 28px; min-height: 28px;
padding: 0 0.65rem; padding: 0.25rem 0.65rem;
flex: 0 0 auto;
align-self: flex-start;
max-width: 100%;
line-height: 1.4;
border: 1px solid currentColor; border: 1px solid currentColor;
border-radius: 999px; border-radius: 999px;
font-size: 0.69rem; font-size: 0.69rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.03em; letter-spacing: 0.03em;
white-space: nowrap; white-space: normal;
} }
.absence-status.is-pending { color: #f59e0b; } .absence-status.is-pending { color: #f59e0b; }
.absence-status.is-approved { color: #fb7185; } .absence-status.is-approved { color: #34d399; }
.absence-status.is-resolved { color: #34d399; } .absence-status.is-resolved { color: #34d399; }
.absence-status.is-rejected, .absence-status.is-rejected,
.absence-status.is-cancelled { color: var(--text-secondary); } .absence-status.is-cancelled { color: var(--text-secondary); }
[data-theme="light"] .absence-status.is-pending { color: #92400e; }
[data-theme="light"] .absence-status.is-approved,
[data-theme="light"] .absence-status.is-resolved { color: #047857; }
.absence-status.is-rejected { color: #fb7185; }
[data-theme="light"] .absence-status.is-rejected { color: #be123c; }
.absence-registry-buttons { .absence-registry-buttons {
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
@@ -472,7 +492,7 @@ body.absence-wizard-open {
.teacher-dialogue-grid { .teacher-dialogue-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem; gap: 1rem;
} }
@@ -526,13 +546,64 @@ body.absence-wizard-open {
.teacher-dialogue-toolbar { .teacher-dialogue-toolbar {
display: flex; display: flex;
align-items: stretch;
gap: .55rem; gap: .55rem;
margin: 1rem 0; margin: 1rem 0;
} }
.teacher-dialogue-toolbar select { .teacher-dialogue-toolbar > select,
.teacher-dialogue-toolbar .custom-select-wrapper {
min-width: 0; 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 { .teacher-dialogue-list {
@@ -544,7 +615,7 @@ body.absence-wizard-open {
.teacher-dialogue-item { .teacher-dialogue-item {
display: grid; display: grid;
grid-template-columns: 7px minmax(0, 1fr) minmax(180px, .42fr); grid-template-columns: 7px minmax(0, 1fr);
gap: .85rem; gap: .85rem;
padding: .85rem; padding: .85rem;
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
@@ -553,6 +624,7 @@ body.absence-wizard-open {
} }
.teacher-dialogue-mark { .teacher-dialogue-mark {
grid-row: 1 / 3;
border-radius: 99px; border-radius: 99px;
background: #94a3b8; background: #94a3b8;
} }
@@ -571,6 +643,11 @@ body.absence-wizard-open {
font-size: .68rem; font-size: .68rem;
} }
.teacher-dialogue-copy {
min-width: 0;
overflow-wrap: anywhere;
}
.teacher-dialogue-copy h3 { .teacher-dialogue-copy h3 {
margin: .22rem 0; margin: .22rem 0;
font-size: .92rem; font-size: .92rem;
@@ -608,17 +685,32 @@ body.absence-wizard-open {
.teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; } .teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; }
.teacher-dialogue-action { .teacher-dialogue-action {
grid-column: 2;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: stretch;
gap: .55rem; gap: .55rem;
} }
.teacher-dialogue-action > div { .teacher-dialogue-action > div {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
gap: .35rem; gap: .5rem;
}
.teacher-dialogue-action > div > .btn {
flex: 1 1 120px;
min-width: 0;
white-space: normal;
line-height: 1.35;
padding-block: 0.4rem;
}
.teacher-dialogue-toolbar > .btn {
align-self: center;
min-height: 44px;
} }
.teacher-dialogue-comment { .teacher-dialogue-comment {
@@ -669,6 +761,16 @@ body.absence-wizard-open {
grid-template-columns: 1fr; 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 { .absence-command-stats {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
@@ -731,6 +833,6 @@ body.absence-wizard-open {
.teacher-dialogue-action { .teacher-dialogue-action {
grid-column: 2; grid-column: 2;
align-items: flex-start; align-items: stretch;
} }
} }

View File

@@ -15,6 +15,7 @@
<link rel="stylesheet" href="css/teacher-absences.css"> <link rel="stylesheet" href="css/teacher-absences.css">
<link rel="stylesheet" href="css/schedule-quality.css"> <link rel="stylesheet" href="css/schedule-quality.css">
<link rel="stylesheet" href="css/schedule-versions.css"> <link rel="stylesheet" href="css/schedule-versions.css">
<link rel="stylesheet" href="../ui-foundation.css">
</head> </head>
<body> <body>
@@ -78,7 +79,7 @@
<!-- Раздел: Расписание --> <!-- Раздел: Расписание -->
<div class="sidebar-section-divider"></div> <div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div> <div class="sidebar-section-title">Расписание</div>
<a href="#" class="nav-item" data-tab="schedule"> <a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -149,7 +150,7 @@
<!-- Раздел: Субъекты и ресурсы --> <!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div> <div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div> <div class="sidebar-section-title">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms"> <a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -184,7 +185,7 @@
<!-- Раздел: Система --> <!-- Раздел: Система -->
<div class="sidebar-section-divider"></div> <div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div> <div class="sidebar-section-title">Система</div>
<a href="#" class="nav-item" data-tab="users"> <a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"

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 = { export const api = {
get: async (url) => { 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 (isCachable) {
if (!cache.has(url)) { if (!cache.has(url)) {
cache.set(url, await apiFetch(url, 'GET')); 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 { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js'; import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
import { initializeTelemetry } from '../../telemetry.js'; import { initializeTelemetry } from '../../telemetry.js';
import { confirmAbandonDirtyChanges } from './dirty-state.js';
import { trackUxEvent } from '../../telemetry.js';
async function bootstrap() { async function bootstrap() {
const session = await initializeSession(); const session = await initializeSession();
@@ -224,6 +226,7 @@ navItems.forEach(item => {
async function switchTab(tab) { async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return; if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
if (currentTab && !(await confirmAbandonDirtyChanges())) return;
// UI Update // UI Update
navItems.forEach(n => n.classList.remove('active')); navItems.forEach(n => n.classList.remove('active'));
@@ -245,6 +248,7 @@ async function switchTab(tab) {
} }
currentTab = tab; currentTab = tab;
trackUxEvent('раздел_открыт', { tab, role: currentRole });
if (window.location.hash !== `#${tab}`) { if (window.location.hash !== `#${tab}`) {
window.location.hash = tab; window.location.hash = tab;
} }
@@ -281,7 +285,7 @@ async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return; if (currentRole !== 'ADMIN') return;
try { try {
const count = countOverride === null 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; : countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count)); updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(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,43 @@
export function preferredScheduleSemester(semesters, date) {
const items = (semesters || [])
.filter(semester => semester?.startDate && semester?.endDate);
const current = items.find(semester =>
semester.startDate <= date && semester.endDate >= date
);
if (current) return current;
const upcoming = items
.filter(semester => semester.startDate > date)
.sort((first, second) => first.startDate.localeCompare(second.startDate));
if (upcoming.length) return upcoming[0];
return items
.filter(semester => semester.endDate < date)
.sort((first, second) => second.endDate.localeCompare(first.endDate))[0] || null;
}
export function defaultSchedulePeriod(semesters, date, durationDays = 14) {
const semester = preferredScheduleSemester(semesters, date);
const startDate = semester && (date < semester.startDate || date > semester.endDate)
? semester.startDate
: date;
const calculatedEnd = addIsoDays(startDate, Math.max(1, durationDays) - 1);
return {
semester,
startDate,
endDate: semester && calculatedEnd > semester.endDate
? semester.endDate
: calculatedEnd
};
}
function addIsoDays(value, days) {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(year, month - 1, day);
date.setDate(date.getDate() + days);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}

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 { 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 { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js'; import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.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 = [ const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' }, { value: 1, label: 'Понедельник', shortLabel: 'Пн' },
@@ -54,25 +60,107 @@ export function calculateCalendarTooltipPosition({
}; };
} }
export function academicYearDefaults(startYearValue) {
const value = String(startYearValue || '').trim();
if (!/^\d{4}$/.test(value)) {
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
}
const startYear = Number(value);
if (startYear < 1000 || startYear > 9998) {
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
}
const endYear = startYear + 1;
const start = new Date(Date.UTC(startYear, 8, 1));
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
const springStart = new Date(Date.UTC(endYear, 1, 1));
const end = new Date(Date.UTC(endYear, 5, 30));
return {
startYear,
endYear,
title: `${startYear}-${endYear}`,
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(end),
semesters: [
{
semesterType: 'autumn',
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(autumnEnd)
},
{
semesterType: 'spring',
startDate: toIsoDateUtc(springStart),
endDate: toIsoDateUtc(end)
}
]
};
}
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
if (!Array.isArray(semesters) || semesters.length === 0) {
throw new Error('Учебный год нельзя создать без семестров');
}
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
const types = new Set();
sorted.forEach((semester, index) => {
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
throw new Error('Заполните тип и даты каждого семестра');
}
if (types.has(semester.semesterType)) {
throw new Error('Типы семестров не должны повторяться');
}
types.add(semester.semesterType);
if (semester.endDate < semester.startDate) {
throw new Error('Дата окончания семестра не может быть раньше даты начала');
}
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
throw new Error('Семестры должны находиться в границах учебного года');
}
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
throw new Error('Периоды семестров не должны пересекаться');
}
});
}
function toIsoDateUtc(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
export function activityColorCode(activity) {
const color = String(activity?.colorCode || '').trim();
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
}
export function activityColorClass(activity) {
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
}
export async function initAcademicCalendar() { export async function initAcademicCalendar() {
const academicYearForm = document.getElementById('academic-year-form'); const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title'); const academicYearFormTitle = document.getElementById('academic-year-form-title');
const academicYearIdInput = document.getElementById('academic-year-id'); const academicYearIdInput = document.getElementById('academic-year-id');
const academicYearTitleInput = document.getElementById('academic-year-title');
const academicYearStartInput = document.getElementById('academic-year-start'); const academicYearStartInput = document.getElementById('academic-year-start');
const academicYearEndInput = document.getElementById('academic-year-end'); const academicYearEndInput = document.getElementById('academic-year-end');
const academicYearResetButton = document.getElementById('academic-year-reset'); const academicYearResetButton = document.getElementById('academic-year-reset');
const academicYearsTbody = document.getElementById('academic-years-tbody'); const academicYearsTbody = document.getElementById('academic-years-tbody');
const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
const academicYearSemesterInputs = {
const semesterForm = document.getElementById('semester-form'); autumn: {
const semesterFormTitle = document.getElementById('semester-form-title'); id: document.getElementById('academic-year-autumn-id'),
const semesterIdInput = document.getElementById('semester-id'); start: document.getElementById('academic-year-autumn-start'),
const semesterYearSelect = document.getElementById('semester-year'); end: document.getElementById('academic-year-autumn-end')
const semesterTypeSelect = document.getElementById('semester-type'); },
const semesterStartInput = document.getElementById('semester-start'); spring: {
const semesterEndInput = document.getElementById('semester-end'); id: document.getElementById('academic-year-spring-id'),
const semesterResetButton = document.getElementById('semester-reset'); start: document.getElementById('academic-year-spring-start'),
end: document.getElementById('academic-year-spring-end')
}
};
const calendarForm = document.getElementById('academic-calendar-form'); const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title'); const calendarFormTitle = document.getElementById('academic-calendar-form-title');
@@ -119,12 +207,19 @@ export async function initAcademicCalendar() {
const subjectCount = document.getElementById('calendar-subject-count'); const subjectCount = document.getElementById('calendar-subject-count');
let academicYears = []; let academicYears = [];
let semesters = [];
let specialties = []; let specialties = [];
let profilesBySpecialty = new Map(); let profilesBySpecialty = new Map();
let studyForms = []; let studyForms = [];
let activityTypes = []; let activityTypes = [];
let calendars = []; 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 subjects = [];
let calendarSubjectRows = []; let calendarSubjectRows = [];
let timeSlotScopes = []; let timeSlotScopes = [];
@@ -138,6 +233,28 @@ export async function initAcademicCalendar() {
let isSyncingCalendarDayDialog = false; let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null; let calendarDayTooltip = null;
let calendarDayTooltipPositionAnimation = 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(); bindEvents();
@@ -158,21 +275,32 @@ export async function initAcademicCalendar() {
function bindEvents() { function bindEvents() {
[ [
academicYearStartInput, academicYearSemesterInputs.autumn.start,
academicYearEndInput, academicYearSemesterInputs.autumn.end,
semesterStartInput, academicYearSemesterInputs.spring.start,
semesterEndInput, academicYearSemesterInputs.spring.end,
fillStartInput, fillStartInput,
fillEndInput fillEndInput
].forEach(bindDateInputMask); ].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => { 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); academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarSearch?.addEventListener('input', debounce(() => {
calendarPage = 0;
loadCalendars();
}, 300));
calendarYearFilter?.addEventListener('change', () => {
calendarPage = 0;
loadCalendars();
});
calendarsRefreshButton?.addEventListener('click', loadCalendars); calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
semesterResetButton?.addEventListener('click', resetSemesterForm); academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
calendarResetButton?.addEventListener('click', resetCalendarForm); calendarResetButton?.addEventListener('click', resetCalendarForm);
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
editorLoadButton?.addEventListener('click', loadCalendarGrid); editorLoadButton?.addEventListener('click', loadCalendarGrid);
@@ -188,6 +316,7 @@ export async function initAcademicCalendar() {
if (event.target.matches('.calendar-day-activity')) { if (event.target.matches('.calendar-day-activity')) {
updateCalendarDayVisual(event.target.closest('.calendar-day')); updateCalendarDayVisual(event.target.closest('.calendar-day'));
renderCalendarTotals(); renderCalendarTotals();
markGridDirty();
} }
}); });
calendarGrid?.addEventListener('mousedown', (event) => { calendarGrid?.addEventListener('mousedown', (event) => {
@@ -239,12 +368,12 @@ export async function initAcademicCalendar() {
}); });
updateCalendarDayDialogSummaryForSelection(days); updateCalendarDayDialogSummaryForSelection(days);
renderCalendarTotals(); renderCalendarTotals();
markGridDirty();
}); });
calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog);
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
academicYearForm?.addEventListener('submit', saveAcademicYear); academicYearForm?.addEventListener('submit', saveAcademicYear);
semesterForm?.addEventListener('submit', saveSemester);
calendarForm?.addEventListener('submit', saveCalendar); calendarForm?.addEventListener('submit', saveCalendar);
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick); academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
@@ -269,9 +398,13 @@ export async function initAcademicCalendar() {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>'; academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try { try {
academicYears = await api.get('/api/admin/calendar/years'); academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
renderAcademicYears(); renderAcademicYears();
populateYearSelects(); 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) { } catch (error) {
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error; throw error;
@@ -319,17 +452,19 @@ export async function initAcademicCalendar() {
event.preventDefault(); event.preventDefault();
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
try { try {
const defaults = applyAcademicYearDateDefaults(true);
const { startDate, endDate } = defaults;
const payload = { const payload = {
title: academicYearTitleInput.value.trim(), startDate,
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'), endDate,
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года') semesters: ['autumn', 'spring'].map(type => readAcademicYearSemester(type))
}; };
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); validateAcademicYearSemesters(payload.semesters, startDate, endDate);
const id = academicYearIdInput.value; const id = academicYearIdInput.value;
await (id await (id
? api.put('/api/admin/calendar/years/' + id, payload) ? api.put('/api/admin/calendar/years/' + id, payload)
: api.post('/api/admin/calendar/years', payload)); : api.post('/api/admin/calendar/years', payload));
showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); showAlert('academic-year-alert', 'Учебный год и семестры сохранены', 'success');
resetAcademicYearForm(); resetAcademicYearForm();
await loadYears(); await loadYears();
await loadCalendars(); await loadCalendars();
@@ -346,33 +481,27 @@ export async function initAcademicCalendar() {
if (editYearButton) { if (editYearButton) {
const year = academicYears.find(item => item.id == editYearButton.dataset.id); const year = academicYears.find(item => item.id == editYearButton.dataset.id);
if (!year) return; if (!year) return;
academicYearFormTitle.textContent = 'Редактирование учебного года'; fillAcademicYearForm(year);
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
setDateInputIso(academicYearStartInput, year.startDate);
setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
} }
if (editSemesterButton) { if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); const year = academicYears.find(item => (item.semesters || [])
if (!semester) return; .some(semester => String(semester.id) === String(editSemesterButton.dataset.id)));
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`; if (!year) return;
semesterIdInput.value = semester.id; const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id));
semesterYearSelect.value = semester.academicYearId || ''; fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType));
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
setDateInputIso(semesterStartInput, semester.startDate);
setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
} }
if (deleteYearButton) { if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; const confirmed = await confirmAction({
title: 'Удаление учебного года',
message: 'Удалить учебный год вместе с семестрами и календарными графиками?',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) return;
try { try {
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
showAlert('academic-year-alert', 'Учебный год удалён', 'success'); showAlert('academic-year-alert', 'Учебный год удалён', 'success');
@@ -386,42 +515,73 @@ export async function initAcademicCalendar() {
function resetAcademicYearForm() { function resetAcademicYearForm() {
academicYearForm.reset(); academicYearForm.reset();
academicYearFormTitle.textContent = 'Учебный год'; academicYearFormTitle.textContent = 'Учебный год и семестры';
academicYearIdInput.value = ''; academicYearIdInput.value = '';
Object.values(academicYearSemesterInputs).forEach(inputs => {
inputs.id.value = '';
});
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
} }
async function saveSemester(event) { function fillAcademicYearForm(year, focusedSemesterType = '') {
event.preventDefault(); academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim();
hideAlert('semester-alert'); academicYearIdInput.value = year.id;
const yearId = semesterYearSelect.value; academicYearStartInput.value = String(year.startDate || '').slice(0, 4);
try { academicYearEndInput.value = String(year.endDate || '').slice(0, 4);
const payload = { ['autumn', 'spring'].forEach(type => {
semesterType: semesterTypeSelect.value, const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type);
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'), const inputs = academicYearSemesterInputs[type];
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра') inputs.id.value = semester?.id || '';
}; setDateInputIso(inputs.start, semester?.startDate || '');
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { setDateInputIso(inputs.end, semester?.endDate || '');
throw new Error('Заполните учебный год, тип и даты семестра'); });
} hideAlert('academic-year-alert');
const id = semesterIdInput.value; academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
await (id if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) {
? api.put('/api/admin/calendar/semesters/' + id, payload) academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true });
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
showAlert('semester-alert', 'Семестр сохранён', 'success');
resetSemesterForm();
await loadYears();
} catch (error) {
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
} }
} }
function resetSemesterForm() { function handleAcademicYearStartInput() {
semesterForm.reset(); academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4);
semesterFormTitle.textContent = 'Семестр'; if (academicYearStartInput.value.length !== 4) {
semesterIdInput.value = ''; academicYearEndInput.value = '';
syncSelects(semesterYearSelect, semesterTypeSelect); return;
hideAlert('semester-alert'); }
try {
applyAcademicYearDateDefaults();
} catch (_) {
academicYearEndInput.value = '';
}
}
function applyAcademicYearDateDefaults(preserveSemesterDates = false) {
const defaults = academicYearDefaults(academicYearStartInput.value);
academicYearEndInput.value = String(defaults.endYear);
const semesterDefaults = {
autumn: defaults.semesters[0],
spring: defaults.semesters[1]
};
['autumn', 'spring'].forEach(type => {
const inputs = academicYearSemesterInputs[type];
if (!preserveSemesterDates || !inputs.start.value) {
setDateInputIso(inputs.start, semesterDefaults[type].startDate);
}
if (!preserveSemesterDates || !inputs.end.value) {
setDateInputIso(inputs.end, semesterDefaults[type].endDate);
}
});
return defaults;
}
function readAcademicYearSemester(type) {
const inputs = academicYearSemesterInputs[type];
return {
id: inputs.id.value ? Number(inputs.id.value) : null,
semesterType: type,
startDate: readDateInputIso(inputs.start, `Начало ${SEMESTER_LABELS[type]} семестра`),
endDate: readDateInputIso(inputs.end, `Окончание ${SEMESTER_LABELS[type]} семестра`)
};
} }
async function loadStudyForms() { async function loadStudyForms() {
@@ -448,13 +608,28 @@ export async function initAcademicCalendar() {
} }
async function loadCalendars() { async function loadCalendars() {
const request = ++calendarRequest;
calendarPagination?.replaceChildren();
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>'; calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try { 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(); renderCalendars();
populateEditorCalendarSelect(); renderPagination(calendarPagination, page, {
populateSubjectCalendarSelect(); label: 'графиков',
onPage: value => { calendarPage = value; loadCalendars(); },
onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); }
});
} catch (error) { } catch (error) {
if (request !== calendarRequest) return;
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error; throw error;
} }
@@ -462,7 +637,7 @@ export async function initAcademicCalendar() {
function renderCalendars() { function renderCalendars() {
if (!calendars.length) { 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; return;
} }
calendarsTbody.innerHTML = calendars.map(calendar => ` calendarsTbody.innerHTML = calendars.map(calendar => `
@@ -502,6 +677,10 @@ export async function initAcademicCalendar() {
const saved = id const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload) ? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', 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); const savedTitle = buildAcademicCalendarTitle(saved);
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success'); showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
resetCalendarForm(); resetCalendarForm();
@@ -536,7 +715,7 @@ export async function initAcademicCalendar() {
if (gridButton) { if (gridButton) {
activateCalendarTab('grid'); activateCalendarTab('grid');
editorCalendarSelect.value = gridButton.dataset.id; setCalendarSelection(editorCalendarSelect, gridButton.dataset.id);
refreshSelectVisuals(editorCalendarSelect); refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid(); await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -545,7 +724,7 @@ export async function initAcademicCalendar() {
if (subjectsButton) { if (subjectsButton) {
activateCalendarTab('subjects'); activateCalendarTab('subjects');
subjectCalendarSelect.value = subjectsButton.dataset.id; setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id);
refreshSelectVisuals(subjectCalendarSelect); refreshSelectVisuals(subjectCalendarSelect);
await loadCalendarSubjects(); await loadCalendarSubjects();
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' }); subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -553,9 +732,30 @@ export async function initAcademicCalendar() {
} }
if (deleteButton) { if (deleteButton) {
if (!confirm('Удалить календарный график?')) return; const confirmed = await confirmAction({
title: 'Удаление календарного графика',
message: 'Удалить календарный график? Связанные настройки больше не будут доступны.',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) return;
try { 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'); showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
await loadCalendars(); await loadCalendars();
} catch (error) { } catch (error) {
@@ -589,20 +789,34 @@ export async function initAcademicCalendar() {
} }
async function loadCalendarSubjects() { async function loadCalendarSubjects() {
const request = ++subjectLoadRequest;
hideAlert('calendar-subject-alert'); hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar(); const calendar = selectedSubjectCalendar();
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId);
refreshSelectVisuals(subjectCalendarSelect);
return;
}
if (request !== subjectLoadRequest) return;
populateSubjectSemesterSelect(); populateSubjectSemesterSelect();
calendarSubjectRows = []; calendarSubjectRows = [];
loadedSubjectCalendarId = '';
if (!calendar) { if (!calendar) {
loadedSubjectCalendarId = '';
clearSubjectDirty();
renderCalendarSubjects(); renderCalendarSubjects();
return; return;
} }
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>'; subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
try { try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return;
calendarSubjectRows = sortCalendarSubjectRows(rows || []); calendarSubjectRows = sortCalendarSubjectRows(rows || []);
loadedSubjectCalendarId = String(calendar.id);
clearSubjectDirty();
renderCalendarSubjects(); renderCalendarSubjects();
} catch (error) { } catch (error) {
if (request !== subjectLoadRequest) return;
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`; subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error'); showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
} }
@@ -611,6 +825,10 @@ export async function initAcademicCalendar() {
function addCalendarSubject() { function addCalendarSubject() {
hideAlert('calendar-subject-alert'); hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar(); const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
const semesterNumber = Number(subjectSemesterSelect.value); const semesterNumber = Number(subjectSemesterSelect.value);
const subjectId = Number(subjectSelect.value); const subjectId = Number(subjectSelect.value);
if (!calendar || !semesterNumber || !subjectId) { if (!calendar || !semesterNumber || !subjectId) {
@@ -641,12 +859,17 @@ export async function initAcademicCalendar() {
} }
]); ]);
renderCalendarSubjects(); renderCalendarSubjects();
markSubjectDirty();
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success'); showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
} }
async function saveCalendarSubjects() { async function saveCalendarSubjects() {
hideAlert('calendar-subject-alert'); hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar(); const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
if (!calendar) { if (!calendar) {
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error'); showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
return; return;
@@ -659,6 +882,8 @@ export async function initAcademicCalendar() {
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload); const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
calendarSubjectRows = sortCalendarSubjectRows(saved || []); calendarSubjectRows = sortCalendarSubjectRows(saved || []);
renderCalendarSubjects(); renderCalendarSubjects();
clearSubjectDirty();
trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length });
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success'); showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
} catch (error) { } catch (error) {
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error'); showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
@@ -674,6 +899,7 @@ export async function initAcademicCalendar() {
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
); );
renderCalendarSubjects(); renderCalendarSubjects();
markSubjectDirty();
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success'); showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
} }
@@ -709,7 +935,7 @@ export async function initAcademicCalendar() {
<span class="calendar-subject-chip"> <span class="calendar-subject-chip">
<span>${escapeHtml(subjectRowLabel(row))}</span> <span>${escapeHtml(subjectRowLabel(row))}</span>
<button type="button" <button type="button"
class="calendar-subject-remove" class="btn btn-icon-sm btn-danger-subtle calendar-subject-remove"
data-semester="${escapeHtml(String(row.semesterNumber))}" data-semester="${escapeHtml(String(row.semesterNumber))}"
data-subject="${escapeHtml(String(row.subjectId))}" data-subject="${escapeHtml(String(row.subjectId))}"
aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button> aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button>
@@ -718,8 +944,14 @@ export async function initAcademicCalendar() {
} }
async function loadCalendarGrid() { async function loadCalendarGrid() {
const request = ++gridLoadRequest;
hideAlert('calendar-editor-alert'); hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
setCalendarSelection(editorCalendarSelect, loadedGridCalendarId);
refreshSelectVisuals(editorCalendarSelect);
return;
}
if (!calendar) { if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return; return;
@@ -727,10 +959,15 @@ export async function initAcademicCalendar() {
try { try {
await loadTimeSlotDateAssignments(); await loadTimeSlotDateAssignments();
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); 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); const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(gridRows); 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'); showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) { } catch (error) {
if (request !== gridLoadRequest) return;
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
} }
} }
@@ -742,6 +979,10 @@ export async function initAcademicCalendar() {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return; return;
} }
if (String(calendar.id) !== loadedGridCalendarId) {
showAlert('calendar-editor-alert', 'Сначала загрузите сетку выбранного графика', 'error');
return;
}
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
calendarId: calendar.id, calendarId: calendar.id,
courseNumber: Number(day.dataset.course), courseNumber: Number(day.dataset.course),
@@ -756,6 +997,8 @@ export async function initAcademicCalendar() {
} }
try { try {
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); 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'); showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
await loadCalendarGrid(); await loadCalendarGrid();
} catch (error) { } catch (error) {
@@ -795,9 +1038,30 @@ export async function initAcademicCalendar() {
} }
}); });
renderCalendarTotals(); renderCalendarTotals();
if (changed) markGridDirty();
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); 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) { function renderCalendarGrid(rows) {
activeCalendarDay = null; activeCalendarDay = null;
selectedCalendarDayKey = null; selectedCalendarDayKey = null;
@@ -895,7 +1159,7 @@ export async function initAcademicCalendar() {
const activity = activityById(selected) || activityTypes[0] || {}; const activity = activityById(selected) || activityTypes[0] || {};
const code = activity.code || row.activityCode || 'Т'; const code = activity.code || row.activityCode || 'Т';
return ` return `
<td class="calendar-day" <td class="calendar-day ${activityColorClass(activity)}"
data-course="${row.courseNumber}" data-course="${row.courseNumber}"
data-date="${row.date}" data-date="${row.date}"
data-week="${row.weekNumber}" data-week="${row.weekNumber}"
@@ -967,9 +1231,11 @@ export async function initAcademicCalendar() {
function updateCalendarDayDialogSummary(day) { function updateCalendarDayDialogSummary(day) {
const activity = activityById(day.querySelector('.calendar-day-activity').value); const activity = activityById(day.querySelector('.calendar-day-activity').value);
if (!activity) { if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = ''; calendarDayDialogActivitySummary.innerHTML = '';
return; return;
} }
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = ` calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span> <span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div> <div>
@@ -987,6 +1253,7 @@ export async function initAcademicCalendar() {
const activity = activityById(commonCalendarDayActivityValue(days)); const activity = activityById(commonCalendarDayActivityValue(days));
if (!activity) { if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = ` calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">...</span> <span class="calendar-day-dialog-code">...</span>
<div> <div>
@@ -997,6 +1264,7 @@ export async function initAcademicCalendar() {
return; return;
} }
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = ` calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span> <span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div> <div>
@@ -1091,6 +1359,7 @@ export async function initAcademicCalendar() {
const select = day.querySelector('.calendar-day-activity'); const select = day.querySelector('.calendar-day-activity');
const activity = activityById(select.value); const activity = activityById(select.value);
const code = activity?.code || '-'; const code = activity?.code || '-';
applyActivityColorClass(day, activity);
day.querySelector('.calendar-day-code').textContent = code; day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
} }
@@ -1102,6 +1371,8 @@ export async function initAcademicCalendar() {
} }
const tooltip = ensureCalendarDayTooltip(); const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day); tooltip.innerHTML = renderCalendarDayTooltip(day);
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
applyActivityColorClass(tooltip, activity);
tooltip.classList.add('visible'); tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event); positionCalendarDayTooltip(tooltip, day, event);
} }
@@ -1285,7 +1556,9 @@ export async function initAcademicCalendar() {
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
const activity = activityById(select.value); const activity = activityById(select.value);
const key = activity ? activity.code : '-'; const key = activity ? activity.code : '-';
counts.set(key, (counts.get(key) || 0) + 1); const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
}); });
const selectedDays = selectedCalendarDays.length const selectedDays = selectedCalendarDays.length
? selectedCalendarDays ? selectedCalendarDays
@@ -1296,19 +1569,18 @@ export async function initAcademicCalendar() {
const selectedCode = selectedActivity?.code || null; const selectedCode = selectedActivity?.code || null;
calendarTotals.innerHTML = Array.from(counts.entries()) calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru')) .sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => { .map(([code, entry]) => {
const selectedClass = selectedCode === code ? ' is-selected' : ''; const selectedClass = selectedCode === code ? ' is-selected' : '';
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : ''; const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
return `<span class="calendar-total-chip${selectedClass}">${escapeHtml(code)}: ${count}${suffix}</span>`; return `<span class="calendar-total-chip ${entry.colorClass}${selectedClass}">${escapeHtml(code)}: ${entry.count}${suffix}</span>`;
}).join(''); }).join('');
} }
function populateYearSelects() { function populateYearSelects() {
const yearOptions = '<option value="">Выберите учебный год</option>' + const yearOptions = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join(''); academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
semesterYearSelect.innerHTML = yearOptions;
calendarYearSelect.innerHTML = yearOptions; calendarYearSelect.innerHTML = yearOptions;
syncSelects(semesterYearSelect, calendarYearSelect); syncSelects(calendarYearSelect);
} }
function populateSpecialtySelects() { function populateSpecialtySelects() {
@@ -1359,31 +1631,12 @@ export async function initAcademicCalendar() {
syncSelects(fillActivitySelect); syncSelects(fillActivitySelect);
} }
function populateEditorCalendarSelect() { function setCalendarSelection(select, id) {
const current = editorCalendarSelect.value; const calendar = calendarCache.get(String(id));
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' + select.innerHTML = calendar
calendars.map(calendar => ? `<option value="${calendar.id}" selected>${escapeHtml(buildAcademicCalendarTitle(calendar) || calendar.title)}</option>`
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>` : '<option value="">Выберите график</option>';
).join(''); calendarComboboxes.get(select)?.syncFromSelect();
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 populateSubjectSemesterSelect() { function populateSubjectSemesterSelect() {
@@ -1427,11 +1680,11 @@ export async function initAcademicCalendar() {
} }
function selectedEditorCalendar() { function selectedEditorCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); return calendarCache.get(String(editorCalendarSelect.value));
} }
function selectedSubjectCalendar() { function selectedSubjectCalendar() {
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value)); return calendarCache.get(String(subjectCalendarSelect?.value));
} }
function calendarSemesterCount(calendar) { function calendarSemesterCount(calendar) {
@@ -1474,6 +1727,16 @@ export async function initAcademicCalendar() {
return activityTypes.find(type => String(type.id) === String(id)); return activityTypes.find(type => String(type.id) === String(id));
} }
function applyActivityColorClass(element, activity) {
if (!element) return;
Array.from(element.classList)
.filter(className => className.startsWith('calendar-activity-color-'))
.forEach(className => element.classList.remove(className));
if (activity) {
element.classList.add(activityColorClass(activity));
}
}
function timeAssignmentForDate(date) { function timeAssignmentForDate(date) {
return timeSlotDateAssignments.find(item => item.date === date); return timeSlotDateAssignments.find(item => item.date === date);
} }
@@ -1609,6 +1872,7 @@ export async function initAcademicCalendar() {
function refreshSelectVisuals(...selects) { function refreshSelectVisuals(...selects) {
selects.filter(Boolean).forEach(select => { selects.filter(Boolean).forEach(select => {
calendarComboboxes.get(select)?.syncFromSelect();
if (select.customSelectInstance) { if (select.customSelectInstance) {
select.customSelectInstance.rebuildMenu(); select.customSelectInstance.rebuildMenu();
} }

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js'; import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { alertAction, confirmAction } from '../dialog.js';
async function fetchEquipments() { async function fetchEquipments() {
try { try {
@@ -93,11 +94,13 @@ export async function initClassrooms() {
</button> </button>
</div> </div>
</td> </td>
<td style="text-align: right;"> <td class="table-actions-cell">
<div class="table-actions">
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button> <button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
${c.status === 'ARCHIVED' ${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>` ? `<button class="btn btn-sm btn-secondary btn-restore-classroom" data-id="${c.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`} : `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}">Архивировать</button>`}
</div>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -131,11 +134,16 @@ export async function initClassrooms() {
const btnEdit = e.target.closest('.classroom-edit-action'); const btnEdit = e.target.closest('.classroom-edit-action');
if (btnDelete) { if (btnDelete) {
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return; if (!await confirmAction({
title: 'Вывод аудитории из эксплуатации',
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
confirmLabel: 'Вывести из эксплуатации',
danger: true
})) return;
try { try {
await api.delete('/api/classrooms/' + btnDelete.dataset.id); await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms(); loadClassrooms();
} catch (err) { alert('Ошибка архивирования'); } } catch (err) { await alertAction({ title: 'Ошибка архивирования', message: err.message || 'Не удалось архивировать аудиторию' }); }
} }
const btnRestore = e.target.closest('.btn-restore-classroom'); const btnRestore = e.target.closest('.btn-restore-classroom');
@@ -143,7 +151,7 @@ export async function initClassrooms() {
try { try {
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {}); await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
loadClassrooms(); loadClassrooms();
} catch (err) { alert('Ошибка восстановления аудитории'); } } catch (err) { await alertAction({ title: 'Ошибка восстановления', message: err.message || 'Не удалось восстановить аудиторию' }); }
} }
if (btnToggleStatus) { if (btnToggleStatus) {
@@ -152,7 +160,7 @@ export async function initClassrooms() {
try { try {
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus }); await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
loadClassrooms(); loadClassrooms();
} catch (err) { alert('Ошибка изменения статуса'); } } catch (err) { await alertAction({ title: 'Ошибка изменения статуса', message: err.message || 'Не удалось изменить доступность аудитории' }); }
} }
if (btnEdit) { if (btnEdit) {
@@ -178,7 +186,7 @@ export async function initClassrooms() {
hideAlert('edit-classroom-alert'); hideAlert('edit-classroom-alert');
modalEditClassroom.classList.add('open'); modalEditClassroom.classList.add('open');
} catch (e) { } catch (e) {
alert('Ошибка загрузки данных аудитории'); await alertAction({ title: 'Ошибка загрузки', message: e.message || 'Не удалось загрузить данные аудитории' });
} }
} }
@@ -288,12 +296,17 @@ export async function initClassrooms() {
manageEquipmentTbody.addEventListener('click', async (e) => { manageEquipmentTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete-equipment'); const btnDelete = e.target.closest('.btn-delete-equipment');
if (!btnDelete) return; if (!btnDelete) return;
if (!confirm('Удалить оборудование из каталога?')) return; if (!await confirmAction({
title: 'Удаление оборудования',
message: 'Удалить оборудование из каталога?',
confirmLabel: 'Удалить',
danger: true
})) return;
try { try {
await api.delete('/api/equipments/' + btnDelete.dataset.id); await api.delete('/api/equipments/' + btnDelete.dataset.id);
await loadManageEquipments(); await loadManageEquipments();
} catch (err) { } catch (err) {
alert(err.message || 'Ошибка удаления'); await alertAction({ title: 'Ошибка удаления', message: err.message || 'Не удалось удалить оборудование' });
} }
}); });

View File

@@ -1,5 +1,6 @@
import { api, getSession } from '../api.js'; import { api, getSession } from '../api.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { defaultSchedulePeriod } from '../schedule-period.js';
const ROLE_DEPARTMENT = 'DEPARTMENT'; const ROLE_DEPARTMENT = 'DEPARTMENT';
@@ -72,6 +73,7 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true; departmentField.hidden = true;
departmentSelect.value = userDepartmentId || ''; departmentSelect.value = userDepartmentId || '';
} }
await selectDefaultWorkloadPeriod(startInput, endInput);
await loadAllTeachers(); await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]); renderImportRows([{ code: '', name: '' }]);
await loadWorkspace(); await loadWorkspace();
@@ -475,6 +477,20 @@ export async function initDepartmentWorkspace() {
} }
} }
async function selectDefaultWorkloadPeriod(startInput, endInput) {
try {
const semesters = await api.get('/api/schedule/semesters');
const period = defaultSchedulePeriod(
semesters,
formatLocalDate(new Date())
);
startInput.value = period.startDate;
endInput.value = period.endDate;
} catch (_error) {
// Текущий диапазон остаётся безопасным fallback, если справочник семестров недоступен.
}
}
async function loadDepartments(role, userDepartmentId) { async function loadDepartments(role, userDepartmentId) {
const departments = await api.get('/api/departments'); const departments = await api.get('/api/departments');
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру'); fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');

View File

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

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js'; import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { AsyncCombobox } from '../async-combobox.js';
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']); const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
const ACTION_LABELS = { const ACTION_LABELS = {
@@ -83,7 +84,30 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
}; };
registryButton.hidden = false; 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(); bindEvents();
renderRegistry(); 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) { function setEditorValues(existing) {
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || ''; const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || ''; const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
@@ -250,6 +265,8 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
); );
teacherSelect.value = String(effectiveTeacherId); teacherSelect.value = String(effectiveTeacherId);
classroomSelect.value = String(effectiveClassroomId); classroomSelect.value = String(effectiveClassroomId);
teacherCombobox.syncFromSelect();
classroomCombobox.syncFromSelect();
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно'; formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
commentInput.value = existing?.comment || ''; 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)); const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : ''; 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) { function renderSemesterCalendar(startDate, endDate, available, selectedDate) {

View File

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

View File

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

View File

@@ -1,6 +1,15 @@
import { api, getSession } from '../api.js'; import { api, getSession } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { preferredScheduleSemester } from '../schedule-period.js';
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.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 ROLE_DEPARTMENT = 'DEPARTMENT';
const TARGET_GROUPS = 'groups'; const TARGET_GROUPS = 'groups';
@@ -61,7 +70,18 @@ export async function initScheduleView() {
const tables = document.getElementById('schedule-view-tables'); const tables = document.getElementById('schedule-view-tables');
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]')); const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
const departmentSelect = document.getElementById('schedule-view-department'); const departmentSelect = document.getElementById('schedule-view-department');
const academicYearSelect = document.getElementById('schedule-view-year');
const semesterSelect = document.getElementById('schedule-view-semester'); const semesterSelect = document.getElementById('schedule-view-semester');
const restored = readUrlState({
scheduleTarget: TARGET_GROUPS,
scheduleEntity: '',
scheduleDate: '',
scheduleYear: '',
scheduleSemester: '',
scheduleSubject: '',
scheduleLessonType: '',
scheduleParity: ''
});
const state = { const state = {
dictionaries: { dictionaries: {
@@ -82,6 +102,10 @@ export async function initScheduleView() {
const mobileMedia = window.matchMedia(MOBILE_QUERY); const mobileMedia = window.matchMedia(MOBILE_QUERY);
setDefaultDate(dateInput); 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(); configureDepartmentTarget();
updatePeriodLabel(); updatePeriodLabel();
updateEntityFieldVisibility(); updateEntityFieldVisibility();
@@ -91,6 +115,7 @@ export async function initScheduleView() {
clearInactiveEntityValues(); clearInactiveEntityValues();
updateEntityFieldVisibility(); updateEntityFieldVisibility();
resetResults(SCHEDULE_REFRESH_PROMPT); resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
}); });
loadButton?.addEventListener('click', loadData); loadButton?.addEventListener('click', loadData);
previousPeriodButton?.addEventListener('click', () => movePeriod(-1)); previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
@@ -102,11 +127,22 @@ export async function initScheduleView() {
dateInput?.addEventListener('change', () => { dateInput?.addEventListener('change', () => {
updatePeriodLabel(); updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT); 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', () => { semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester(); synchronizeDateWithSemester();
updatePeriodLabel(); updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT); resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
}); });
resultPreviousButton?.addEventListener('click', () => moveResult(-1)); resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1)); resultNextButton?.addEventListener('click', () => moveResult(1));
@@ -131,8 +167,10 @@ export async function initScheduleView() {
} }
try { try {
state.dictionaries = await loadDictionaries(role, userDepartmentId); Object.assign(state.dictionaries, await loadDictionaries(role, userDepartmentId));
selectCurrentSemester(); mergeLoadedDictionaries(state.dictionaries, entityComboboxes);
restoreSimpleFilters(restored);
selectCurrentSemester(restored.scheduleYear, restored.scheduleSemester);
overridePanel = createScheduleOverridePanel({ overridePanel = createScheduleOverridePanel({
role, role,
dictionaries: state.dictionaries, dictionaries: state.dictionaries,
@@ -148,6 +186,11 @@ export async function initScheduleView() {
resetResults(message); 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() { function configureDepartmentTarget() {
if (role !== ROLE_DEPARTMENT) return; if (role !== ROLE_DEPARTMENT) return;
targetSelect.value = TARGET_DEPARTMENTS; targetSelect.value = TARGET_DEPARTMENTS;
@@ -186,8 +229,12 @@ export async function initScheduleView() {
].forEach(([fieldTarget, id]) => { ].forEach(([fieldTarget, id]) => {
if (fieldTarget === target) return; if (fieldTarget === target) return;
if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return; if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return;
if (entityComboboxes[fieldTarget]) {
entityComboboxes[fieldTarget].clear();
} else {
const select = document.getElementById(id); const select = document.getElementById(id);
if (select) select.value = ''; if (select) select.value = '';
}
}); });
} }
@@ -254,6 +301,8 @@ export async function initScheduleView() {
state.overrides = overrides; state.overrides = overrides;
overridePanel.setRegistry(overrides, range); overridePanel.setRegistry(overrides, range);
renderLessons(lessons, range, target, previousKey); renderLessons(lessons, range, target, previousKey);
persistFilters();
trackUxEvent('расписание_найдено', { target, lessons: lessons.length });
} catch (error) { } catch (error) {
const message = error.message || 'Ошибка загрузки расписания'; const message = error.message || 'Ошибка загрузки расписания';
showAlert('schedule-view-alert', message, 'error'); showAlert('schedule-view-alert', message, 'error');
@@ -321,17 +370,7 @@ export async function initScheduleView() {
function buildSectionContext(target) { function buildSectionContext(target) {
const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : ''; const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : '';
const selectedDepartmentId = role === ROLE_DEPARTMENT const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId);
? userDepartmentId
: (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : '');
const allowedGroupIds = new Set();
if (selectedGroupId) {
allowedGroupIds.add(selectedGroupId);
} else if (selectedDepartmentId) {
state.dictionaries.groups
.filter(group => String(group.departmentId) === String(selectedDepartmentId))
.forEach(group => allowedGroupIds.add(String(group.id)));
}
return { return {
selectedGroupId, selectedGroupId,
selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '', selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '',
@@ -421,6 +460,11 @@ export async function initScheduleView() {
function renderDesktopSchedule(section, range) { function renderDesktopSchedule(section, range) {
const slots = collectSlots(section.lessons); const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderDesktopSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) { if (!blocks.length || !slots.length) {
@@ -432,6 +476,50 @@ export async function initScheduleView() {
.join(''); .join('');
} }
function renderDesktopSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const dayHeaders = DAY_ORDER.map(day => `
<th class="room-day-cell">
<strong>${escapeHtml(dayShort(day))}</strong>
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
</th>
`).join('');
const rows = slots.map(slot => `
<tr>
<td class="room-time-cell">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
</td>
${DAY_ORDER.map(day => renderSemesterSlotCell(day, slot, lessonMatrix)).join('')}
</tr>
`).join('');
tables.innerHTML = `
<section class="schedule-view-week-panel schedule-view-semester-panel">
<div class="room-week-heading">
<h3>${escapeHtml(section.title)}</h3>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
<div class="workload-grid-container schedule-view-grid-container">
<table class="workload-table room-week-table schedule-view-matrix-table">
<thead>
<tr>
<th class="room-time-heading">Время</th>
${dayHeaders}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</section>
`;
}
function renderMobileDayTabs() { function renderMobileDayTabs() {
dayTabs.hidden = false; dayTabs.hidden = false;
dayTabs.innerHTML = DAY_ORDER.map(day => ` dayTabs.innerHTML = DAY_ORDER.map(day => `
@@ -451,6 +539,11 @@ export async function initScheduleView() {
function renderMobileSchedule(section, range) { function renderMobileSchedule(section, range) {
const slots = collectSlots(section.lessons); const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderMobileSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) { if (!blocks.length || !slots.length) {
@@ -460,6 +553,34 @@ export async function initScheduleView() {
tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join(''); tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join('');
} }
function renderMobileSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const rows = slots.map(slot => `
<article class="schedule-view-mobile-slot">
<div class="schedule-view-mobile-slot-time">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
</div>
<div class="schedule-view-mobile-slot-content">
${renderSlotContent(slotStatesForSemester(state.mobileDay, slot, lessonMatrix))}
</div>
</article>
`).join('');
tables.innerHTML = `
<section class="schedule-view-mobile-day schedule-view-mobile-semester">
<div class="schedule-view-mobile-day-heading">
<strong>${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))}</strong>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
${rows}
</section>
`;
}
function renderMobileDayBlock(block, slots, lessonsByDateSlot) { function renderMobileDayBlock(block, slots, lessonsByDateSlot) {
const dayDates = block.datesByDay[state.mobileDay] || {}; const dayDates = block.datesByDay[state.mobileDay] || {};
const rows = slots.map(slot => { const rows = slots.map(slot => {
@@ -537,6 +658,12 @@ export async function initScheduleView() {
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`; return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
} }
function renderSemesterSlotCell(day, slot, lessonMatrix) {
const states = slotStatesForSemester(day, slot, lessonMatrix);
const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : '';
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
}
function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity, parity,
@@ -545,6 +672,54 @@ export async function initScheduleView() {
})); }));
} }
function slotStatesForSemester(day, slot, lessonMatrix) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
return {
parity,
label,
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
};
});
}
function buildSemesterLessonMatrix(lessons) {
const weekRanges = new Map();
lessons.forEach(lesson => {
const signature = lessonContentSignature(lesson);
const weekNumber = Number(lesson.weekNumber);
if (!Number.isFinite(weekNumber)) return;
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
range.startWeek = Math.min(range.startWeek, weekNumber);
range.endWeek = Math.max(range.endWeek, weekNumber);
weekRanges.set(signature, range);
});
const matrix = new Map();
lessons.forEach(lesson => {
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
? lesson.parity
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
const signature = lessonContentSignature(lesson);
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
if (!matrix.has(key)) matrix.set(key, new Map());
if (!matrix.get(key).has(signature)) {
const range = weekRanges.get(signature);
matrix.get(key).set(signature, {
...lesson,
displayStartWeek: range?.startWeek,
displayEndWeek: range?.endWeek
});
}
});
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
key,
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
]));
}
function renderSlotContent(states) { function renderSlotContent(states) {
if (slotStatesEqual(states[0].state, states[1].state)) { if (slotStatesEqual(states[0].state, states[1].state)) {
return renderUnifiedSlot(states[0].state); return renderUnifiedSlot(states[0].state);
@@ -640,6 +815,9 @@ export async function initScheduleView() {
} }
function getLessonWeeksText(lesson) { function getLessonWeeksText(lesson) {
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
}
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) { if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
return ''; return '';
} }
@@ -667,42 +845,7 @@ export async function initScheduleView() {
if (startWeek <= 0) startWeek = 1; if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek; if (endWeek < startWeek) endWeek = startWeek;
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson)); return formatLessonWeeksText(startWeek, endWeek);
}
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
const semesterStartWeek = firstApplicableSemesterWeek(parity);
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
return startWeek === endWeek
? `(${startWeek} нед.)`
: `(с ${startWeek} по ${endWeek} нед.)`;
}
function semesterWeeksForLesson(lesson) {
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
function dateInRange(date, startDate, endDate) {
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
}
function firstApplicableSemesterWeek(parity) {
return parity === 'EVEN' ? 2 : 1;
}
function lastApplicableSemesterWeek(maxWeeks, parity) {
const totalWeeks = Number(maxWeeks || 0);
if (!totalWeeks) return 0;
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
return totalWeeks;
} }
function renderLessonCard(lesson) { function renderLessonCard(lesson) {
@@ -722,7 +865,7 @@ export async function initScheduleView() {
const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate
? ` ? `
<button type="button" <button type="button"
class="schedule-override-edit" class="btn btn-sm btn-secondary schedule-override-edit"
data-schedule-override-edit data-schedule-override-edit
data-base-rule-slot-id="${escapeHtml(lesson.scheduleRuleSlotId)}" data-base-rule-slot-id="${escapeHtml(lesson.scheduleRuleSlotId)}"
data-lesson-date="${escapeHtml(sourceDate)}"> data-lesson-date="${escapeHtml(sourceDate)}">
@@ -799,14 +942,38 @@ export async function initScheduleView() {
.find(semester => String(semester.id) === String(semesterId)) || null; .find(semester => String(semester.id) === String(semesterId)) || null;
} }
function selectCurrentSemester() { function selectCurrentSemester(academicYearId = '', semesterId = '') {
if (!semesterSelect) return; if (!academicYearSelect || !semesterSelect) return;
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value); const selected = resolveAcademicPeriod(state.dictionaries.semesters, {
semesterSelect.value = currentSemester ? String(currentSemester.id) : ''; academicYearId,
semesterId,
date: dateInput.value
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
synchronizeDateWithSemester(); synchronizeDateWithSemester();
updatePeriodLabel(); 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() { function synchronizeDateWithSemester() {
const semester = selectedSemester(); const semester = selectedSemester();
if (!semester) { if (!semester) {
@@ -826,6 +993,26 @@ export async function initScheduleView() {
if (!range) return 1; if (!range) return 1;
return isoDay(dateInput.value || range.startDate); 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() { function createEmptyOverridePanel() {
@@ -837,37 +1024,111 @@ function createEmptyOverridePanel() {
} }
async function loadDictionaries(role, userDepartmentId) { async function loadDictionaries(role, userDepartmentId) {
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([ const [departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/departments'), api.get('/api/departments'),
api.get('/api/subjects'), api.get('/api/subjects'),
api.get('/api/lesson-types'), api.get('/api/lesson-types'),
api.get('/api/schedule/semesters') 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-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
return { return {
groups, groups: [],
teachers, teachers: [],
classrooms, classrooms: [],
departments, departments,
semesters: semesters || [] 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) { function setDefaultDate(dateInput) {
const today = new Date(); const today = new Date();
dateInput.value = toIsoDate(today); dateInput.value = toIsoDate(today);
@@ -960,6 +1221,12 @@ function buildScheduleSections(lessons, splitType, context) {
.sort((a, b) => naturalCompare(a.title, b.title)); .sort((a, b) => naturalCompare(a.title, b.title));
} }
export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) {
return target === TARGET_GROUPS && selectedGroupId
? new Set([String(selectedGroupId)])
: new Set();
}
function splitEntitiesForLesson(lesson, splitType, context) { function splitEntitiesForLesson(lesson, splitType, context) {
if (splitType === TARGET_TEACHERS) { if (splitType === TARGET_TEACHERS) {
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
@@ -1108,10 +1375,36 @@ export function findSemesterForDate(semesters, date) {
} }
export function scheduleRangeForSemester(selectedDate, semester) { export function scheduleRangeForSemester(selectedDate, semester) {
const anchorDate = semester && !dateWithinSemester(selectedDate, semester) if (semester?.startDate && semester?.endDate) {
? semester.startDate return {
: selectedDate; startDate: semester.startDate,
return twoWeekRange(anchorDate); endDate: semester.endDate
};
}
return twoWeekRange(selectedDate);
}
export function formatLessonWeeksText(startWeek, endWeek) {
const start = Number(startWeek);
const end = Number(endWeek);
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
return start === end
? `(${start} нед.)`
: `(с ${start} по ${end} нед.)`;
}
function semesterScheduleCaption(semester) {
const weeks = semesterWeekCount(semester);
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
return `Весь семестр · ${weekLabel}`;
}
function semesterWeekCount(semester) {
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
} }
function dateWithinSemester(date, semester) { function dateWithinSemester(date, semester) {

View File

@@ -1,5 +1,17 @@
import { api } from '../api.js'; 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 { 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 = [ const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник' }, { value: 1, label: 'Понедельник' },
@@ -29,11 +41,42 @@ const SEMESTER_LABELS = {
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2; const ACADEMIC_HOURS_PER_SLOT = 2;
const VISUAL_MENU_GAP = 8;
const VISUAL_MENU_VIEWPORT_PADDING = 8;
export function calculateVisualContextMenuPosition({
anchorRect,
menuWidth,
menuHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
let left = anchorRect.right + VISUAL_MENU_GAP;
let top = anchorRect.top;
if (left > maxLeft) {
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
}
if (top > maxTop) {
top = anchorRect.bottom - menuHeight;
}
return {
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
export function scheduleSlotActionLabel(slotCount) { export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить'; return slotCount === 1 ? 'Очистить' : 'Удалить';
} }
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
return !programmatic && !(type === 'input' && fromAsyncCombobox);
}
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') { export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status)); const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
return available.find(version => String(version.id) === String(requestedVersionId)) return available.find(version => String(version.id) === String(requestedVersionId))
@@ -73,11 +116,16 @@ const LESSON_TYPE_LIMITS = [
]; ];
export async function initSchedule() { 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 ruleForm = document.getElementById('schedule-rule-form');
const ruleFormTitle = document.getElementById('schedule-rule-form-title'); const ruleFormTitle = document.getElementById('schedule-rule-form-title');
const ruleIdInput = document.getElementById('schedule-rule-id'); const ruleIdInput = document.getElementById('schedule-rule-id');
const ruleSubjectSelect = document.getElementById('schedule-rule-subject'); const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester'); 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 scheduleVersionSelect = document.getElementById('schedule-version-select');
const scheduleVersionContext = document.getElementById('schedule-version-context'); const scheduleVersionContext = document.getElementById('schedule-version-context');
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow'); const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
@@ -101,6 +149,7 @@ export async function initSchedule() {
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu'); const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
const ruleGroupsText = document.getElementById('schedule-rule-groups-text'); const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes'); const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
const ruleGroupSearch = document.getElementById('schedule-rule-group-search');
const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
const ruleResetButton = document.getElementById('schedule-rule-reset'); const ruleResetButton = document.getElementById('schedule-rule-reset');
const slotAddButton = document.getElementById('schedule-slot-add'); const slotAddButton = document.getElementById('schedule-slot-add');
@@ -141,10 +190,16 @@ export async function initSchedule() {
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time'); const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
let rules = []; 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 subjects = [];
let groups = []; let groups = [];
let teachers = [];
let classrooms = [];
let lessonTypes = []; let lessonTypes = [];
let subgroups = []; let subgroups = [];
let timeSlots = []; let timeSlots = [];
@@ -160,8 +215,17 @@ export async function initSchedule() {
let syncingVisualPeriod = false; let syncingVisualPeriod = false;
let pendingConflictResolution = null; let pendingConflictResolution = null;
let visualContextTarget = null; let visualContextTarget = null;
let visualContextMenuPositionAnimation = null;
let pendingSlotMove = null; let pendingSlotMove = null;
let creatingDraft = false; let creatingDraft = false;
let lastVersionYearValue = '';
let lastVersionSemesterValue = '';
let lastVersionValue = '';
let slotComboboxes = [];
let conflictComboboxes = [];
let groupSearchRequest = 0;
let subgroupRequest = 0;
let syncingRuleForm = false;
bindEvents(); bindEvents();
@@ -173,13 +237,30 @@ export async function initSchedule() {
syncRuleSemesterWithVersion(); syncRuleSemesterWithVersion();
setRuleEditorEnabled(Boolean(selectedScheduleVersion())); setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
await loadRules(); await loadRules();
dirtyScope.clear();
} catch (error) { } catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', '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() { function bindEvents() {
rulesSearch?.addEventListener('input', debounce(() => {
rulesPage = 0;
loadRules();
}, 300));
refreshRulesButton?.addEventListener('click', loadRules); refreshRulesButton?.addEventListener('click', loadRules);
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
scheduleVersionSelect?.addEventListener('change', async () => { scheduleVersionSelect?.addEventListener('change', async () => {
const requestedValue = scheduleVersionSelect.value;
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
scheduleVersionSelect.value = lastVersionValue;
return;
}
lastVersionValue = requestedValue;
if (scheduleVersionSelect.value) { if (scheduleVersionSelect.value) {
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value); localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
} }
@@ -199,7 +280,10 @@ export async function initSchedule() {
}); });
draftForm?.addEventListener('submit', createDraftFromSelectedVersion); draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
ruleResetButton?.addEventListener('click', resetRuleForm); ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); slotAddButton?.addEventListener('click', () => {
renderRuleSlots([...readRuleSlots(false), {}]);
dirtyScope.mark();
});
visualToggleButton?.addEventListener('click', toggleVisualDrawer); visualToggleButton?.addEventListener('click', toggleVisualDrawer);
visualCloseButton?.addEventListener('click', closeVisualDrawer); visualCloseButton?.addEventListener('click', closeVisualDrawer);
visualBackdrop?.addEventListener('click', closeVisualDrawer); visualBackdrop?.addEventListener('click', closeVisualDrawer);
@@ -207,6 +291,7 @@ export async function initSchedule() {
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange); visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange); visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualStage?.addEventListener('click', handleVisualStageClick); visualStage?.addEventListener('click', handleVisualStageClick);
visualDrawer?.addEventListener('scroll', closeVisualContextMenu, true);
conflictCloseButton?.addEventListener('click', closeConflictModal); conflictCloseButton?.addEventListener('click', closeConflictModal);
conflictCancelButton?.addEventListener('click', closeConflictModal); conflictCancelButton?.addEventListener('click', closeConflictModal);
conflictModal?.addEventListener('click', (event) => { conflictModal?.addEventListener('click', (event) => {
@@ -276,6 +361,7 @@ export async function initSchedule() {
const slots = readRuleSlots(false); const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1); slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]); renderRuleSlots(slots.length ? slots : [{}]);
dirtyScope.mark();
return; return;
} }
@@ -292,9 +378,11 @@ export async function initSchedule() {
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu); ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown); ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation()); 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(); updateRuleGroupsText();
renderRuleSlots(readRuleSlots(false)); if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots);
}); });
document.addEventListener('click', () => { document.addEventListener('click', () => {
closeRuleGroupsMenu(); closeRuleGroupsMenu();
@@ -311,25 +399,39 @@ export async function initSchedule() {
updatePreferenceHints(); updatePreferenceHints();
}); });
ruleForm.addEventListener('submit', saveRule); 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); rulesTbody.addEventListener('click', handleRuleTableClick);
} }
async function loadBaseLists() { async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([ [subjects, lessonTypes] = await Promise.all([
api.get('/api/subjects'), api.get('/api/subjects'),
api.get('/api/groups'), api.get('/api/lesson-types')
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/lesson-types'),
api.get('/api/subgroups')
]); ]);
subgroups = [];
populateSubjectSelects(); populateSubjectSelects();
await searchRuleGroups('');
} }
async function loadYears() { async function loadYears() {
academicYears = await api.get('/api/admin/calendar/years'); academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []); semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects(); 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(); selectDefaultVisualPeriod();
} }
@@ -339,15 +441,27 @@ export async function initSchedule() {
} }
async function loadScheduleVersions(preferredVersionId = null) { async function loadScheduleVersions(preferredVersionId = null) {
const responses = await Promise.all(semesters.map(semester => const semesterId = scheduleVersionSemesterSelect?.value || defaultScheduleSemesterId();
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`) if (!semesterId) {
)); scheduleVersions = [];
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status)); scheduleVersionSelect.innerHTML = '<option value="">Сначала выберите семестр</option>';
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || ''; 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'); localStorage.removeItem('magistr.schedule.openVersionId');
const selected = chooseScheduleVersion( const selected = chooseScheduleVersion(
scheduleVersions, scheduleVersions,
ruleSemesterSelect.value || defaultScheduleSemesterId(), semesterId,
requestedVersionId requestedVersionId
); );
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED'); const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
@@ -359,6 +473,7 @@ export async function initSchedule() {
].filter(Boolean).join('') ].filter(Boolean).join('')
: '<option value="">Расписания не созданы</option>'; : '<option value="">Расписания не созданы</option>';
scheduleVersionSelect.value = selected ? String(selected.id) : ''; scheduleVersionSelect.value = selected ? String(selected.id) : '';
lastVersionValue = scheduleVersionSelect.value;
if (selected) { if (selected) {
localStorage.setItem('magistr.schedule.versionId', String(selected.id)); localStorage.setItem('magistr.schedule.versionId', String(selected.id));
} }
@@ -366,6 +481,64 @@ export async function initSchedule() {
setRuleEditorEnabled(Boolean(selected)); 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) { function versionOptionGroup(label, versions) {
if (!versions.length) return ''; if (!versions.length) return '';
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => ` return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
@@ -376,12 +549,11 @@ export async function initSchedule() {
} }
function defaultScheduleSemesterId() { function defaultScheduleSemesterId() {
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId'); return resolveAcademicPeriod(semesters, {
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId)); academicYearId: localStorage.getItem('magistr.schedule.yearId'),
if (saved) return saved.id; semesterId: localStorage.getItem('magistr.schedule.semesterId'),
const today = localIsoDate(new Date()); date: localIsoDate(new Date())
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today); }).semesterId;
return current?.id || semesters[0]?.id || '';
} }
function updateVersionContext() { function updateVersionContext() {
@@ -476,6 +648,11 @@ export async function initSchedule() {
} }
async function loadRules() { async function loadRules() {
const request = ++rulesRequest;
++visualRequest;
visualRules = [];
rules = [];
rulesPagination?.replaceChildren();
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>'; rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
const version = selectedScheduleVersion(); const version = selectedScheduleVersion();
if (!version) { if (!version) {
@@ -484,26 +661,43 @@ export async function initSchedule() {
setVisualState('Нет выбранного расписания'); setVisualState('Нет выбранного расписания');
return; return;
} }
if (rulesVersionId !== String(version.id)) {
rulesPage = 0;
rulesVersionId = String(version.id);
}
try { 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) { if (!visualPeriodTouched) {
selectDefaultVisualPeriod(); selectDefaultVisualPeriod();
} }
renderRules(); renderRules();
renderVisualSchedule(); if (visualDrawer?.classList.contains('open')) await loadVisualRules();
openRequestedRule(); await openRequestedRule();
} catch (error) { } catch (error) {
if (request !== rulesRequest) return;
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
setVisualState('Ошибка загрузки правил расписания'); setVisualState('Ошибка загрузки правил расписания');
} }
} }
function openRequestedRule() { async function openRequestedRule() {
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId'); const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
if (!requestedRuleId) return; if (!requestedRuleId) return;
localStorage.removeItem('magistr.schedule.openRuleId'); localStorage.removeItem('magistr.schedule.openRuleId');
const rule = rules.find(item => String(item.id) === requestedRuleId); const rule = await api.get(`/api/admin/schedule-rules/${encodeURIComponent(requestedRuleId)}`);
if (!rule) { if (!rule || String(rule.scheduleVersionId) !== String(selectedScheduleVersion()?.id)) {
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error'); showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
return; return;
} }
@@ -512,7 +706,7 @@ export async function initSchedule() {
function renderRules() { function renderRules() {
if (!rules.length) { 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(); renderVisualSchedule();
return; return;
} }
@@ -551,10 +745,12 @@ export async function initSchedule() {
visualToggleButton?.setAttribute('aria-expanded', 'true'); visualToggleButton?.setAttribute('aria-expanded', 'true');
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание'); visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
if (visualBackdrop) visualBackdrop.hidden = false; if (visualBackdrop) visualBackdrop.hidden = false;
loadVisualRules();
} }
function closeVisualDrawer() { function closeVisualDrawer() {
if (!visualDrawer) return; if (!visualDrawer) return;
closeVisualContextMenu();
visualDrawer.classList.remove('open'); visualDrawer.classList.remove('open');
visualDrawer.setAttribute('aria-hidden', 'true'); visualDrawer.setAttribute('aria-hidden', 'true');
visualToggleButton?.classList.remove('open'); visualToggleButton?.classList.remove('open');
@@ -563,34 +759,49 @@ export async function initSchedule() {
if (visualBackdrop) visualBackdrop.hidden = true; 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() { function renderVisualSchedule() {
if (!visualStage || !visualSummary) return; if (!visualStage || !visualSummary || !visualDrawer?.classList.contains('open')) return;
if (!selectedVisualSemesterId) { if (!selectedVisualSemesterId) {
syncVisualGroupFilter([]); syncVisualGroupFilter([]);
visualSummary.textContent = 'Выберите учебный год и семестр'; visualSummary.textContent = 'Выберите учебный год и семестр';
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра'); setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
return; return;
} }
if (!rules.length) { if (!visualRules.length) {
syncVisualGroupFilter([]); syncVisualGroupFilter([]);
visualSummary.textContent = 'Нет сохранённых правил'; visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам'); setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return; return;
} }
const visualRules = visualRulesForSelectedPeriod(); const periodRules = visualRulesForSelectedPeriod();
const periodLabel = visualPeriodLabel(); const periodLabel = visualPeriodLabel();
if (!visualRules.length) { if (!periodRules.length) {
syncVisualGroupFilter([]); syncVisualGroupFilter([]);
visualSummary.textContent = `${periodLabel} · правил нет`; visualSummary.textContent = `${periodLabel} · правил нет`;
setVisualState('В выбранном семестре нет сохранённых правил расписания'); setVisualState('В выбранном семестре нет сохранённых правил расписания');
return; return;
} }
const allGroups = collectVisualGroups(visualRules); const allGroups = collectVisualGroups(periodRules);
syncVisualGroupFilter(allGroups); syncVisualGroupFilter(allGroups);
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id))); 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) { if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения'; visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий'); setVisualState('У правил должны быть группы и слоты занятий');
@@ -602,7 +813,7 @@ export async function initSchedule() {
return; return;
} }
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules); const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, periodRules);
const rows = buildVisualRows(columns, slots, lessonsByCell); const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) { if (!rows.length) {
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`; visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
@@ -610,7 +821,7 @@ export async function initSchedule() {
return; 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 = ` visualStage.innerHTML = `
<div class="schedule-visual-table-wrap"> <div class="schedule-visual-table-wrap">
<table class="schedule-visual-table"> <table class="schedule-visual-table">
@@ -779,7 +990,7 @@ export async function initSchedule() {
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''} ${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
${meta ? `<small>${escapeHtml(meta)}</small>` : ''} ${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
<button type="button" <button type="button"
class="schedule-visual-edit-rule" class="btn btn-icon-sm btn-ghost schedule-visual-edit-rule"
data-rule-id="${escapeHtml(String(lesson.ruleId))}" data-rule-id="${escapeHtml(String(lesson.ruleId))}"
data-slot-id="${escapeHtml(String(lesson.slotId || ''))}" data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}" data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
@@ -870,7 +1081,7 @@ export async function initSchedule() {
} }
function openVisualContextMenu(button) { 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) { if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return; return;
@@ -882,6 +1093,23 @@ export async function initSchedule() {
}; };
if (!visualContextMenu) return; if (!visualContextMenu) return;
visualContextMenu.hidden = false; visualContextMenu.hidden = false;
const anchorRect = button.getBoundingClientRect();
const position = calculateVisualContextMenuPosition({
anchorRect,
menuWidth: visualContextMenu.offsetWidth,
menuHeight: visualContextMenu.offsetHeight,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
visualContextMenuPositionAnimation?.cancel();
const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`;
visualContextMenuPositionAnimation = visualContextMenu.animate(
[
{ transform: positionedTransform },
{ transform: positionedTransform }
],
{ duration: 1, fill: 'forwards' }
);
visualContextMenu.querySelector('button')?.focus(); visualContextMenu.querySelector('button')?.focus();
} }
@@ -889,6 +1117,8 @@ export async function initSchedule() {
visualContextTarget = null; visualContextTarget = null;
if (!visualContextMenu) return; if (!visualContextMenu) return;
visualContextMenu.hidden = true; visualContextMenu.hidden = true;
visualContextMenuPositionAnimation?.cancel();
visualContextMenuPositionAnimation = null;
} }
async function handleVisualContextMenuClick(event) { async function handleVisualContextMenuClick(event) {
@@ -896,7 +1126,7 @@ export async function initSchedule() {
if (!actionButton || !visualContextTarget) return; if (!actionButton || !visualContextTarget) return;
const action = actionButton.dataset.action; const action = actionButton.dataset.action;
const target = { ...visualContextTarget }; 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) { if (!rule) {
closeVisualContextMenu(); closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
@@ -923,7 +1153,12 @@ export async function initSchedule() {
async function deleteRuleFromVisual(rule) { async function deleteRuleFromVisual(rule) {
closeVisualContextMenu(); closeVisualContextMenu();
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return; if (!await confirmAction({
title: 'Удалить правило расписания?',
message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`,
confirmLabel: 'Удалить правило',
danger: true
})) return;
try { try {
await api.delete('/api/admin/schedule-rules/' + rule.id); await api.delete('/api/admin/schedule-rules/' + rule.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
@@ -934,7 +1169,7 @@ export async function initSchedule() {
} }
function openMoveSlotModal(target) { 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 slotIndex = findRuleSlotIndex(rule, target);
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null; const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
if (!rule || !slot) { if (!rule || !slot) {
@@ -977,7 +1212,7 @@ export async function initSchedule() {
event.preventDefault(); event.preventDefault();
if (!pendingSlotMove) return; if (!pendingSlotMove) return;
hideAlert('schedule-move-slot-alert'); 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]; const slot = rule?.slots?.[pendingSlotMove.slotIndex];
if (!rule || !slot) { if (!rule || !slot) {
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error'); showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
@@ -1147,7 +1382,7 @@ export async function initSchedule() {
function visualRulesForSelectedPeriod() { function visualRulesForSelectedPeriod() {
if (!selectedVisualSemesterId) return []; if (!selectedVisualSemesterId) return [];
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId); return visualRules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
} }
function firstSemesterWithRules() { function firstSemesterWithRules() {
@@ -1209,14 +1444,19 @@ export async function initSchedule() {
const deleteButton = event.target.closest('.btn-delete-rule'); const deleteButton = event.target.closest('.btn-delete-rule');
if (editButton) { 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); || await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
fillRuleForm(rule); fillRuleForm(rule);
return; return;
} }
if (deleteButton) { if (deleteButton) {
if (!confirm('Удалить правило расписания?')) return; if (!await confirmAction({
title: 'Удалить правило расписания?',
message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.',
confirmLabel: 'Удалить правило',
danger: true
})) return;
try { try {
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id); await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
@@ -1253,7 +1493,11 @@ export async function initSchedule() {
} }
async function finishSuccessfulRuleSave(saved) { async function finishSuccessfulRuleSave(saved) {
const mode = ruleIdInput.value ? 'update' : 'create';
const versionId = scheduleVersionSelect.value;
dirtyScope.clear();
resetRuleForm(); resetRuleForm();
trackUxEvent('правило_расписания_сохранено', { mode, versionId });
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success'); showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
await loadRules(); await loadRules();
} }
@@ -1378,6 +1622,8 @@ export async function initSchedule() {
} }
function renderConflictSlots(rule, conflictFields = []) { function renderConflictSlots(rule, conflictFields = []) {
conflictComboboxes.forEach(combobox => combobox.destroy());
conflictComboboxes = [];
const slots = rule.slots || []; const slots = rule.slots || [];
if (!conflictSlotsContainer) return; if (!conflictSlotsContainer) return;
if (!slots.length) { if (!slots.length) {
@@ -1401,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-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-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"><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('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" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</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> </section>
`; `;
}).join(''); }).join('');
initializeEntityComboboxes(conflictSlotsContainer, '.conflict-slot-teacher', '.conflict-slot-classroom', conflictComboboxes);
} }
function conflictFieldClass(field, fields) { function conflictFieldClass(field, fields) {
@@ -1529,7 +1776,7 @@ export async function initSchedule() {
function buildRulePayload() { function buildRulePayload() {
const groupIds = selectedRuleGroupIds(); const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true); const slots = readRuleSlots(true).map(ruleSlotToPayload);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания'); if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
@@ -1548,17 +1795,22 @@ export async function initSchedule() {
}; };
} }
function fillRuleForm(rule) { async function fillRuleForm(rule) {
ruleFormTitle.textContent = 'Редактирование правила расписания'; ruleFormTitle.textContent = 'Редактирование правила расписания';
ruleIdInput.value = rule.id; ruleIdInput.value = rule.id;
ruleSubjectSelect.value = rule.subjectId || ''; ruleSubjectSelect.value = rule.subjectId || '';
ruleSemesterSelect.value = rule.semesterId || ''; ruleSemesterSelect.value = rule.semesterId || '';
fillTypeLimits(rule); fillTypeLimits(rule);
syncSelects(ruleSubjectSelect, ruleSemesterSelect); syncSelects(ruleSubjectSelect, ruleSemesterSelect);
cacheRuleGroups(rule.groupIds || [], rule.groupNames || []);
renderRuleGroups(rule.groupIds || []); 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 : [{}]); renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
syncRuleSemesterWithVersion(); syncRuleSemesterWithVersion();
hideAlert('schedule-rule-alert'); hideAlert('schedule-rule-alert');
dirtyScope.clear();
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
@@ -1567,11 +1819,14 @@ export async function initSchedule() {
ruleFormTitle.textContent = 'Новое правило расписания'; ruleFormTitle.textContent = 'Новое правило расписания';
ruleIdInput.value = ''; ruleIdInput.value = '';
fillTypeLimits({}); fillTypeLimits({});
subgroupRequest += 1;
subgroups = [];
renderRuleGroups([]); renderRuleGroups([]);
renderRuleSlots([{}]); renderRuleSlots([{}]);
syncRuleSemesterWithVersion(); syncRuleSemesterWithVersion();
syncSelects(ruleSubjectSelect, ruleSemesterSelect); syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert'); hideAlert('schedule-rule-alert');
dirtyScope.clear();
} }
function renderRuleGroups(selectedIds) { function renderRuleGroups(selectedIds) {
@@ -1591,7 +1846,60 @@ export async function initSchedule() {
updateRuleGroupsText(); 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) { function renderRuleSlots(slots) {
slotComboboxes.forEach(combobox => combobox.destroy());
slotComboboxes = [];
const normalizedSlots = slots?.length ? slots : [{}]; const normalizedSlots = slots?.length ? slots : [{}];
const isSingleSlot = normalizedSlots.length === 1; const isSingleSlot = normalizedSlots.length === 1;
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length); const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
@@ -1601,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-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-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-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-teacher" aria-label="Преподаватель слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</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-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> <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) : ''} ${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> <div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
@@ -1610,6 +1918,7 @@ export async function initSchedule() {
<div class="schedule-preference-hint" data-preference-hint></div> <div class="schedule-preference-hint" data-preference-hint></div>
</div> </div>
`).join(''); `).join('');
initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes);
updatePreferenceHints(); updatePreferenceHints();
} }
@@ -1672,6 +1981,8 @@ export async function initSchedule() {
subgroupIds, subgroupIds,
teacherId: Number(row.querySelector('.slot-teacher').value), teacherId: Number(row.querySelector('.slot-teacher').value),
classroomId: Number(row.querySelector('.slot-classroom').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), lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
lessonFormat: row.querySelector('.slot-format').value lessonFormat: row.querySelector('.slot-format').value
}; };
@@ -1973,13 +2284,49 @@ export async function initSchedule() {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
} }
function toTeacherOption(teacher) { function initializeEntityComboboxes(container, teacherSelector, classroomSelector, collector) {
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` }; 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) { function selectedEntityOption(id, label) {
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : ''; return id
return { value: classroom.id, label: `${classroom.name}${capacity}` }; ? `<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) { function toLessonTypeOption(lessonType) {
@@ -2049,9 +2396,16 @@ export async function initSchedule() {
} }
function syncSelects(...selects) { 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 })); select.dispatchEvent(new Event('change', { bubbles: true }));
}); });
} finally {
syncingRuleForm = previousSyncState;
}
} }
function visualCellKey(groupId, dayOfWeek, slotKey) { function visualCellKey(groupId, dayOfWeek, slotKey) {

View File

@@ -1,10 +1,20 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.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() { export async function initSubjects() {
const subjectsTbody = document.getElementById('subjects-tbody'); const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form'); const createSubjectForm = document.getElementById('create-subject-form');
const newSubjectDeptSelect = document.getElementById('new-subject-department'); const newSubjectDeptSelect = document.getElementById('new-subject-department');
const departmentFilter = document.getElementById('subjects-department-filter');
const searchInput = document.getElementById('subjects-search');
const sortSelect = document.getElementById('subjects-sort');
const pagination = document.getElementById('subjects-pagination');
// Модалка управления преподавателями // Модалка управления преподавателями
const modalManageTeachers = document.getElementById('modal-manage-teachers'); const modalManageTeachers = document.getElementById('modal-manage-teachers');
@@ -16,9 +26,19 @@ export async function initSubjects() {
const manageTsTbody = document.getElementById('manage-ts-tbody'); const manageTsTbody = document.getElementById('manage-ts-tbody');
let allSubjects = []; let allSubjects = [];
let allTeachers = [];
let allDepartments = []; let allDepartments = [];
let teacherSubjects = []; 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(); bindEvents();
await loadInitialData(); await loadInitialData();
@@ -34,15 +54,43 @@ export async function initSubjects() {
modalManageTeachers.addEventListener('click', (e) => { modalManageTeachers.addEventListener('click', (e) => {
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open'); 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() { async function loadInitialData() {
try { try {
[allTeachers, allDepartments] = await Promise.all([ allDepartments = await api.get('/api/departments');
api.get('/api/users/teachers'), teacherCombobox = new AsyncCombobox(manageTsTeacherSelect, {
api.get('/api/departments') 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(); populateDepartmentsSelect();
populateDepartmentFilter();
await loadSubjects(); await loadSubjects();
} catch (e) { } catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`; subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
@@ -58,22 +106,58 @@ export async function initSubjects() {
}).join(''); }).join('');
} }
async function loadSubjects() { function populateDepartmentFilter() {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>'; departmentFilter.innerHTML = '<option value="">Все кафедры</option>' + allDepartments.map(department =>
try { `<option value="${department.id}">${escapeHtml(department.departmentName || department.name || department.id)}</option>`
[allSubjects, teacherSubjects] = await Promise.all([ ).join('');
api.get('/api/subjects'), departmentFilter.value = pageState.departmentId;
api.get('/api/teacher-subjects')
]);
renderSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
} }
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() { function renderSubjects() {
if (!allSubjects || !allSubjects.length) { if (!allSubjects || !allSubjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>'; renderTableEmpty(subjectsTbody, 6, pageState.query || pageState.departmentId
? 'Дисциплины не найдены'
: 'Дисциплин пока нет');
return; return;
} }
@@ -106,6 +190,7 @@ export async function initSubjects() {
</tr> </tr>
`; `;
}).join(''); }).join('');
markTableReady(subjectsTbody);
} }
async function createSubject(e) { async function createSubject(e) {
@@ -139,12 +224,17 @@ export async function initSubjects() {
const btnTeachers = e.target.closest('.btn-manage-teachers'); const btnTeachers = e.target.closest('.btn-manage-teachers');
if (btnDelete) { if (btnDelete) {
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return; if (!await confirmAction({
title: 'Архивировать дисциплину?',
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try { try {
await api.delete('/api/subjects/' + btnDelete.dataset.id); await api.delete('/api/subjects/' + btnDelete.dataset.id);
await loadSubjects(); await loadSubjects();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка удаления'); showAlert('subjects-alert', error.message || 'Ошибка архивирования', 'error');
} }
} }
@@ -182,22 +272,10 @@ export async function initSubjects() {
} }
function populateTeachersSelect(subjectId) { function populateTeachersSelect(subjectId) {
// Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине manageTsTeacherSelect.innerHTML = '<option value=""></option>';
const assignedUserIds = new Set( manageTsTeacherSelect.value = '';
teacherSubjects teacherCombobox?.syncFromSelect();
.filter(ts => String(ts.subjectId) === String(subjectId)) teacherCombobox?.search('');
.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('');
} }
async function saveTeacherAssignment(e) { async function saveTeacherAssignment(e) {
@@ -219,7 +297,7 @@ export async function initSubjects() {
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success'); showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
// Перезагрузим привязки и обновим интерфейс // Перезагрузим привязки и обновим интерфейс
teacherSubjects = await api.get('/api/teacher-subjects'); teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
renderSubjectTeachers(subjectId); renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId); populateTeachersSelect(subjectId);
@@ -233,7 +311,12 @@ export async function initSubjects() {
async function handleTeacherAssignmentTableClick(e) { async function handleTeacherAssignmentTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-ts'); const btnDelete = e.target.closest('.btn-delete-ts');
if (!btnDelete) return; if (!btnDelete) return;
if (!confirm('Удалить привязку преподавателя?')) return; if (!await confirmAction({
title: 'Удалить привязку преподавателя?',
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
confirmLabel: 'Удалить привязку',
danger: true
})) return;
const subjectId = manageTsSubjectIdInput.value; const subjectId = manageTsSubjectIdInput.value;
try { 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); renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId); populateTeachersSelect(subjectId);
// Обновим главную таблицу // Обновим главную таблицу
renderSubjects(); renderSubjects();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка удаления привязки'); showAlert('manage-ts-alert', error.message || 'Ошибка удаления привязки', 'error');
} }
} }
@@ -260,4 +343,14 @@ export async function initSubjects() {
if (!department) return departmentId || '-'; if (!department) return departmentId || '-';
return department.departmentName || department.name || 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 { api, getSession } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.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']); 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() { export async function initTeacherAbsences() {
const session = getSession(); const session = getSession();
const role = session?.role; const role = session?.role;
@@ -27,6 +36,20 @@ export async function initTeacherAbsences() {
let preferenceItems = []; let preferenceItems = [];
let changeItems = []; 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); setDefaultPeriod(startInput, endInput);
form?.addEventListener('submit', createAbsence); form?.addEventListener('submit', createAbsence);
statusFilter?.addEventListener('change', loadAbsences); statusFilter?.addEventListener('change', loadAbsences);
@@ -46,21 +69,7 @@ export async function initTeacherAbsences() {
document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews); document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews);
changeReviewList?.addEventListener('click', handleChangeReview); changeReviewList?.addEventListener('click', handleChangeReview);
await Promise.all([loadTeachers(), loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]); await Promise.all([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>`;
}
}
async function loadAbsences() { async function loadAbsences() {
registry.innerHTML = '<div class="loading-row">Загрузка...</div>'; registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
@@ -167,7 +176,7 @@ export async function initTeacherAbsences() {
await openWizard(id); await openWizard(id);
} }
} catch (error) { } catch (error) {
window.alert(error.message || 'Не удалось выполнить действие'); await alertAction({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
} finally { } finally {
button.disabled = false; button.disabled = false;
} }
@@ -328,7 +337,7 @@ export async function initTeacherAbsences() {
const approved = button.dataset.preferenceReview === 'approve'; const approved = button.dataset.preferenceReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || ''; const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) { if (!approved && !comment) {
window.alert('При отклонении укажите комментарий'); await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return; return;
} }
button.disabled = true; button.disabled = true;
@@ -339,7 +348,7 @@ export async function initTeacherAbsences() {
}); });
await loadPreferenceReviews(); await loadPreferenceReviews();
} catch (error) { } catch (error) {
window.alert(error.message || 'Не удалось рассмотреть пожелание'); await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
button.disabled = false; button.disabled = false;
} }
} }
@@ -397,7 +406,7 @@ export async function initTeacherAbsences() {
const approved = button.dataset.changeReview === 'approve'; const approved = button.dataset.changeReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || ''; const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) { if (!approved && !comment) {
window.alert('При отклонении укажите комментарий'); await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return; return;
} }
button.disabled = true; 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 api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]); await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) { } catch (error) {
window.alert(error.message || 'Не удалось применить решение'); await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
button.disabled = false; button.disabled = false;
} }
} }

View File

@@ -1,36 +1,72 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.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() { export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody'); const requestsTbody = document.getElementById('teacher-requests-tbody');
const refreshButton = document.getElementById('teacher-requests-refresh'); const refreshButton = document.getElementById('teacher-requests-refresh');
const requestsCount = document.getElementById('teacher-requests-count'); 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 = []; 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() { async function loadDepartments() {
departments = await api.get('/api/departments'); departments = await api.get('/api/departments');
} }
async function loadRequests() { async function loadRequests() {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>'; renderTableLoading(requestsTbody, 7, 'Загрузка заявок...');
hideAlert('teacher-requests-alert'); hideAlert('teacher-requests-alert');
try { try {
const requests = await api.get('/api/teacher-requests?status=PENDING'); const params = new URLSearchParams({
const normalizedRequests = Array.isArray(requests) ? requests : []; 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); 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', { 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) { } catch (error) {
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей'); renderTableError(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей', loadRequests);
updateCount(0); updateCount(0);
} }
} }
function renderRequests(requests) { function renderRequests(requests) {
if (!requests.length) { if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>'; renderTableEmpty(requestsTbody, 7, state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет');
return; return;
} }
requestsTbody.innerHTML = requests.map(request => ` requestsTbody.innerHTML = requests.map(request => `
@@ -51,12 +87,15 @@ export async function initTeacherRequests() {
<td>${escapeHtml(request.comment || '-')}</td> <td>${escapeHtml(request.comment || '-')}</td>
<td> <td>
<div class="teacher-request-actions"> <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-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button> <button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
` : `<span class="badge">${escapeHtml(request.status === 'APPROVED' ? 'Одобрена' : 'Отклонена')}</span>`}
</div> </div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
markTableReady(requestsTbody);
} }
function updateCount(count) { function updateCount(count) {
@@ -98,7 +137,18 @@ export async function initTeacherRequests() {
} }
async function rejectRequest(requestId) { 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; if (reviewComment === null) return;
try { try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment }); await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
@@ -110,6 +160,16 @@ export async function initTeacherRequests() {
} }
refreshButton?.addEventListener('click', loadRequests); 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) => { requestsTbody.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve'); const approveButton = event.target.closest('.teacher-request-approve');
@@ -133,4 +193,13 @@ export async function initTeacherRequests() {
} catch (error) { } catch (error) {
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей'); 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 { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
export async function initUniversityStructure() { export async function initUniversityStructure() {
const deptTbody = document.getElementById('departments-tbody'); const deptTbody = document.getElementById('departments-tbody');
@@ -51,6 +54,29 @@ export async function initUniversityStructure() {
let specialties = []; let specialties = [];
let currentProfiles = []; let currentProfiles = [];
let allProfiles = []; 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 = [ const structureTabs = [
@@ -104,11 +130,12 @@ export async function initUniversityStructure() {
} }
function renderDepartments() { function renderDepartments() {
if (!departments || !departments.length) { const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>'; if (!visible.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return; return;
} }
deptTbody.innerHTML = departments.map(d => ` deptTbody.innerHTML = visible.map(d => `
<tr> <tr>
<td>${d.id}</td> <td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td> <td>${escapeHtml(d.departmentName || d.name)}</td>
@@ -122,11 +149,12 @@ export async function initUniversityStructure() {
} }
function renderSpecialties() { function renderSpecialties() {
if (!specialties || !specialties.length) { const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>'; if (!visible.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return; return;
} }
specTbody.innerHTML = specialties.map(s => ` specTbody.innerHTML = visible.map(s => `
<tr> <tr>
<td>${s.id}</td> <td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td> <td>${escapeHtml(s.specialityName || s.name)}</td>
@@ -179,7 +207,7 @@ export async function initUniversityStructure() {
} }
if (deleteBtn) { if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return; if (!await confirmAction({ title: 'Удаление кафедры', message: 'Удалить кафедру?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete('/api/departments/' + deleteBtn.dataset.id); await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success'); showAlert('create-dept-alert', 'Кафедра удалена', 'success');
@@ -252,7 +280,7 @@ export async function initUniversityStructure() {
} }
if (deleteBtn) { if (deleteBtn) {
if (!confirm('Удалить специальность?')) return; if (!await confirmAction({ title: 'Удаление специальности', message: 'Удалить специальность?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id); await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success'); showAlert('create-spec-alert', 'Специальность удалена', 'success');
@@ -390,7 +418,7 @@ export async function initUniversityStructure() {
} }
if (deleteBtn) { if (deleteBtn) {
if (!confirm('Удалить профиль обучения?')) return; if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`); await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
showAlert('manage-profile-alert', 'Профиль удален', 'success'); showAlert('manage-profile-alert', 'Профиль удален', 'success');
@@ -427,11 +455,12 @@ export async function initUniversityStructure() {
} }
function renderAllProfiles() { function renderAllProfiles() {
if (!allProfiles || !allProfiles.length) { const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>'; if (!visible.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
return; return;
} }
tabProfileTbody.innerHTML = allProfiles.map(p => ` tabProfileTbody.innerHTML = visible.map(p => `
<tr> <tr>
<td>${p.id}</td> <td>${p.id}</td>
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td> <td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
@@ -517,7 +546,7 @@ export async function initUniversityStructure() {
if (deleteBtn) { if (deleteBtn) {
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id); const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
if (!profile) return; if (!profile) return;
if (!confirm('Удалить профиль обучения?')) return; if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`); await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
showAlert('tab-profile-alert', 'Профиль удален', 'success'); showAlert('tab-profile-alert', 'Профиль удален', 'success');

View File

@@ -1,5 +1,10 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.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 = { const ROLE_LABELS = {
ADMIN: 'Администратор', ADMIN: 'Администратор',
@@ -21,21 +26,53 @@ const ROLE_BADGE = {
export async function initUsers() { export async function initUsers() {
const usersTbody = document.getElementById('users-tbody'); const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form'); 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() { async function loadUsers() {
renderTableLoading(usersTbody, 7, 'Загрузка пользователей...');
hideAlert('users-alert');
try { try {
const users = await api.get('/api/users?includeArchived=true'); const [sort, direction] = state.sort.split(',');
renderUsers(users); 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) { } catch (e) {
usersTbody.innerHTML = renderTableError(usersTbody, 7, e.message || 'Не удалось загрузить пользователей', loadUsers);
'<tr><td colspan="7" class="loading-row">Ошибка загрузки: ' +
escapeHtml(e.message) + '</td></tr>';
} }
} }
function renderUsers(users) { function renderUsers(users) {
if (!users || !users.length) { if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>'; renderTableEmpty(usersTbody, 7, state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет');
return; return;
} }
@@ -49,13 +86,30 @@ export async function initUsers() {
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td> <td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td> <td>
${u.status === 'ARCHIVED' ${u.status === 'ARCHIVED'
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>` ? `<button class="btn btn-sm btn-secondary btn-restore-user" data-id="${u.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`} : `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
</td> </td>
</tr> </tr>
`).join(''); `).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) => { createForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
hideAlert('create-alert'); hideAlert('create-alert');
@@ -97,12 +151,17 @@ export async function initUsers() {
const deleteBtn = e.target.closest('.btn-archive-user'); const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return; if (!deleteBtn) return;
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return; if (!await confirmAction({
title: 'Архивировать пользователя?',
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
confirmLabel: 'Архивировать',
danger: true
})) return;
try { try {
await api.delete('/api/users/' + deleteBtn.dataset.id); await api.delete('/api/users/' + deleteBtn.dataset.id);
loadUsers(); loadUsers();
} catch (err) { } 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', {}); await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
loadUsers(); loadUsers();
} catch (err) { } 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(); await loadUsers();
} }

View File

@@ -61,7 +61,7 @@ html {
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="../css/components.css"> <link rel="stylesheet" href="../css/components.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="../../ui-foundation.css">
</head> </head>
<body> <body>

View File

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

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