Compare commits
6 Commits
855d191517
...
Redesign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b6797713 | ||
|
|
a1c4ee4298 | ||
|
|
3599d850f6 | ||
|
|
52d516dd8f | ||
|
|
ca0b9352f9 | ||
|
|
d384a43aa2 |
10
AGENTS.md
10
AGENTS.md
@@ -62,8 +62,14 @@ docker compose logs -f backend
|
||||
## Критические правила для агентов
|
||||
|
||||
### Flyway миграции
|
||||
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
|
||||
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
||||
- До отдельного решения владельца проект работает в режиме пересобираемого baseline:
|
||||
**все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
|
||||
только в `V1__init.sql`**.
|
||||
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
|
||||
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
|
||||
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
|
||||
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
|
||||
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
|
||||
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
||||
|
||||
### Языковые требования
|
||||
|
||||
@@ -63,18 +63,22 @@
|
||||
|
||||
**Файлы:**
|
||||
|
||||
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265`
|
||||
- `docs/README.md:45-54`
|
||||
- `backend/src/main/resources/db/migration/V2__test_data.sql`
|
||||
- `docs/README.md`
|
||||
|
||||
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
|
||||
**Проблема:** схема и данные разделены: V1 теперь не содержит доменных записей, однако
|
||||
`V2__test_data.sql` всё равно автоматически применяется общим Flyway location ко всем tenant-БД.
|
||||
V2 создаёт администратора с публично документированным коротким паролем, несколько известных
|
||||
demo-пользователей и большой демонстрационный набор. Пароли 150 массовых преподавателей случайны
|
||||
и непубличны, но это не устраняет риск доступного администратора и загрязнения production-БД.
|
||||
|
||||
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
|
||||
|
||||
**Как исправить:**
|
||||
|
||||
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
|
||||
- Исключать `V2__test_data.sql` из production Flyway locations либо включать её только явным
|
||||
dev/test-профилем.
|
||||
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
|
||||
- Разнести demo/dev seed и обязательную production-схему.
|
||||
- Ротировать пароли уже созданных tenant-БД.
|
||||
|
||||
## Высокий приоритет
|
||||
|
||||
676
UX_IMPROVEMENT_PLAN.md
Normal file
676
UX_IMPROVEMENT_PLAN.md
Normal 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
55
UX_SITE_CHECKLIST.md
Normal 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` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий.
|
||||
|
||||
## Что пока не проверяем
|
||||
|
||||
Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**.
|
||||
@@ -7,8 +7,9 @@ import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicCalendarGridService;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -19,6 +20,8 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@@ -28,10 +31,6 @@ public class AcademicCalendarController {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
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 AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicCalendarGridService calendarGridService;
|
||||
@@ -40,10 +39,6 @@ public class AcademicCalendarController {
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicCalendarGridService calendarGridService,
|
||||
@@ -51,10 +46,6 @@ public class AcademicCalendarController {
|
||||
AcademicStructureService academicStructureService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarPeriodRepository = calendarPeriodRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.calendarGridService = calendarGridService;
|
||||
@@ -71,6 +62,46 @@ public class AcademicCalendarController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AcademicCalendarDto> getCalendarsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long academicYearId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return PageResponse.from(calendarRepository.searchOptions(
|
||||
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
|
||||
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
|
||||
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
|
||||
.map(this::toCalendarDto));
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
public List<AcademicCalendarDto> searchCalendarOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long academicYearId,
|
||||
@RequestParam(required = false) Long specialtyId,
|
||||
@RequestParam(required = false) Long profileId,
|
||||
@RequestParam(required = false) Long studyFormId,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
List<AcademicCalendarDto> options = new ArrayList<>(calendarRepository.searchOptions(
|
||||
normalizedQuery,
|
||||
academicYearId,
|
||||
specialtyId,
|
||||
profileId,
|
||||
studyFormId,
|
||||
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("title").ascending())
|
||||
).stream()
|
||||
.map(this::toCalendarDto)
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.id()))) {
|
||||
calendarRepository.findByIdWithDetails(selectedId).map(this::toCalendarDto).ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
|
||||
return calendarRepository.findByIdWithDetails(id)
|
||||
@@ -81,10 +112,7 @@ public class AcademicCalendarController {
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
|
||||
try {
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
applyCalendar(calendar, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
|
||||
return ResponseEntity.ok(toCalendarDto(academicStructureService.createCalendar(request)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
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) {
|
||||
if (rows.size() > 1000) {
|
||||
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
|
||||
|
||||
@@ -10,11 +10,15 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.EquipmentRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/classrooms")
|
||||
@@ -40,6 +44,26 @@ public class ClassroomController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<ClassroomResponse> searchClassroomOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
List<ClassroomResponse> options = new ArrayList<>(classroomRepository.searchActiveOptions(
|
||||
normalizedQuery,
|
||||
LifecycleEntity.STATUS_ARCHIVED,
|
||||
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("name").ascending())
|
||||
).stream()
|
||||
.map(this::mapToResponse)
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||
classroomRepository.findById(selectedId).map(this::mapToResponse).ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.AcademicCalendarSubject;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
@@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -98,6 +108,118 @@ public class GroupController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<GroupResponse> getGroupsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) List<Long> educationFormId,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) Integer yearStartStudy,
|
||||
@RequestParam(defaultValue = "true") boolean includeArchived,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "name") String sort,
|
||||
@RequestParam(defaultValue = "asc") String direction) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
Specification<StudentGroup> specification = groupSpecification(
|
||||
query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived
|
||||
);
|
||||
Page<StudentGroup> groups = groupRepository.findAll(
|
||||
specification,
|
||||
PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction))
|
||||
);
|
||||
return PageResponse.from(groups.map(this::mapToResponse));
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
public List<GroupResponse> searchGroupOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<GroupResponse> options = new ArrayList<>(groupRepository.findAll(
|
||||
groupSpecification(query, null, effectiveDepartmentId, null, false),
|
||||
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("name").ascending())
|
||||
).stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.map(this::mapToResponse)
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||
groupRepository.findById(selectedId)
|
||||
.filter(group -> effectiveDepartmentId == null
|
||||
|| Objects.equals(group.getDepartmentId(), effectiveDepartmentId))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.map(this::mapToResponse)
|
||||
.ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||
? currentUser.departmentId()
|
||||
: requestedDepartmentId;
|
||||
}
|
||||
|
||||
private Specification<StudentGroup> groupSpecification(String query,
|
||||
List<Long> educationFormIds,
|
||||
Long departmentId,
|
||||
Integer yearStartStudy,
|
||||
boolean includeArchived) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (!includeArchived) {
|
||||
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||
}
|
||||
if (educationFormIds != null && !educationFormIds.isEmpty()) {
|
||||
predicates.add(root.get("educationForm").get("id").in(educationFormIds));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
if (yearStartStudy != null) {
|
||||
predicates.add(builder.equal(root.get("yearStartStudy"), yearStartStudy));
|
||||
}
|
||||
String normalizedQuery = normalizeQuery(query);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("name")), pattern),
|
||||
builder.like(builder.lower(root.get("speciality").get("specialityCode")), pattern),
|
||||
builder.like(builder.lower(root.get("speciality").get("specialityName")), pattern),
|
||||
builder.like(builder.lower(root.get("specialtyProfile").get("name")), pattern),
|
||||
builder.like(builder.lower(root.get("educationForm").get("name")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort groupSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "yearStartStudy" -> "yearStartStudy";
|
||||
case "groupSize" -> "groupSize";
|
||||
case "status" -> "status";
|
||||
default -> "name";
|
||||
};
|
||||
return "desc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).descending()
|
||||
: Sort.by(property).ascending();
|
||||
}
|
||||
|
||||
private int normalizePage(int page) {
|
||||
return Math.max(page, 0);
|
||||
}
|
||||
|
||||
private int normalizeSize(int size) {
|
||||
return Math.max(1, Math.min(size, 100));
|
||||
}
|
||||
|
||||
private String normalizeQuery(String query) {
|
||||
return query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@GetMapping("/{departmentId}")
|
||||
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleRuleConflictException;
|
||||
import com.magistr.app.service.ScheduleRuleService;
|
||||
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
|
||||
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<ScheduleRuleDto> getPage(
|
||||
@RequestParam Long versionId,
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return scheduleRuleService.getPage(versionId, query, page, size);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
|
||||
return ResponseEntity.ok(scheduleRuleService.getById(id));
|
||||
|
||||
@@ -12,10 +12,13 @@ import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@RestController
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
|
||||
public class SubgroupController {
|
||||
@@ -36,8 +39,16 @@ public class SubgroupController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/subgroups")
|
||||
public List<SubgroupDto> getAll() {
|
||||
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
|
||||
public List<SubgroupDto> getAll(@RequestParam(required = false) List<Long> groupId) {
|
||||
if (groupId != null && groupId.size() > 100) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп");
|
||||
}
|
||||
List<Subgroup> selected = groupId == null
|
||||
? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()
|
||||
: groupId.isEmpty()
|
||||
? List.of()
|
||||
: subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList());
|
||||
return selected.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.dto.SubjectResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
@@ -11,9 +13,14 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -53,6 +60,74 @@ public class SubjectController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public PageResponse<SubjectResponse> getSubjectsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(defaultValue = "false") boolean includeArchived,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "name") String sort,
|
||||
@RequestParam(defaultValue = "asc") String direction) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
var subjects = subjectRepository.findAll(
|
||||
subjectSpecification(query, effectiveDepartmentId, includeArchived),
|
||||
PageRequest.of(
|
||||
Math.max(page, 0),
|
||||
Math.max(1, Math.min(size, 100)),
|
||||
subjectSort(sort, direction)
|
||||
)
|
||||
);
|
||||
return PageResponse.from(subjects.map(this::toResponse));
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||
? currentUser.departmentId()
|
||||
: requestedDepartmentId;
|
||||
}
|
||||
|
||||
private Specification<Subject> subjectSpecification(String query,
|
||||
Long departmentId,
|
||||
boolean includeArchived) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (!includeArchived) {
|
||||
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("name")), pattern),
|
||||
builder.like(builder.lower(root.get("code")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort subjectSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "code" -> "code";
|
||||
case "departmentId" -> "departmentId";
|
||||
case "status" -> "status";
|
||||
default -> "name";
|
||||
};
|
||||
return "desc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).descending()
|
||||
: Sort.by(property).ascending();
|
||||
}
|
||||
|
||||
private SubjectResponse toResponse(Subject subject) {
|
||||
return new SubjectResponse(subject.getId(), subject.getName(), subject.getCode(), subject.getDepartmentId());
|
||||
}
|
||||
|
||||
@GetMapping("/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ReviewTeacherCreationRequest;
|
||||
import com.magistr.app.dto.TeacherCreationRequestResponse;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
@@ -12,11 +13,16 @@ import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -55,6 +61,63 @@ public class TeacherCreationRequestController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<TeacherCreationRequestResponse> getRequestsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) TeacherCreationRequestStatus status,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "createdAt") String sort,
|
||||
@RequestParam(defaultValue = "desc") String direction) {
|
||||
var requests = teacherCreationRequestRepository.findAll(
|
||||
requestSpecification(query, status, departmentId),
|
||||
PageRequest.of(
|
||||
Math.max(page, 0),
|
||||
Math.max(1, Math.min(size, 100)),
|
||||
requestSort(sort, direction)
|
||||
)
|
||||
);
|
||||
return PageResponse.from(requests.map(this::toResponse));
|
||||
}
|
||||
|
||||
private Specification<TeacherCreationRequest> requestSpecification(String query,
|
||||
TeacherCreationRequestStatus status,
|
||||
Long departmentId) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (status != null) {
|
||||
predicates.add(builder.equal(root.get("status"), status));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("department").get("id"), departmentId));
|
||||
}
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("username")), pattern),
|
||||
builder.like(builder.lower(root.get("fullName")), pattern),
|
||||
builder.like(builder.lower(root.get("jobTitle")), pattern),
|
||||
builder.like(builder.lower(root.get("department").get("departmentName")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort requestSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "fullName" -> "fullName";
|
||||
case "username" -> "username";
|
||||
case "status" -> "status";
|
||||
default -> "createdAt";
|
||||
};
|
||||
return "asc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).ascending()
|
||||
: Sort.by(property).descending();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
@Transactional
|
||||
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
|
||||
|
||||
@@ -16,10 +16,13 @@ import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/teacher-subjects")
|
||||
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||
@@ -53,15 +56,18 @@ public class TeacherSubjectController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TeacherSubjectResponse> getAll() {
|
||||
return teacherSubjectRepository.findAll().stream()
|
||||
public List<TeacherSubjectResponse> getAll(@RequestParam(required = false) List<Long> subjectId) {
|
||||
if (subjectId != null && subjectId.size() > 100) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин");
|
||||
}
|
||||
List<TeacherSubject> assignments = subjectId == null
|
||||
? teacherSubjectRepository.findAll()
|
||||
: subjectId.isEmpty()
|
||||
? List.of()
|
||||
: teacherSubjectRepository.findBySubjectIdInWithDetails(subjectId.stream().distinct().toList());
|
||||
return assignments.stream()
|
||||
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
|
||||
.map(ts -> new TeacherSubjectResponse(
|
||||
ts.getUserId(),
|
||||
ts.getUser().getUsername(),
|
||||
ts.getSubjectId(),
|
||||
ts.getSubject().getName()
|
||||
))
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -136,4 +142,14 @@ public class TeacherSubjectController {
|
||||
)
|
||||
&& departmentId.equals(subject.getDepartmentId());
|
||||
}
|
||||
|
||||
private TeacherSubjectResponse toResponse(TeacherSubject teacherSubject) {
|
||||
return new TeacherSubjectResponse(
|
||||
teacherSubject.getUserId(),
|
||||
teacherSubject.getUser().getUsername(),
|
||||
teacherSubject.getUser().getFullName(),
|
||||
teacherSubject.getSubjectId(),
|
||||
teacherSubject.getSubject().getName()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.magistr.app.dto.CreateUserRequest;
|
||||
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
@@ -22,11 +23,16 @@ import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -80,6 +86,23 @@ public class UserController {
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<UserResponse> getUsersPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Role role,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(defaultValue = "true") boolean includeArchived,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "fullName") String sort,
|
||||
@RequestParam(defaultValue = "asc") String direction) {
|
||||
var users = userRepository.findAll(
|
||||
userSpecification(query, role, departmentId, includeArchived),
|
||||
PageRequest.of(normalizePage(page), normalizeSize(size), userSort(sort, direction))
|
||||
);
|
||||
return PageResponse.from(users.map(this::toUserResponse));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<UserResponse> getTeachers() {
|
||||
@@ -91,6 +114,92 @@ public class UserController {
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/options")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<UserResponse> searchTeacherOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
Long effectiveDepartmentId = currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||
? currentUser.departmentId()
|
||||
: departmentId;
|
||||
LocalDate today = businessTime.today();
|
||||
List<UserResponse> options = new ArrayList<>(userRepository.searchTeacherOptions(
|
||||
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||
Role.TEACHER,
|
||||
LifecycleEntity.STATUS_ARCHIVED,
|
||||
effectiveDepartmentId,
|
||||
today,
|
||||
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("fullName").ascending())
|
||||
).stream()
|
||||
.map(user -> effectiveDepartmentId == null
|
||||
? toUserResponse(user)
|
||||
: toUserResponse(user, effectiveDepartmentId))
|
||||
.toList());
|
||||
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||
userRepository.findById(selectedId)
|
||||
.filter(user -> user.getRole() == Role.TEACHER)
|
||||
.filter(user -> !user.isArchivedRecord())
|
||||
.filter(user -> effectiveDepartmentId == null
|
||||
|| teacherDepartmentService.hasAssignmentAtDate(user.getId(), effectiveDepartmentId, today))
|
||||
.map(user -> effectiveDepartmentId == null
|
||||
? toUserResponse(user)
|
||||
: toUserResponse(user, effectiveDepartmentId))
|
||||
.ifPresent(options::add);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private Specification<User> userSpecification(String query,
|
||||
Role role,
|
||||
Long departmentId,
|
||||
boolean includeArchived) {
|
||||
return (root, criteriaQuery, builder) -> {
|
||||
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||
if (!includeArchived) {
|
||||
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||
}
|
||||
if (role != null) {
|
||||
predicates.add(builder.equal(root.get("role"), role));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedQuery.isEmpty()) {
|
||||
String pattern = "%" + normalizedQuery + "%";
|
||||
predicates.add(builder.or(
|
||||
builder.like(builder.lower(root.get("username")), pattern),
|
||||
builder.like(builder.lower(root.get("fullName")), pattern),
|
||||
builder.like(builder.lower(root.get("jobTitle")), pattern)
|
||||
));
|
||||
}
|
||||
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||
};
|
||||
}
|
||||
|
||||
private Sort userSort(String field, String direction) {
|
||||
String property = switch (field) {
|
||||
case "username" -> "username";
|
||||
case "role" -> "role";
|
||||
case "status" -> "status";
|
||||
default -> "fullName";
|
||||
};
|
||||
return "desc".equalsIgnoreCase(direction)
|
||||
? Sort.by(property).descending()
|
||||
: Sort.by(property).ascending();
|
||||
}
|
||||
|
||||
private int normalizePage(int page) {
|
||||
return Math.max(page, 0);
|
||||
}
|
||||
|
||||
private int normalizeSize(int size) {
|
||||
return Math.max(1, Math.min(size, 100));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||
|
||||
@@ -53,7 +53,7 @@ public class WorkloadController {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarizeTeacherWorkload(lessons, startDate, endDate);
|
||||
return summarizeTeacherWorkload(lessons, startDate, endDate, effectiveDepartmentId);
|
||||
}
|
||||
|
||||
@GetMapping("/classrooms")
|
||||
@@ -98,15 +98,19 @@ public class WorkloadController {
|
||||
|
||||
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
LocalDate endDate,
|
||||
Long scopedDepartmentId) {
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = scopedDepartmentId == null
|
||||
? assignmentsByTeacher(lessons, startDate, endDate)
|
||||
: Map.of();
|
||||
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
|
||||
.filter(lesson -> lesson.teacherId() != null)
|
||||
.collect(Collectors.groupingBy(
|
||||
lesson -> new TeacherDepartmentKey(
|
||||
lesson.teacherId(),
|
||||
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
|
||||
scopedDepartmentId != null
|
||||
? scopedDepartmentId
|
||||
: departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
|
||||
),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
|
||||
27
backend/src/main/java/com/magistr/app/dto/PageResponse.java
Normal file
27
backend/src/main/java/com/magistr/app/dto/PageResponse.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ public class TeacherSubjectResponse {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String fullName;
|
||||
private Long subjectId;
|
||||
private String subjectName;
|
||||
|
||||
public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
|
||||
public TeacherSubjectResponse(Long userId, String username, String fullName, Long subjectId, String subjectName) {
|
||||
this.userId = userId;
|
||||
this.username = username;
|
||||
this.fullName = fullName;
|
||||
this.subjectId = subjectId;
|
||||
this.subjectName = subjectName;
|
||||
}
|
||||
@@ -22,6 +24,10 @@ public class TeacherSubjectResponse {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public Long getSubjectId() {
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long> {
|
||||
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long>, JpaSpecificationExecutor<AcademicCalendar> {
|
||||
|
||||
long countByStudyFormId(Long studyFormId);
|
||||
|
||||
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query(value = """
|
||||
select calendar
|
||||
from AcademicCalendar calendar
|
||||
join fetch calendar.academicYear academicYear
|
||||
join fetch calendar.speciality speciality
|
||||
join fetch calendar.specialtyProfile specialtyProfile
|
||||
join fetch calendar.studyForm studyForm
|
||||
where (:query = ''
|
||||
or lower(calendar.title) like concat('%', :query, '%')
|
||||
or lower(academicYear.title) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityCode) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityName) like concat('%', :query, '%')
|
||||
or lower(specialtyProfile.name) like concat('%', :query, '%')
|
||||
or lower(studyForm.name) like concat('%', :query, '%'))
|
||||
and (:academicYearId is null or academicYear.id = :academicYearId)
|
||||
and (:specialtyId is null or speciality.id = :specialtyId)
|
||||
and (:profileId is null or specialtyProfile.id = :profileId)
|
||||
and (:studyFormId is null or studyForm.id = :studyFormId)
|
||||
""",
|
||||
countQuery = """
|
||||
select count(calendar)
|
||||
from AcademicCalendar calendar
|
||||
join calendar.academicYear academicYear
|
||||
join calendar.speciality speciality
|
||||
join calendar.specialtyProfile specialtyProfile
|
||||
join calendar.studyForm studyForm
|
||||
where (:query = ''
|
||||
or lower(calendar.title) like concat('%', :query, '%')
|
||||
or lower(academicYear.title) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityCode) like concat('%', :query, '%')
|
||||
or lower(speciality.specialityName) like concat('%', :query, '%')
|
||||
or lower(specialtyProfile.name) like concat('%', :query, '%')
|
||||
or lower(studyForm.name) like concat('%', :query, '%'))
|
||||
and (:academicYearId is null or academicYear.id = :academicYearId)
|
||||
and (:specialtyId is null or speciality.id = :specialtyId)
|
||||
and (:profileId is null or specialtyProfile.id = :profileId)
|
||||
and (:studyFormId is null or studyForm.id = :studyFormId)
|
||||
""")
|
||||
Page<AcademicCalendar> searchOptions(@Param("query") String query,
|
||||
@Param("academicYearId") Long academicYearId,
|
||||
@Param("specialtyId") Long specialtyId,
|
||||
@Param("profileId") Long profileId,
|
||||
@Param("studyFormId") Long studyFormId,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,29 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Classroom;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
|
||||
public interface ClassroomRepository extends JpaRepository<Classroom, Long>, JpaSpecificationExecutor<Classroom> {
|
||||
Optional<Classroom> findByName(String name);
|
||||
|
||||
List<Classroom> findByStatusNot(String status);
|
||||
|
||||
@Query("""
|
||||
select classroom
|
||||
from Classroom classroom
|
||||
where classroom.status <> :archivedStatus
|
||||
and (:query = ''
|
||||
or lower(classroom.name) like concat('%', :query, '%')
|
||||
or lower(coalesce(classroom.building, '')) like concat('%', :query, '%'))
|
||||
""")
|
||||
Page<Classroom> searchActiveOptions(@Param("query") String query,
|
||||
@Param("archivedStatus") String archivedStatus,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -10,7 +11,7 @@ import org.springframework.data.repository.query.Param;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long>, JpaSpecificationExecutor<StudentGroup> {
|
||||
|
||||
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -13,6 +15,23 @@ import java.util.Collection;
|
||||
|
||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||
|
||||
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
|
||||
@Query("""
|
||||
select r.id from ScheduleRule r
|
||||
join r.scheduleVersion version
|
||||
join r.subject subject
|
||||
where r.status <> 'ARCHIVED'
|
||||
and version.id = :versionId
|
||||
and (:query = '' or str(r.id) = :query
|
||||
or lower(subject.name) like concat('%', :query, '%')
|
||||
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
|
||||
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
|
||||
order by r.id desc
|
||||
""")
|
||||
Page<Long> searchPageIds(
|
||||
@Param("versionId") Long versionId, @Param("query") String query,
|
||||
Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
where r.id = :id
|
||||
""")
|
||||
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.scheduleVersion
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where r.id in :ids
|
||||
""")
|
||||
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
""")
|
||||
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
|
||||
|
||||
@Query("""
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
join fetch subgroup.studentGroup studentGroup
|
||||
where studentGroup.id in :groupIds
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
order by studentGroup.name, subgroup.name
|
||||
""")
|
||||
List<Subgroup> findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List<Long> groupIds);
|
||||
|
||||
@Query("""
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
|
||||
@@ -2,11 +2,12 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Subject;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||
public interface SubjectRepository extends JpaRepository<Subject, Long>, JpaSpecificationExecutor<Subject> {
|
||||
Optional<Subject> findByNameIgnoreCase(String name);
|
||||
|
||||
List<Subject> findByDepartmentId(Long departmentId);
|
||||
|
||||
@@ -3,10 +3,11 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.TeacherCreationRequest;
|
||||
import com.magistr.app.model.TeacherCreationRequestStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
|
||||
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long>, JpaSpecificationExecutor<TeacherCreationRequest> {
|
||||
|
||||
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject,
|
||||
List<TeacherSubject> findByUserId(Long userId);
|
||||
List<TeacherSubject> findBySubjectId(Long subjectId);
|
||||
|
||||
@Query("""
|
||||
select teacherSubject
|
||||
from TeacherSubject teacherSubject
|
||||
join fetch teacherSubject.user
|
||||
join fetch teacherSubject.subject
|
||||
where teacherSubject.subjectId in :subjectIds
|
||||
order by teacherSubject.subject.name, teacherSubject.user.fullName
|
||||
""")
|
||||
List<TeacherSubject> findBySubjectIdInWithDetails(@Param("subjectIds") List<Long> subjectIds);
|
||||
|
||||
@Query(value = """
|
||||
select subject_link.user_id
|
||||
from teacher_subjects subject_link
|
||||
|
||||
@@ -4,14 +4,18 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
@@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
|
||||
|
||||
@Query(value = """
|
||||
select user
|
||||
from User user
|
||||
where user.role = :role
|
||||
and user.status <> :archivedStatus
|
||||
and (:query = ''
|
||||
or lower(user.username) like concat('%', :query, '%')
|
||||
or lower(user.fullName) like concat('%', :query, '%')
|
||||
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
|
||||
and (:departmentId is null or exists (
|
||||
select assignment.id
|
||||
from TeacherDepartmentAssignment assignment
|
||||
where assignment.teacher.id = user.id
|
||||
and assignment.department.id = :departmentId
|
||||
and assignment.validFrom <= :date
|
||||
and (assignment.validTo is null or assignment.validTo >= :date)
|
||||
))
|
||||
""",
|
||||
countQuery = """
|
||||
select count(user)
|
||||
from User user
|
||||
where user.role = :role
|
||||
and user.status <> :archivedStatus
|
||||
and (:query = ''
|
||||
or lower(user.username) like concat('%', :query, '%')
|
||||
or lower(user.fullName) like concat('%', :query, '%')
|
||||
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
|
||||
and (:departmentId is null or exists (
|
||||
select assignment.id
|
||||
from TeacherDepartmentAssignment assignment
|
||||
where assignment.teacher.id = user.id
|
||||
and assignment.department.id = :departmentId
|
||||
and assignment.validFrom <= :date
|
||||
and (assignment.validTo is null or assignment.validTo >= :date)
|
||||
))
|
||||
""")
|
||||
Page<User> searchTeacherOptions(@Param("query") String query,
|
||||
@Param("role") Role role,
|
||||
@Param("archivedStatus") String archivedStatus,
|
||||
@Param("departmentId") Long departmentId,
|
||||
@Param("date") LocalDate date,
|
||||
Pageable pageable);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select user from User user where user.id = :id")
|
||||
Optional<User> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionHistory;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
@@ -19,13 +20,20 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
|
||||
import java.time.Instant;
|
||||
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.Set;
|
||||
|
||||
@Service
|
||||
public class AcademicPeriodService {
|
||||
|
||||
private static final String YEAR_CONFLICT_MESSAGE =
|
||||
"Название или период учебного года конфликтует с существующим учебным годом";
|
||||
"Учебный год или его период конфликтует с существующим учебным годом";
|
||||
private static final String SEMESTER_CONFLICT_MESSAGE =
|
||||
"Тип или период семестра конфликтует с существующим семестром учебного года";
|
||||
|
||||
@@ -49,48 +57,53 @@ public class AcademicPeriodService {
|
||||
|
||||
@Transactional
|
||||
public AcademicYear createYear(AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
String title = request.title().trim();
|
||||
YearPeriod period = resolveYearPeriod(request);
|
||||
List<SemesterDto> semesterRequests = validateRequestedSemesters(
|
||||
request.semesters(), period.startDate(), period.endDate(), true
|
||||
);
|
||||
String title = academicYearTitle(period.startDate(), period.endDate());
|
||||
if (academicYearRepository.existsByTitle(title)
|
||||
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
|
||||
|| academicYearRepository.existsOverlapping(period.startDate(), period.endDate())) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, title, request.startDate(), request.endDate());
|
||||
applyYear(year, title, period.startDate(), period.endDate());
|
||||
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();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicYear updateYear(Long id, AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
YearPeriod period = resolveYearPeriod(request);
|
||||
AcademicYear year = lockYear(id);
|
||||
String title = request.title().trim();
|
||||
String title = academicYearTitle(period.startDate(), period.endDate());
|
||||
|
||||
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|
||||
|| academicYearRepository.existsOverlappingExcluding(
|
||||
id, request.startDate(), request.endDate())) {
|
||||
id, period.startDate(), period.endDate())) {
|
||||
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);
|
||||
synchronizeSemesters(saved, existingSemesters, effectiveSemesterRequests, requestedSemesters);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
@@ -139,19 +152,15 @@ public class AcademicPeriodService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
private void validateYear(AcademicYearDto request) {
|
||||
private YearPeriod resolveYearPeriod(AcademicYearDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные учебного года обязательны");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название учебного года обязательно");
|
||||
if (request.startDate() == null) {
|
||||
throw new IllegalArgumentException("Дата начала учебного года обязательна");
|
||||
}
|
||||
validateDateRange(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
"Даты начала и окончания учебного года обязательны",
|
||||
"Дата окончания учебного года не может быть раньше даты начала"
|
||||
);
|
||||
LocalDate endDate = LocalDate.of(request.startDate().getYear() + 1, 6, 30);
|
||||
return new YearPeriod(request.startDate(), endDate);
|
||||
}
|
||||
|
||||
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,
|
||||
LocalDate outerEnd,
|
||||
LocalDate innerStart,
|
||||
@@ -226,6 +339,10 @@ public class AcademicPeriodService {
|
||||
year.setEndDate(endDate);
|
||||
}
|
||||
|
||||
private String academicYearTitle(LocalDate startDate, LocalDate endDate) {
|
||||
return startDate.getYear() + "-" + endDate.getYear();
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
@@ -283,4 +400,7 @@ public class AcademicPeriodService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record YearPeriod(LocalDate startDate, LocalDate endDate) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
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.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
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.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@Service
|
||||
public class AcademicStructureService {
|
||||
|
||||
private static final String THEORY_ACTIVITY_CODE = "Т";
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
@@ -48,6 +55,7 @@ public class AcademicStructureService {
|
||||
|
||||
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
@@ -59,6 +67,7 @@ public class AcademicStructureService {
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarPeriodRepository = calendarPeriodRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
@@ -82,6 +91,41 @@ public class AcademicStructureService {
|
||||
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
|
||||
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
|
||||
validateCalendarRequest(request);
|
||||
|
||||
@@ -24,7 +24,7 @@ public class ScheduleGeneratorService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
|
||||
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 final ScheduleRuleRepository scheduleRuleRepository;
|
||||
@@ -328,7 +328,7 @@ public class ScheduleGeneratorService {
|
||||
}
|
||||
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
||||
if (inclusiveDays > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
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> LESSON_FORMATS = Set.of("Очно", "Онлайн");
|
||||
|
||||
@@ -706,8 +708,8 @@ public class ScheduleOverrideService {
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
||||
}
|
||||
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) {
|
||||
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней");
|
||||
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 200 дней");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.magistr.app.service;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -17,32 +18,45 @@ import java.util.stream.Collectors;
|
||||
public class ScheduleQueryService {
|
||||
|
||||
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 GroupRepository groupRepository;
|
||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
private final AcademicDateService academicDateService;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
|
||||
@Autowired
|
||||
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicDateService academicDateService) {
|
||||
AcademicDateService academicDateService,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupRepository = groupRepository;
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
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,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
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);
|
||||
generatedLessons = new ArrayList<>(generatedLessons);
|
||||
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 -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
|
||||
.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 -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId))
|
||||
.filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId))
|
||||
@@ -204,6 +227,29 @@ public class ScheduleQueryService {
|
||||
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(
|
||||
List<RenderedLessonDto> generatedLessons,
|
||||
Collection<ScheduleOverride> overrideSnapshot,
|
||||
@@ -380,7 +426,7 @@ public class ScheduleQueryService {
|
||||
}
|
||||
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
||||
if (inclusiveDays > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,12 +441,10 @@ public class ScheduleQueryService {
|
||||
.map(List::of)
|
||||
.orElse(List.of());
|
||||
}
|
||||
List<StudentGroup> groups;
|
||||
if (departmentId != null) {
|
||||
groups = groupRepository.findByDepartmentId(departmentId);
|
||||
} else {
|
||||
groups = groupRepository.findAll();
|
||||
}
|
||||
// Кафедральный scope определяется по преподавателю на дату занятия,
|
||||
// а не по выпускающей кафедре учебной группы. Фильтрация выполняется
|
||||
// после генерации и применения точечных изменений.
|
||||
List<StudentGroup> groups = groupRepository.findAll();
|
||||
groups = groups.stream()
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
.toList();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.model.Classroom;
|
||||
@@ -31,6 +32,7 @@ import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ScheduleRuleDto> getPage(Long versionId, String query, int page, int size) {
|
||||
var ids = scheduleRuleRepository.searchPageIds(versionId,
|
||||
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100))));
|
||||
if (ids.isEmpty()) {
|
||||
return new PageResponse<>(List.of(), ids.getNumber(), ids.getSize(), ids.getTotalElements(),
|
||||
ids.getTotalPages(), ids.isFirst(), ids.isLast());
|
||||
}
|
||||
var details = scheduleRuleRepository.findPageWithDetails(ids.getContent()).stream()
|
||||
.collect(java.util.stream.Collectors.toMap(ScheduleRule::getId, this::toDto));
|
||||
return PageResponse.from(ids.map(details::get));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||
return getAll(semesterId, groupId, null);
|
||||
|
||||
@@ -136,6 +136,23 @@ public class TeacherDepartmentService {
|
||||
.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
|
||||
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
|
||||
DepartmentTransferRequest request,
|
||||
|
||||
@@ -19,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO departments (name, code) VALUES
|
||||
('Кафедра ИБ', 1),
|
||||
('Кафедра ВТ', 2),
|
||||
('Кафедра КТ', 3);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS specialties (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
@@ -36,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO specialties (name, specialty_code) VALUES
|
||||
('Информационная безопасность', '10.03.01'),
|
||||
('Информатика и вычислительная техника', '09.03.01'),
|
||||
('Программная инженерия', '09.03.04')
|
||||
ON CONFLICT (specialty_code) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
|
||||
@@ -57,11 +46,6 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||||
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
||||
);
|
||||
|
||||
INSERT INTO specialty_profiles (specialty_id, name)
|
||||
SELECT id, 'Без профиля'
|
||||
FROM specialties
|
||||
ON CONFLICT (specialty_id, name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
-- Пользователи и роли
|
||||
-- ==========================================
|
||||
@@ -83,15 +67,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
||||
INSERT INTO users (username, password, role, full_name, job_title, department_id)
|
||||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
|
||||
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
|
||||
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
|
||||
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
|
||||
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -189,16 +164,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
|
||||
ON teacher_department_assignments(teacher_id, department_id)
|
||||
WHERE valid_to IS NULL;
|
||||
|
||||
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
|
||||
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
|
||||
FROM users u
|
||||
WHERE u.role = 'TEACHER'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM teacher_department_assignments existing
|
||||
WHERE existing.teacher_id = u.id
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
@@ -243,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO education_forms (name) VALUES
|
||||
('Бакалавриат'),
|
||||
('Магистратура'),
|
||||
('Специалитет')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
-- Учебные группы
|
||||
-- ==========================================
|
||||
@@ -272,19 +231,6 @@ CREATE TABLE IF NOT EXISTS student_groups (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
-- Тестовая базовая группа для работы
|
||||
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
|
||||
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
|
||||
FROM (VALUES
|
||||
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
|
||||
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
|
||||
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
|
||||
JOIN education_forms ef ON ef.name = data.education_form_name
|
||||
JOIN departments dept ON dept.name = data.department_name
|
||||
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
|
||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
|
||||
|
||||
-- ==========================================
|
||||
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
||||
-- ==========================================
|
||||
@@ -373,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_subgroup_student_capacity();
|
||||
|
||||
INSERT INTO subgroups (group_id, name, student_capacity)
|
||||
SELECT
|
||||
sg.id,
|
||||
data.name,
|
||||
CASE
|
||||
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
|
||||
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
|
||||
END
|
||||
FROM student_groups sg
|
||||
CROSS JOIN (VALUES
|
||||
('Подгруппа 1', 1),
|
||||
('Подгруппа 2', 2)
|
||||
) AS data(name, part_number)
|
||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
||||
ON CONFLICT (group_id, name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
-- Справочники
|
||||
-- ==========================================
|
||||
@@ -414,14 +344,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
|
||||
COMMENT ON INDEX uq_subjects_name_ci IS
|
||||
'Глобально уникальное название дисциплины без учёта регистра';
|
||||
|
||||
INSERT INTO subjects (name, department_id) VALUES
|
||||
('Высшая математика', 1),
|
||||
('Философия', 1),
|
||||
('Информатика', 1),
|
||||
('Базы данных', 1),
|
||||
('Английский язык', 1)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subject_comments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||
@@ -441,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
|
||||
duration_minutes INT DEFAULT 90
|
||||
);
|
||||
|
||||
INSERT INTO lesson_types (name, color_code) VALUES
|
||||
('Лекция', '#FF6B6B'),
|
||||
('Практика', '#4ECDC4'),
|
||||
('Лабораторная работа', '#45B7D1')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Оборудование
|
||||
CREATE TABLE IF NOT EXISTS equipments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -461,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO equipments (name) VALUES
|
||||
('Проектор'),
|
||||
('ПК'),
|
||||
('Лаборатория'),
|
||||
('Интерактивная доска'),
|
||||
('Документ-камера'),
|
||||
('Аудиосистема')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Аудитории
|
||||
CREATE TABLE IF NOT EXISTS classrooms (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -488,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
||||
('101 Ленинская', 120, 'Главный корпус', 1),
|
||||
('202 IT Lab', 20, 'Корпус IT', 2),
|
||||
('303 Обычная', 30, 'Главный корпус', 3)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Привязка оборудования к аудиториям (Many-to-Many)
|
||||
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||
@@ -503,20 +404,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||
PRIMARY KEY (classroom_id, equipment_id)
|
||||
);
|
||||
|
||||
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
||||
SELECT c.id, e.id,
|
||||
CASE
|
||||
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
||||
WHEN e.name = 'ПК' THEN 1
|
||||
ELSE 1
|
||||
END
|
||||
FROM classrooms c, equipments e
|
||||
WHERE
|
||||
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
||||
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
||||
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
||||
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
||||
@@ -575,11 +462,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
|
||||
ON time_slot_scopes(day_of_week)
|
||||
WHERE apply_mode = 'WEEKDAY';
|
||||
|
||||
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
|
||||
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
|
||||
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_slots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
||||
@@ -597,21 +479,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
||||
|
||||
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
|
||||
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
|
||||
FROM (VALUES
|
||||
(1, '08:00', '09:30', 90),
|
||||
(2, '09:40', '11:10', 90),
|
||||
(3, '11:40', '13:10', 90),
|
||||
(4, '13:20', '14:50', 90),
|
||||
(5, '15:00', '16:30', 90),
|
||||
(6, '16:50', '18:20', 90),
|
||||
(7, '18:30', '20:00', 90)
|
||||
) AS data(order_number, start_time, end_time, duration_minutes)
|
||||
CROSS JOIN time_slot_scopes scope
|
||||
WHERE scope.code IN ('default', 'saturday')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
assignment_date DATE NOT NULL UNIQUE,
|
||||
@@ -629,11 +496,6 @@ CREATE TABLE IF NOT EXISTS academic_years (
|
||||
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
||||
);
|
||||
|
||||
INSERT INTO academic_years (title, start_date, end_date) VALUES
|
||||
('2024-2025', '2024-09-01', '2025-06-30'),
|
||||
('2025-2026', '2025-09-01', '2026-06-30')
|
||||
ON CONFLICT (title) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semesters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||||
@@ -645,16 +507,6 @@ CREATE TABLE IF NOT EXISTS semesters (
|
||||
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
||||
);
|
||||
|
||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||||
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
|
||||
FROM academic_years
|
||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||||
|
||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||||
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
|
||||
FROM academic_years
|
||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
||||
@@ -667,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
||||
description TEXT
|
||||
);
|
||||
|
||||
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
|
||||
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
|
||||
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
|
||||
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
|
||||
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
|
||||
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
|
||||
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
|
||||
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
|
||||
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
|
||||
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
|
||||
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
|
||||
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
|
||||
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
|
||||
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendars (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
@@ -700,21 +536,6 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
|
||||
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
|
||||
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
|
||||
|
||||
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
|
||||
SELECT
|
||||
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
|
||||
ay.id,
|
||||
specialty.id,
|
||||
profile.id,
|
||||
study_form.id,
|
||||
4
|
||||
FROM academic_years ay
|
||||
CROSS JOIN education_forms study_form
|
||||
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
|
||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
||||
WHERE ay.title = '2025-2026'
|
||||
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||
@@ -754,24 +575,6 @@ AS $$
|
||||
) + 1
|
||||
$$;
|
||||
|
||||
INSERT INTO academic_calendar_periods (
|
||||
calendar_id,
|
||||
course_number,
|
||||
start_date,
|
||||
end_date,
|
||||
activity_type_id
|
||||
)
|
||||
SELECT
|
||||
calendar.id,
|
||||
course.course_number,
|
||||
ay.start_date,
|
||||
ay.end_date,
|
||||
activity.id
|
||||
FROM academic_calendars calendar
|
||||
JOIN academic_years ay ON ay.id = calendar.academic_year_id
|
||||
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
|
||||
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||
@@ -800,21 +603,6 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
|
||||
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
|
||||
ON student_group_calendar_assignments(group_id, academic_year_id);
|
||||
|
||||
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
|
||||
SELECT
|
||||
sg.id,
|
||||
ay.id,
|
||||
calendar.id
|
||||
FROM student_groups sg
|
||||
JOIN academic_years ay ON ay.title = '2025-2026'
|
||||
JOIN academic_calendars calendar
|
||||
ON calendar.academic_year_id = ay.id
|
||||
AND calendar.specialty_id = sg.specialty_id
|
||||
AND calendar.specialty_profile_id = sg.specialty_profile_id
|
||||
AND calendar.study_form_id = sg.education_form_id
|
||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
||||
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||
@@ -991,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
||||
|
||||
CREATE TEMP TABLE tmp_default_rules AS
|
||||
SELECT
|
||||
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
|
||||
rule_key,
|
||||
subject_id,
|
||||
semester_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
FROM (
|
||||
SELECT
|
||||
data.rule_key,
|
||||
subj.id AS subject_id,
|
||||
sem.id AS semester_id,
|
||||
data.lecture_academic_hours,
|
||||
data.laboratory_academic_hours,
|
||||
data.practice_academic_hours,
|
||||
data.lecture_start_week,
|
||||
data.laboratory_start_week,
|
||||
data.practice_start_week
|
||||
FROM (VALUES
|
||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
|
||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
|
||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
|
||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
|
||||
) AS data(
|
||||
rule_key,
|
||||
subject_name,
|
||||
year_title,
|
||||
semester_type,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
)
|
||||
JOIN subjects subj ON subj.name = data.subject_name
|
||||
JOIN academic_years ay ON ay.title = data.year_title
|
||||
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
||||
) prepared_rules;
|
||||
|
||||
INSERT INTO schedule_rules (
|
||||
id,
|
||||
subject_id,
|
||||
semester_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
)
|
||||
SELECT
|
||||
schedule_rule_id,
|
||||
subject_id,
|
||||
semester_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
FROM tmp_default_rules;
|
||||
|
||||
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
||||
SELECT
|
||||
rules.schedule_rule_id,
|
||||
sg.id
|
||||
FROM (VALUES
|
||||
('stream_math', 'ИВТ-21-1'),
|
||||
('stream_math', 'ИБ-41м'),
|
||||
('ivt_informatics_lab', 'ИВТ-21-1'),
|
||||
('ivt_databases_practice', 'ИВТ-21-1'),
|
||||
('ib_philosophy', 'ИБ-41м')
|
||||
) AS data(rule_key, group_name)
|
||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||||
JOIN student_groups sg ON sg.name = data.group_name
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO schedule_rule_slots (
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
)
|
||||
SELECT
|
||||
rules.schedule_rule_id,
|
||||
data.day_of_week,
|
||||
data.parity,
|
||||
ts.id,
|
||||
teacher.id,
|
||||
classroom.id,
|
||||
lt.id,
|
||||
data.lesson_format
|
||||
FROM (VALUES
|
||||
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
|
||||
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
|
||||
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
|
||||
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
||||
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||||
JOIN time_slot_scopes scope ON scope.code = 'default'
|
||||
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
|
||||
JOIN users teacher ON teacher.username = data.teacher_username
|
||||
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
||||
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
||||
|
||||
-- ==========================================
|
||||
-- Функция обновления timestamp
|
||||
-- ==========================================
|
||||
@@ -1371,7 +1044,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
cancel_violations,
|
||||
move_violations,
|
||||
replace_violations,
|
||||
@@ -1470,7 +1143,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
lecture_violations,
|
||||
laboratory_violations,
|
||||
practice_violations
|
||||
@@ -1511,7 +1184,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23505',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
duplicate_slot_groups
|
||||
);
|
||||
END IF;
|
||||
@@ -1577,7 +1250,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
duration_violations,
|
||||
overlap_violations,
|
||||
scope_violations
|
||||
@@ -1733,7 +1406,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
academic_year_overlap_violations,
|
||||
semester_bounds_violations,
|
||||
semester_overlap_violations
|
||||
@@ -1864,7 +1537,7 @@ BEGIN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||
assignment_violations,
|
||||
calendar_period_violations,
|
||||
calendar_subject_violations
|
||||
@@ -2430,33 +2103,9 @@ CREATE UNIQUE INDEX uq_schedule_versions_published_semester
|
||||
CREATE INDEX idx_schedule_versions_semester_history
|
||||
ON schedule_versions(semester_id, version_number DESC);
|
||||
|
||||
INSERT INTO schedule_versions (
|
||||
semester_id,
|
||||
version_number,
|
||||
name,
|
||||
status,
|
||||
created_at,
|
||||
published_at
|
||||
)
|
||||
SELECT
|
||||
semester.id,
|
||||
1,
|
||||
'Опубликованная версия 1',
|
||||
'PUBLISHED',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM semesters semester;
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ADD COLUMN schedule_version_id BIGINT;
|
||||
|
||||
UPDATE schedule_rules rule
|
||||
SET schedule_version_id = version.id,
|
||||
version_group_id = COALESCE(rule.version_group_id, rule.id)
|
||||
FROM schedule_versions version
|
||||
WHERE version.semester_id = rule.semester_id
|
||||
AND version.status = 'PUBLISHED';
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ALTER COLUMN schedule_version_id SET NOT NULL,
|
||||
ADD CONSTRAINT fk_schedule_rules_version
|
||||
@@ -2487,14 +2136,6 @@ CREATE TABLE schedule_version_history (
|
||||
CREATE INDEX idx_schedule_version_history_version
|
||||
ON schedule_version_history(version_id, created_at DESC, id DESC);
|
||||
|
||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
||||
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
|
||||
FROM schedule_versions;
|
||||
|
||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
||||
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
|
||||
FROM schedule_versions;
|
||||
|
||||
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
|
||||
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
|
||||
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
|
||||
|
||||
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,6 @@ import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
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.service.AcademicCalendarGridService;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
@@ -24,22 +20,41 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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.when;
|
||||
|
||||
class AcademicCalendarControllerTest {
|
||||
|
||||
@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);
|
||||
AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class);
|
||||
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
|
||||
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
|
||||
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
|
||||
AcademicStructureService academicStructureService = mock(AcademicStructureService.class);
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
year.setId(1L);
|
||||
@@ -55,26 +70,6 @@ class AcademicCalendarControllerTest {
|
||||
studyForm.setId(4L);
|
||||
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(
|
||||
null,
|
||||
null,
|
||||
@@ -91,13 +86,31 @@ class AcademicCalendarControllerTest {
|
||||
null,
|
||||
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);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class);
|
||||
assertThat(((AcademicCalendarDto) response.getBody()).title())
|
||||
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027");
|
||||
.isEqualTo(saved.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -134,10 +147,6 @@ class AcademicCalendarControllerTest {
|
||||
AcademicCalendarController controller = new AcademicCalendarController(
|
||||
calendarRepository,
|
||||
periodRepository,
|
||||
mock(AcademicYearRepository.class),
|
||||
mock(SpecialtiesRepository.class),
|
||||
mock(SpecialtyProfileRepository.class),
|
||||
mock(EducationFormRepository.class),
|
||||
mock(SubjectRepository.class),
|
||||
mock(AcademicCalendarSubjectRepository.class),
|
||||
mock(AcademicCalendarGridService.class),
|
||||
|
||||
@@ -33,6 +33,20 @@ class ScheduleRuleAdminControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.DisplayName("Страница правил принимает фильтры и возвращает метаданные пагинации")
|
||||
void returnsRulesPage() throws Exception {
|
||||
when(service.getPage(7L, "Математика", 2, 25)).thenReturn(
|
||||
new com.magistr.app.dto.PageResponse<>(List.of(), 2, 25, 50, 2, false, true));
|
||||
mockMvc.perform(get("/api/admin/schedule-rules/page")
|
||||
.param("versionId", "7").param("query", "Математика").param("page", "2"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items").isArray())
|
||||
.andExpect(jsonPath("$.page").value(2))
|
||||
.andExpect(jsonPath("$.size").value(25))
|
||||
.andExpect(jsonPath("$.totalItems").value(50));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsBadRequestWithRussianValidationMessage() throws Exception {
|
||||
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -74,6 +75,52 @@ class TeacherSubjectControllerTest {
|
||||
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsAssignmentsOnlyForRequestedSubjects() {
|
||||
TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class);
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
SubjectRepository subjectRepository = mock(SubjectRepository.class);
|
||||
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||
User teacher = teacher();
|
||||
Subject subject = new Subject();
|
||||
subject.setId(20L);
|
||||
subject.setName("Высшая математика");
|
||||
TeacherSubject assignment = new TeacherSubject(teacher.getId(), subject.getId());
|
||||
setTeacherSubjectDetails(assignment, teacher, subject);
|
||||
when(teacherSubjectRepository.findBySubjectIdInWithDetails(List.of(subject.getId())))
|
||||
.thenReturn(List.of(assignment));
|
||||
TeacherSubjectController controller = new TeacherSubjectController(
|
||||
teacherSubjectRepository,
|
||||
userRepository,
|
||||
subjectRepository,
|
||||
teacherDepartmentService,
|
||||
BUSINESS_TIME
|
||||
);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
|
||||
var result = controller.getAll(List.of(subject.getId()));
|
||||
|
||||
assertThat(result).singleElement().satisfies(response -> {
|
||||
assertThat(response.getSubjectId()).isEqualTo(subject.getId());
|
||||
assertThat(response.getUserId()).isEqualTo(teacher.getId());
|
||||
assertThat(response.getFullName()).isEqualTo(teacher.getFullName());
|
||||
});
|
||||
verify(teacherSubjectRepository).findBySubjectIdInWithDetails(List.of(subject.getId()));
|
||||
}
|
||||
|
||||
private void setTeacherSubjectDetails(TeacherSubject assignment, User teacher, Subject subject) {
|
||||
try {
|
||||
var userField = TeacherSubject.class.getDeclaredField("user");
|
||||
userField.setAccessible(true);
|
||||
userField.set(assignment, teacher);
|
||||
var subjectField = TeacherSubject.class.getDeclaredField("subject");
|
||||
subjectField.setAccessible(true);
|
||||
subjectField.set(assignment, subject);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new AssertionError("Не удалось подготовить тестовую привязку", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private User teacher() {
|
||||
User teacher = new User();
|
||||
teacher.setId(10L);
|
||||
|
||||
@@ -82,6 +82,36 @@ class WorkloadControllerTest {
|
||||
.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
|
||||
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -30,6 +31,7 @@ class AcademicCalendarWeekNumberBaselineIntegrationTest {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class AcademicStructureInvariantIntegrationTest {
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current()).isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
long groupId = queryLong(
|
||||
@@ -81,6 +81,7 @@ class AcademicStructureInvariantIntegrationTest {
|
||||
"23514",
|
||||
"сохранённые назначения календарных графиков станут несовместимыми"
|
||||
);
|
||||
deleteOtherCalendarAssignments(connection, calendarId, assignmentId);
|
||||
assertSqlState(
|
||||
() -> updateInt(
|
||||
connection,
|
||||
@@ -386,7 +387,7 @@ class AcademicStructureInvariantIntegrationTest {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
@@ -541,6 +542,20 @@ class AcademicStructureInvariantIntegrationTest {
|
||||
""");
|
||||
}
|
||||
|
||||
private void deleteOtherCalendarAssignments(Connection connection,
|
||||
long calendarId,
|
||||
long preservedAssignmentId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
DELETE FROM student_group_calendar_assignments
|
||||
WHERE calendar_id = ?
|
||||
AND id <> ?
|
||||
""")) {
|
||||
statement.setLong(1, calendarId);
|
||||
statement.setLong(2, preservedAssignmentId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, value);
|
||||
|
||||
@@ -37,8 +37,8 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
.as("После полной миграции должна существовать текущая версия")
|
||||
.isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна быть V1")
|
||||
.isEqualTo("1");
|
||||
.as("Тестовые данные должны быть загружены миграцией V2")
|
||||
.isEqualTo("2");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
@@ -149,7 +149,7 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
.as("После полной миграции должна существовать текущая версия")
|
||||
.isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion())
|
||||
.as("Последней успешно применённой миграцией должна быть V1")
|
||||
.isEqualTo("1");
|
||||
.as("Тестовые данные должны быть загружены миграцией V2")
|
||||
.isEqualTo("2");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
@@ -82,7 +82,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ class SubjectOwnershipInvariantIntegrationTest {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
flyway.clean();
|
||||
|
||||
@@ -180,7 +180,7 @@ class TeacherDepartmentInvariantIntegrationTest {
|
||||
return Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ class TeacherPreferencesMigrationIntegrationTest {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.target(MigrationVersion.fromVersion("2"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(connection, """
|
||||
SELECT count(*)
|
||||
|
||||
@@ -41,7 +41,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current()).isNotNull();
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
long manualScopeId = insertManualScope(connection, "migration_checks");
|
||||
@@ -171,7 +171,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
|
||||
}
|
||||
|
||||
private Flyway baselineFlyway() {
|
||||
return flyway("1");
|
||||
return flyway("2");
|
||||
}
|
||||
|
||||
private Flyway flyway(String targetVersion) {
|
||||
|
||||
@@ -73,18 +73,38 @@ class AcademicPeriodServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsNonOverlappingYearWithTrimmedTitle() {
|
||||
void createsYearWithAutomaticTitleEndDateAndSemesters() {
|
||||
AcademicYear saved = service.createYear(yearRequest(
|
||||
" 2026-2027 ",
|
||||
"Произвольное название",
|
||||
YEAR_START,
|
||||
YEAR_END
|
||||
YEAR_END.minusMonths(1)
|
||||
));
|
||||
|
||||
assertThat(saved.getTitle()).isEqualTo("2026-2027");
|
||||
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();
|
||||
}
|
||||
|
||||
@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
|
||||
void rejectsDuplicateTitleOrOverlappingYear() {
|
||||
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
|
||||
@@ -95,7 +115,7 @@ class AcademicPeriodServiceTest {
|
||||
YEAR_END
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом");
|
||||
.hasMessage("Учебный год или его период конфликтует с существующим учебным годом");
|
||||
|
||||
verify(academicYearRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
@@ -112,17 +132,49 @@ class AcademicPeriodServiceTest {
|
||||
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
|
||||
.thenReturn(List.of(autumn));
|
||||
|
||||
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest(
|
||||
"2026-2027",
|
||||
YEAR_START.plusDays(1),
|
||||
YEAR_END
|
||||
)))
|
||||
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequestWithoutSemesters(YEAR_START.plusDays(1))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Новые границы учебного года не включают все его семестры");
|
||||
.hasMessage("Семестр должен полностью находиться в границах учебного года");
|
||||
|
||||
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
|
||||
void rejectsSemesterOutsideAcademicYear() {
|
||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||
@@ -218,19 +270,33 @@ class AcademicPeriodServiceTest {
|
||||
YEAR_END
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом")
|
||||
.hasMessage("Учебный год или его период конфликтует с существующим учебным годом")
|
||||
.hasNoCause();
|
||||
}
|
||||
|
||||
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,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
return semesterRequest(null, semesterType, startDate, endDate);
|
||||
}
|
||||
|
||||
private SemesterDto semesterRequest(Long id,
|
||||
SemesterType semesterType,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
return new SemesterDto(
|
||||
null,
|
||||
id,
|
||||
YEAR_ID,
|
||||
"2026-2027",
|
||||
semesterType,
|
||||
|
||||
@@ -4,12 +4,15 @@ import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
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.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
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.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
@@ -56,6 +60,8 @@ class AcademicStructureServiceTest {
|
||||
@Mock
|
||||
private AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
@Mock
|
||||
private AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
@Mock
|
||||
private AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
@Mock
|
||||
private AcademicYearRepository academicYearRepository;
|
||||
@@ -88,6 +94,7 @@ class AcademicStructureServiceTest {
|
||||
service = new AcademicStructureService(
|
||||
calendarRepository,
|
||||
calendarPeriodRepository,
|
||||
activityTypeRepository,
|
||||
calendarSubjectRepository,
|
||||
academicYearRepository,
|
||||
specialtiesRepository,
|
||||
@@ -118,12 +125,54 @@ class AcademicStructureServiceTest {
|
||||
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L);
|
||||
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
|
||||
.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)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
|
||||
.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
|
||||
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
|
||||
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))
|
||||
|
||||
@@ -145,8 +145,8 @@ class ScheduleGenerationQueryCountTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Генератор отклоняет 121 включительную дату")
|
||||
void rejects121InclusiveDates() {
|
||||
@DisplayName("Генератор отклоняет 201 включительную дату")
|
||||
void rejects201InclusiveDates() {
|
||||
ScheduleGeneratorService generator = new ScheduleGeneratorService(
|
||||
mock(ScheduleRuleRepository.class),
|
||||
mock(GroupRepository.class),
|
||||
@@ -161,10 +161,10 @@ class ScheduleGenerationQueryCountTest {
|
||||
assertThatThrownBy(() -> generator.buildScheduleForGroups(
|
||||
List.of(),
|
||||
startDate,
|
||||
startDate.plusDays(120)
|
||||
startDate.plusDays(200)
|
||||
))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Диапазон расписания не может превышать 120 дней");
|
||||
.hasMessage("Диапазон расписания не может превышать 200 дней");
|
||||
}
|
||||
|
||||
private AcademicYear academicYear() {
|
||||
|
||||
@@ -181,6 +181,15 @@ class ScheduleOverrideServiceTest {
|
||||
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
|
||||
void availabilityContainsOnlyStudyDatesOfSourceSemester() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
@@ -9,6 +11,8 @@ import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -86,8 +90,8 @@ class ScheduleQueryServiceTest {
|
||||
groupLifecycleService
|
||||
);
|
||||
LocalDate startDate = LocalDate.of(2026, 1, 1);
|
||||
LocalDate allowedEndDate = startDate.plusDays(119);
|
||||
LocalDate rejectedEndDate = startDate.plusDays(120);
|
||||
LocalDate allowedEndDate = startDate.plusDays(199);
|
||||
LocalDate rejectedEndDate = startDate.plusDays(200);
|
||||
when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate))
|
||||
.thenReturn(List.of());
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate))
|
||||
@@ -101,7 +105,7 @@ class ScheduleQueryServiceTest {
|
||||
null, null, null, null,
|
||||
startDate, rejectedEndDate))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Диапазон расписания не может превышать 120 дней");
|
||||
.hasMessage("Диапазон расписания не может превышать 200 дней");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@@ -138,6 +142,63 @@ class ScheduleQueryServiceTest {
|
||||
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) {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId((long) id);
|
||||
|
||||
@@ -47,6 +47,7 @@ import static org.mockito.Mockito.verify;
|
||||
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
||||
properties = {
|
||||
"spring.jpa.open-in-view=false",
|
||||
"spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true",
|
||||
"management.endpoint.health.validate-group-membership=false"
|
||||
}
|
||||
)
|
||||
@@ -86,6 +87,48 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.DisplayName("Страницы правил не пересекаются, поиск работает вне первой страницы")
|
||||
void pagesRulesAndSearchesAcrossWholeVersion() {
|
||||
Long versionId = jdbcTemplate.queryForObject("""
|
||||
SELECT schedule_version_id FROM schedule_rules
|
||||
WHERE status <> 'ARCHIVED' GROUP BY schedule_version_id
|
||||
ORDER BY COUNT(*) DESC LIMIT 1
|
||||
""", Long.class);
|
||||
var first = service.getPage(versionId, "", 0, 25);
|
||||
var second = service.getPage(versionId, "", 1, 25);
|
||||
assertThat(first.items()).hasSize(25);
|
||||
assertThat(second.items()).hasSize(25);
|
||||
assertThat(first.totalItems()).isGreaterThan(50);
|
||||
assertThat(first.items()).extracting(ScheduleRuleDto::id)
|
||||
.doesNotContainAnyElementsOf(second.items().stream().map(ScheduleRuleDto::id).toList());
|
||||
var rule = second.items().get(0);
|
||||
assertThat(rule.slots()).isNotEmpty();
|
||||
assertThat(service.getPage(versionId, rule.id().toString(), 0, 25).items())
|
||||
.extracting(ScheduleRuleDto::id).contains(rule.id());
|
||||
assertThat(service.getPage(versionId, rule.subjectName().toUpperCase(java.util.Locale.ROOT), 0, 25).items())
|
||||
.isNotEmpty().allMatch(item -> item.subjectName().equals(rule.subjectName()));
|
||||
assertThat(service.getPage(versionId, rule.groupNames().get(0), 0, 25).items())
|
||||
.isNotEmpty().allMatch(item -> item.groupNames().contains(rule.groupNames().get(0)));
|
||||
assertThat(service.getPage(versionId, "НетТакойДисциплины", 0, 25).totalItems()).isZero();
|
||||
assertThat(service.getPage(versionId, "", 100000, 25).items()).isEmpty();
|
||||
assertThat(service.getPage(versionId, "", -1, 1000).size()).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private com.magistr.app.repository.AcademicCalendarRepository calendarRepository;
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.DisplayName("Календарные графики ищутся по году с ограничением страницы в БД")
|
||||
void pagesCalendarsForFutureYears() {
|
||||
var page = calendarRepository.searchOptions("2027-2028", null, null, null, null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 25,
|
||||
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
|
||||
.and(org.springframework.data.domain.Sort.by("title", "id"))));
|
||||
assertThat(page.getTotalElements()).isEqualTo(44);
|
||||
assertThat(page.getContent()).hasSize(25).allMatch(calendar -> calendar.getAcademicYear().getTitle().equals("2027-2028"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
|
||||
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
||||
|
||||
@@ -173,6 +173,28 @@ class TeacherDepartmentServiceTest {
|
||||
)).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) {
|
||||
when(assignmentRepository.saveAndFlush(any(TeacherDepartmentAssignment.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
@@ -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` установлено только для `Т`.
|
||||
|
||||
## Правило генерации расписания
|
||||
|
||||
|
||||
134
docs/API.md
134
docs/API.md
@@ -230,10 +230,39 @@ Refresh-токен ротируется при каждом успешном о
|
||||
|
||||
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
|
||||
|
||||
### `GET /api/users/page`
|
||||
|
||||
Пагинированный реестр пользователей для администратора. Параметры: `query`, `role`,
|
||||
`departmentId`, `includeArchived`, `page`, `size`, `sort`, `direction`. Размер страницы
|
||||
ограничен диапазоном `1..100`; поддерживаемые поля сортировки — `fullName`, `username`,
|
||||
`role`, `status`.
|
||||
|
||||
Пагинированные endpoints используют общий ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"page": 0,
|
||||
"size": 25,
|
||||
"totalItems": 0,
|
||||
"totalPages": 0,
|
||||
"first": true,
|
||||
"last": true
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/users/teachers`
|
||||
|
||||
Список только преподавателей (роль `TEACHER`).
|
||||
|
||||
### `GET /api/users/teachers/options`
|
||||
|
||||
Серверный поиск преподавателей для асинхронных списков. Параметры: `query`,
|
||||
`departmentId`, `selectedId`, `limit` (`1..50`). Поиск выполняется по логину, ФИО и
|
||||
должности; архивные записи исключаются. Для роли `DEPARTMENT` backend принудительно
|
||||
использует кафедру текущего пользователя и учитывает действующие основные и дополнительные
|
||||
назначения из `teacher_department_assignments`. `selectedId` не обходит это ограничение.
|
||||
|
||||
### `GET /api/users/teachers/{departmentId}`
|
||||
|
||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
||||
@@ -309,6 +338,7 @@ Refresh-токен ротируется при каждом успешном о
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
|
||||
| `GET` | `/api/teacher-requests/page?query=&status=PENDING&page=0&size=25` | Пагинированный поиск заявок по логину, ФИО, должности и кафедре |
|
||||
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
|
||||
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
|
||||
|
||||
@@ -365,10 +395,9 @@ Refresh-токен ротируется при каждом успешном о
|
||||
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
||||
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
||||
|
||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120
|
||||
календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля
|
||||
невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и
|
||||
отклоняется. Если у группы нет назначения календарного графика на учебный год даты,
|
||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 200
|
||||
календарных дат с учётом обеих границ: 200 дат разрешены, 201 дата отклоняется. Если у
|
||||
группы нет назначения календарного графика на учебный год даты,
|
||||
расписание для неё возвращается пустым списком. Время пары берётся из базового слота
|
||||
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
|
||||
`/api/admin/time-slots`.
|
||||
@@ -508,12 +537,30 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
**Тело учебного года:**
|
||||
```json
|
||||
{
|
||||
"title": "2026-2027",
|
||||
"startDate": "2026-09-01",
|
||||
"endDate": "2027-06-30"
|
||||
"semesters": [
|
||||
{
|
||||
"semesterType": "autumn",
|
||||
"startDate": "2026-09-01",
|
||||
"endDate": "2027-01-31"
|
||||
},
|
||||
{
|
||||
"semesterType": "spring",
|
||||
"startDate": "2027-02-01",
|
||||
"endDate": "2027-06-30"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Название и дата окончания учебного года вычисляются backend автоматически по дате начала:
|
||||
для `2026-09-01` будут сохранены название `2026-2027` и окончание `2027-06-30`.
|
||||
В административной форме пользователь вводит только год `2026`; frontend преобразует его
|
||||
в `startDate: 2026-09-01` и сразу показывает вычисленный год окончания `2027`.
|
||||
При создании требуется хотя бы один семестр; учебный год и все переданные семестры
|
||||
сохраняются одной транзакцией. Поля `title` и `endDate`, переданные старым клиентом,
|
||||
не влияют на вычисленные значения.
|
||||
|
||||
**Тело семестра:**
|
||||
```json
|
||||
{
|
||||
@@ -526,10 +573,11 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного
|
||||
года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий
|
||||
период разрешено начать на следующий календарный день после окончания предыдущего.
|
||||
Повторное название года, повторный тип семестра или пересечение возвращаются как
|
||||
Повторный тип семестра или пересечение возвращаются как
|
||||
`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 | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
|
||||
| `GET` | `/api/admin/academic-calendars/page` | Страница графиков: `query`, `academicYearId`, `page`, `size` |
|
||||
| `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса |
|
||||
| `GET` | `/api/admin/academic-calendars/{id}` | Один график |
|
||||
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
|
||||
@@ -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 блокирует график и повторно проверяет
|
||||
все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
|
||||
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
|
||||
@@ -656,11 +726,18 @@ CRUD доступен по:
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
||||
| `GET` | `/api/admin/schedule-rules/page` | Страница правил: обязательный `versionId`, параметры `query`, `page`, `size` |
|
||||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
|
||||
|
||||
`GET /api/admin/schedule-rules/page` возвращает тот же формат `PageResponse`;
|
||||
`page` по умолчанию 0, `size` — 25 (ограничение 1–100). Правила выбранной версии,
|
||||
кроме архивных, сортируются по ID по убыванию. `query` ищет по названию дисциплины,
|
||||
названию группы или точному ID. Сначала БД выбирает страницу ID, затем загружаются
|
||||
группы и слоты только этих правил; пагинация не выполняется в памяти над полной коллекцией.
|
||||
|
||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||
|
||||
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
|
||||
@@ -752,6 +829,9 @@ ID существующих слотов сохраняются; удалени
|
||||
| `timeSlotId` | Временной слот |
|
||||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||||
|
||||
Диапазон включает обе границы и не может превышать 200 календарных дней. Этого достаточно,
|
||||
чтобы запросить осенний или весенний семестр целиком одним обращением.
|
||||
|
||||
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
|
||||
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
|
||||
назначен через `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}` | Удалить изменение и вернуть актуальный вариант из правила |
|
||||
|
||||
`startDate` и `endDate` у списка передаются только парой, включительно; максимальный
|
||||
диапазон — 120 дней. Ответ реестра содержит исходные дату, время, преподавателя,
|
||||
диапазон — 200 дней. Ответ реестра содержит исходные дату, время, преподавателя,
|
||||
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
|
||||
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
|
||||
|
||||
@@ -1276,6 +1356,21 @@ payload обрабатываются один раз, используется
|
||||
|
||||
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
|
||||
|
||||
### `GET /api/groups/page`
|
||||
|
||||
Пагинированный реестр групп. Параметры: `query`, повторяемый `educationFormId`,
|
||||
`departmentId`, `yearStartStudy`, `includeArchived`, `page`, `size`, `sort`, `direction`.
|
||||
Поддерживаемые поля сортировки: `name`, `yearStartStudy`, `groupSize`, `status`. Поиск
|
||||
проверяет название группы, код и название специальности, профиль и форму обучения. Для
|
||||
роли `DEPARTMENT` кафедра всегда берётся из JWT-контекста независимо от переданного фильтра.
|
||||
|
||||
### `GET /api/groups/options`
|
||||
|
||||
Асинхронный поиск активных групп. Параметры: `query`, `departmentId`, `selectedId`, `limit`
|
||||
(`1..50`). Завершившие обучение и архивные группы исключаются. Для роли `DEPARTMENT`
|
||||
действует то же принудительное ограничение своей кафедрой, включая восстановление выбранного
|
||||
`selectedId`.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
[
|
||||
@@ -1379,7 +1474,7 @@ legacy-alias для старых клиентов и исторически со
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/subgroups` | Список всех подгрупп |
|
||||
| `GET` | `/api/subgroups?groupId=1&groupId=2` | Подгруппы выбранных групп; без параметра — совместимый список всех подгрупп, не более 100 ID групп за запрос |
|
||||
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
|
||||
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
|
||||
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
|
||||
@@ -1457,6 +1552,12 @@ legacy-alias для старых клиентов и исторически со
|
||||
|
||||
Список аудиторий с привязанным оборудованием.
|
||||
|
||||
### `GET /api/classrooms/options`
|
||||
|
||||
Асинхронный поиск доступных неархивных аудиторий по названию и корпусу. Параметры:
|
||||
`query`, `selectedId`, `limit` (`1..50`). Выбранная запись добавляется в результат, если
|
||||
она не попала в первую страницу поиска.
|
||||
|
||||
**Ответ:**
|
||||
```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
|
||||
{
|
||||
"name": "Физика",
|
||||
@@ -1591,14 +1698,17 @@ legacy-alias для старых клиентов и исторически со
|
||||
|
||||
### `GET /api/teacher-subjects`
|
||||
|
||||
Список всех привязок.
|
||||
Список привязок. Повторяемый параметр `subjectId` ограничивает результат указанными
|
||||
дисциплинами; за один запрос принимается не более 100 ID. Без параметра сохраняется
|
||||
совместимый режим получения всех доступных текущей роли привязок.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"userId": 2,
|
||||
"userName": "Тестовый преподаватель",
|
||||
"username": "teacher2",
|
||||
"fullName": "Тестовый преподаватель",
|
||||
"subjectId": 1,
|
||||
"subjectName": "Высшая математика"
|
||||
}
|
||||
|
||||
@@ -157,6 +157,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
При создании и изменении backend всегда формирует его из кода специальности, названия
|
||||
профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется.
|
||||
|
||||
Новый календарный график сохраняется вместе с начальной сеткой одной транзакцией. Для каждого
|
||||
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
|
||||
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
|
||||
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
|
||||
графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции
|
||||
`V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам
|
||||
без единого периода; частично или полностью настроенные сетки не изменяются.
|
||||
|
||||
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
|
||||
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
|
||||
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
|
||||
@@ -203,9 +211,10 @@ constraint. При чтении API разворачивает периоды о
|
||||
|
||||
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
||||
|
||||
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными
|
||||
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат
|
||||
разрешены, 121 дата отклоняется до генерации.
|
||||
Диапазон расписания включает обе переданные границы и ограничен ровно 200 календарными
|
||||
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 200 дат
|
||||
разрешены, 201 дата отклоняется до генерации. Один запрос поэтому покрывает учебный
|
||||
семестр целиком.
|
||||
|
||||
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
|
||||
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
|
||||
@@ -216,9 +225,13 @@ constraint. При чтении API разворачивает периоды о
|
||||
замен полный список групп не загружается.
|
||||
|
||||
Отчёты workload и свободные аудитории не являются интерактивным поиском и используют
|
||||
отдельный `searchForAggregation`. Он обходит все активные группы разрешённого scope без
|
||||
лимита 50, но переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию
|
||||
и дедупликацию. Поэтому tenant с 51 или 100 группами получает полный агрегат, а защита
|
||||
отдельный `searchForAggregation`. Он обходит все активные группы без лимита 50, но
|
||||
переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию и
|
||||
дедупликацию. Кафедральный scope применяется после генерации и точечных изменений по
|
||||
любой действующей основной или дополнительной связи преподавателя с кафедрой на дату занятия,
|
||||
а не по кафедре учебной группы.
|
||||
Поэтому межкафедральные занятия входят в нагрузку преподавателя его кафедры. Tenant с 51
|
||||
или 100 группами получает полный агрегат, а защита
|
||||
широкого `/api/schedule/search` остаётся прежней.
|
||||
|
||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||
|
||||
@@ -887,6 +887,10 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
|
||||
остаются строки старших курсов или дисциплины старших семестров.
|
||||
|
||||
При создании графика backend в той же транзакции добавляет по одному периоду `Т` на весь
|
||||
учебный год для каждого курса `1..course_count`. Если создание периодов завершается ошибкой,
|
||||
строка `academic_calendars` также откатывается.
|
||||
|
||||
#### `academic_calendar_periods` — Периоды активности календарного графика
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
@@ -949,8 +953,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
|
||||
Пара `semester_id + version_number` уникальна. Частичный индекс
|
||||
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
|
||||
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и
|
||||
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после
|
||||
семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
|
||||
привязывает к ней тестовые правила. Для семестров, создаваемых через API после
|
||||
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
|
||||
`Основное расписание` и две записи истории: создание и публикацию.
|
||||
|
||||
@@ -1173,10 +1177,15 @@ CHECK фиксирует допустимую форму каждого типа
|
||||
### Правила работы
|
||||
|
||||
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
||||
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
|
||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
|
||||
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||||
5. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
|
||||
2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
|
||||
триггеры и ограничения изменяются только в `V1__init.sql`.
|
||||
3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
|
||||
ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
|
||||
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
|
||||
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
|
||||
tenant-БД. Сброс выполняется только по прямой команде владельца.
|
||||
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||||
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
|
||||
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
|
||||
пустой tenant-схеме
|
||||
|
||||
@@ -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 с отсутствиями
|
||||
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
|
||||
проекта объединены в baseline `V1`. В каталоге миграций остаётся один файл
|
||||
`V1__init.sql`.
|
||||
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
||||
непосредственно в V1.
|
||||
Перед применением этой редакции требуется полностью пустая tenant-схема: для базы, где
|
||||
предыдущая V1 уже записана в `flyway_schema_history`, изменённая контрольная сумма вызовет
|
||||
ошибку проверки.
|
||||
Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
|
||||
владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
|
||||
в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
|
||||
tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
|
||||
|
||||
`V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
|
||||
Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый tenant, включая
|
||||
production. Это сознательное ограничение текущего режима разработки; перед production-релизом
|
||||
V2 необходимо сделать условной или исключить из production locations.
|
||||
|
||||
### Объём тестового набора V2
|
||||
|
||||
На чистой схеме V2 создаёт детерминированный связанный набор:
|
||||
|
||||
| Сущности | Количество |
|
||||
|----------|-----------:|
|
||||
| Кафедры / специальности / профили | 41 / 42 / 42 |
|
||||
| Пользователи / преподаватели | 155 / 151 |
|
||||
| Группы / подгруппы | 202 / 404 |
|
||||
| Дисциплины / связи преподаватель–дисциплина / разрешённые типы занятий | 229 / 844 / 2532 |
|
||||
| Аудитории / связи с оборудованием | 80 / 174 |
|
||||
| Календарные графики / периоды / дисциплины графиков / назначения групп | 132 / 576 / 3456 / 202 |
|
||||
| Версии / события истории | 8 / 16 |
|
||||
| Правила / связи с группами / слоты / лабораторные подгруппы | 610 / 611 / 610 / 202 |
|
||||
| Пожелания / отсутствия / комментарии к дисциплинам | 60 / 20 / 40 |
|
||||
|
||||
Учебные годы: 2024–2025, 2025–2026, 2026–2027 и 2027–2028. Для трёх последних
|
||||
создаётся одинаковый набор из 44 графиков направлений с периодами теоретического обучения
|
||||
и дисциплинами по семестрам. Назначения существующих групп сохраняются на 2025–2026 год.
|
||||
|
||||
Из 202 групп две (`ИВТ-21-1`, `ИБ-41м`) перенесены из прежней V1, ещё 200 созданы по
|
||||
шифрам направлений ЮЗГУ. Источниками названий служат публичные страницы структуры,
|
||||
приёмной кампании и перечней дисциплин ЮЗГУ; ФИО 150 массовых преподавателей синтетические
|
||||
и имеют непубликуемый случайный пароль. Доступны для ручного входа только документированные
|
||||
исторические demo-аккаунты.
|
||||
|
||||
### Полный сброс БД (локально)
|
||||
|
||||
|
||||
@@ -251,19 +251,18 @@ public class AbsenceController {
|
||||
|
||||
### Правила
|
||||
|
||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
|
||||
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
|
||||
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
|
||||
4. После добавления — перезапустите backend для применения
|
||||
1. До отдельного решения владельца вся постоянная схема изменяется только в `V1__init.sql`
|
||||
2. `V2__test_data.sql` предназначена исключительно для тестовых и демонстрационных данных;
|
||||
постоянный DDL в ней запрещён
|
||||
3. Не создавайте `V3` и следующие миграции, пока владелец не отменит режим пересобираемого baseline
|
||||
4. Любое изменение V1/V2 требует полного сброса затронутых tenant-БД из-за checksum mismatch
|
||||
5. Не сбрасывайте tenant-БД без прямой команды владельца
|
||||
|
||||
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
|
||||
|
||||
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца
|
||||
содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о
|
||||
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД.
|
||||
Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних
|
||||
вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в
|
||||
проекте нет.
|
||||
`V1__init.sql` содержит только структуру: таблицы, функции, триггеры, индексы, ограничения
|
||||
и комментарии. `V2__test_data.sql` содержит все прежние seed-записи, большой связанный набор
|
||||
и заполнение периодов `Т` для полностью пустых календарных графиков. Такое разделение позволяет
|
||||
отдельно проверить schema-only V1, но обе миграции по-прежнему автоматически применяются ко
|
||||
всем tenant-БД.
|
||||
|
||||
### Применение
|
||||
|
||||
@@ -271,6 +270,10 @@ public class AbsenceController {
|
||||
# Локально — сброс и повтор всех миграций
|
||||
docker compose down -v && docker compose up -d
|
||||
|
||||
# Интеграционная проверка V1/V2 на PostgreSQL через Testcontainers
|
||||
cd backend
|
||||
mvn -Dtest='com.magistr.app.migration.*IntegrationTest' test
|
||||
|
||||
# Продакшн — применить к существующим тенантам
|
||||
kubectl rollout restart deployment backend -n magistr
|
||||
```
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
|
||||
## Статус
|
||||
|
||||
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД.
|
||||
Новая модель является базовой схемой проекта и создаётся в
|
||||
`backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по
|
||||
типам занятий уже включены в schema-only baseline, а все демонстрационные записи загружаются
|
||||
отдельно из `V2__test_data.sql`. Применение рассчитано на полный сброс БД.
|
||||
|
||||
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
||||
|
||||
## База данных
|
||||
|
||||
`V1__init.sql` создаёт и заполняет:
|
||||
`V1__init.sql` создаёт структуру следующих таблиц, а `V2__test_data.sql` заполняет их
|
||||
связанным тестовым набором:
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---------|------------|
|
||||
|
||||
101
docs/FRONTEND.md
101
docs/FRONTEND.md
File diff suppressed because one or more lines are too long
@@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик
|
||||
| `admin` | `admin` | Администратор |
|
||||
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
|
||||
|
||||
Эти и остальные прежние демонстрационные записи загружаются миграцией
|
||||
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
|
||||
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
|
||||
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
|
||||
|
||||
### Полезные команды
|
||||
|
||||
```bash
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
2. Для группы находится назначенный календарный учебный график.
|
||||
3. По году начала обучения вычисляется текущий курс группы.
|
||||
4. По курсу и дате находится код активности.
|
||||
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`.
|
||||
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в тестовых данных
|
||||
`V2__test_data.sql` это `Т`.
|
||||
6. Для разрешённого дня выбираются активные правила и слоты.
|
||||
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
## Проверочный сценарий
|
||||
|
||||
1. Полностью сбросить локальную БД.
|
||||
2. Запустить приложение и применить `V1__init.sql`.
|
||||
2. Запустить приложение и применить schema-only `V1__init.sql`, затем тестовый набор
|
||||
`V2__test_data.sql`.
|
||||
3. Создать профиль специальности.
|
||||
4. Создать календарный график и заполнить дневную сетку.
|
||||
5. Назначить график группе.
|
||||
|
||||
@@ -630,7 +630,7 @@
|
||||
}
|
||||
|
||||
.schedule-view-extra-grid {
|
||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -689,6 +689,58 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.schedule-initial-state {
|
||||
display: flex;
|
||||
min-height: 220px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schedule-initial-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.schedule-initial-state[hidden],
|
||||
.schedule-initial-content.is-loading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedule-initial-content .card,
|
||||
#schedule-table tbody tr {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
#schedule-table {
|
||||
min-width: 1180px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
#schedule-table .schedule-rule-col-id { width: 5%; }
|
||||
#schedule-table .schedule-rule-col-subject { width: 16%; }
|
||||
#schedule-table .schedule-rule-col-semester { width: 13%; }
|
||||
#schedule-table .schedule-rule-col-groups { width: 16%; }
|
||||
#schedule-table .schedule-rule-col-load { width: 14%; }
|
||||
#schedule-table .schedule-rule-col-slots { width: 20%; }
|
||||
#schedule-table .schedule-rule-col-actions { width: 16%; }
|
||||
|
||||
#schedule-table th,
|
||||
#schedule-table td {
|
||||
overflow-wrap: anywhere;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#schedule-table thead th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-group-select {
|
||||
max-width: 520px;
|
||||
}
|
||||
@@ -703,6 +755,23 @@
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.schedule-group-search {
|
||||
width: calc(100% - 1rem);
|
||||
min-height: 38px;
|
||||
margin: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.schedule-group-search:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 3px solid var(--accent-glow);
|
||||
}
|
||||
|
||||
.schedule-hours-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1014,9 +1083,9 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(10, 10, 15, 0.97);
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.schedule-visual-context-menu[hidden] {
|
||||
@@ -1325,6 +1394,152 @@
|
||||
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 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -2148,6 +2363,20 @@
|
||||
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:focus-visible {
|
||||
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card));
|
||||
@@ -2311,7 +2540,7 @@
|
||||
}
|
||||
|
||||
.calendar-total-chip {
|
||||
--chip-color: var(--accent);
|
||||
--chip-color: var(--activity-color, var(--accent));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
@@ -3760,3 +3989,193 @@ tbody tr:hover {
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.registry-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.registry-toolbar .form-group {
|
||||
min-width: min(260px, 100%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registry-search {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-actions,
|
||||
.pagination-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.pagination-summary,
|
||||
.pagination-page,
|
||||
.pagination-size span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.pagination-size select {
|
||||
min-height: 36px;
|
||||
padding: 0.25rem 1.75rem 0.25rem 0.55rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.state-spinner {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-right: 0.55rem;
|
||||
vertical-align: -2px;
|
||||
border: 2px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: state-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes state-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.view-state-error {
|
||||
color: var(--danger, #dc2626);
|
||||
}
|
||||
|
||||
.view-state-error .btn {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.async-combobox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.async-combobox-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.async-combobox-control input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.async-combobox-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.45rem;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.async-combobox-clear:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.async-combobox-menu {
|
||||
position: absolute;
|
||||
z-index: 1200;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 10, 15, 0.96);
|
||||
backdrop-filter: blur(20px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(135%);
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
|
||||
}
|
||||
|
||||
[data-theme="light"] .async-combobox-menu {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
|
||||
}
|
||||
|
||||
.async-combobox-option {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 0.2rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.async-combobox-option:hover,
|
||||
.async-combobox-option:focus,
|
||||
.async-combobox-option[aria-selected="true"] {
|
||||
outline: none;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.async-combobox-option small,
|
||||
.async-combobox-state {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.async-combobox-state {
|
||||
padding: 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.async-combobox-state.is-error { color: var(--danger, #dc2626); }
|
||||
|
||||
.project-dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 4000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.project-dialog {
|
||||
width: min(520px, 100%);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 18px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.project-dialog header,
|
||||
.project-dialog footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.project-dialog header h2 { margin: 0; }
|
||||
.project-dialog footer { justify-content: flex-end; margin-top: 1rem; }
|
||||
.project-dialog-field { display: grid; gap: 0.4rem; margin-top: 1rem; }
|
||||
.project-dialog-field textarea { width: 100%; resize: vertical; }
|
||||
.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; }
|
||||
|
||||
@@ -79,6 +79,8 @@
|
||||
}
|
||||
|
||||
.version-hero-controls label {
|
||||
display: block;
|
||||
margin-bottom: .35rem;
|
||||
color: rgba(255, 255, 255, .62);
|
||||
font-size: .7rem;
|
||||
font-weight: 800;
|
||||
@@ -98,6 +100,12 @@
|
||||
|
||||
.version-hero-controls option { color: #152536; background: #f7f4ed; }
|
||||
|
||||
.version-hero-period-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .65rem;
|
||||
}
|
||||
|
||||
.version-status {
|
||||
min-height: 2.8rem;
|
||||
padding: .8rem 1rem;
|
||||
@@ -130,9 +138,13 @@
|
||||
}
|
||||
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
|
||||
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
|
||||
.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .55rem; align-items: end; }
|
||||
.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.version-constructor-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; align-items: end; }
|
||||
.version-constructor-field { min-width: 0; }
|
||||
.version-constructor-field-wide { grid-column: 1 / -1; }
|
||||
.version-constructor-controls label { display: block; margin-bottom: .35rem; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); }
|
||||
.version-constructor-field select { width: 100%; }
|
||||
.version-constructor-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: .55rem; }
|
||||
|
||||
.version-current {
|
||||
display: grid;
|
||||
@@ -304,5 +316,7 @@
|
||||
.version-diff-score { grid-template-columns: 1fr; }
|
||||
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
|
||||
.version-constructor-controls { grid-template-columns: 1fr; }
|
||||
.version-constructor-controls label { grid-column: auto; }
|
||||
.version-constructor-field-wide { grid-column: auto; }
|
||||
.version-constructor-actions { grid-column: auto; flex-direction: column; }
|
||||
.version-hero-period-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.absence-create-card {
|
||||
z-index: 20;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.absence-create-card:focus-within {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.absence-create-card .card-header-row > div:first-child,
|
||||
.absence-registry-head > div:first-child {
|
||||
display: flex;
|
||||
@@ -526,13 +535,64 @@ body.absence-wizard-open {
|
||||
|
||||
.teacher-dialogue-toolbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: .55rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar select {
|
||||
.teacher-dialogue-toolbar > select,
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar > select {
|
||||
min-height: 44px;
|
||||
padding: 0.65rem 2.15rem 0.65rem 0.8rem;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
|
||||
border-radius: 11px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-trigger {
|
||||
min-height: 44px;
|
||||
border-color: color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
|
||||
border-radius: 11px;
|
||||
background: color-mix(in srgb, var(--bg-input) 88%, #f59e0b 4%);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-trigger:hover,
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger {
|
||||
border-color: color-mix(in srgb, #f59e0b 62%, var(--bg-card-border));
|
||||
background: color-mix(in srgb, var(--bg-hover) 90%, #f59e0b 6%);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger,
|
||||
.teacher-dialogue-toolbar .custom-select-trigger:focus {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, #f59e0b 20%, transparent);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-menu {
|
||||
border-color: color-mix(in srgb, #f59e0b 25%, var(--bg-card-border));
|
||||
background: rgba(10, 10, 15, 0.96);
|
||||
backdrop-filter: blur(20px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(135%);
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-item:hover:not(.disabled) {
|
||||
background: color-mix(in srgb, #f59e0b 10%, var(--bg-hover));
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-item.selected {
|
||||
background: color-mix(in srgb, #f59e0b 18%, var(--bg-input));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .teacher-dialogue-toolbar .custom-select-menu {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.15), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
|
||||
}
|
||||
|
||||
.teacher-dialogue-list {
|
||||
@@ -669,6 +729,16 @@ body.absence-wizard-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar .custom-select-wrapper,
|
||||
.teacher-dialogue-toolbar > select,
|
||||
.teacher-dialogue-toolbar > .btn {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.absence-command-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
51
frontend/admin/js/academic-period.js
Normal file
51
frontend/admin/js/academic-period.js
Normal 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);
|
||||
}
|
||||
@@ -135,7 +135,8 @@ export async function logout() {
|
||||
|
||||
export const api = {
|
||||
get: async (url) => {
|
||||
const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
||||
const isCachable = !url.includes('/page') && !url.includes('/options')
|
||||
&& CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
||||
if (isCachable) {
|
||||
if (!cache.has(url)) {
|
||||
cache.set(url, await apiFetch(url, 'GET'));
|
||||
|
||||
193
frontend/admin/js/async-combobox.js
Normal file
193
frontend/admin/js/async-combobox.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
103
frontend/admin/js/dialog.js
Normal file
103
frontend/admin/js/dialog.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
36
frontend/admin/js/dirty-state.js
Normal file
36
frontend/admin/js/dirty-state.js
Normal 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;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
|
||||
import { initializeTelemetry } from '../../telemetry.js';
|
||||
import { confirmAbandonDirtyChanges } from './dirty-state.js';
|
||||
import { trackUxEvent } from '../../telemetry.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const session = await initializeSession();
|
||||
@@ -224,6 +226,7 @@ navItems.forEach(item => {
|
||||
|
||||
async function switchTab(tab) {
|
||||
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
|
||||
if (currentTab && !(await confirmAbandonDirtyChanges())) return;
|
||||
|
||||
// UI Update
|
||||
navItems.forEach(n => n.classList.remove('active'));
|
||||
@@ -245,6 +248,7 @@ async function switchTab(tab) {
|
||||
}
|
||||
|
||||
currentTab = tab;
|
||||
trackUxEvent('раздел_открыт', { tab, role: currentRole });
|
||||
if (window.location.hash !== `#${tab}`) {
|
||||
window.location.hash = tab;
|
||||
}
|
||||
@@ -281,7 +285,7 @@ async function refreshTeacherRequestBadge(countOverride = null) {
|
||||
if (currentRole !== 'ADMIN') return;
|
||||
try {
|
||||
const count = countOverride === null
|
||||
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
|
||||
? Number((await api.get('/api/teacher-requests/page?status=PENDING&page=0&size=1')).totalItems || 0)
|
||||
: countOverride;
|
||||
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
|
||||
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
|
||||
|
||||
34
frontend/admin/js/pagination.js
Normal file
34
frontend/admin/js/pagination.js
Normal 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));
|
||||
});
|
||||
}
|
||||
43
frontend/admin/js/schedule-period.js
Normal file
43
frontend/admin/js/schedule-period.js
Normal 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('-');
|
||||
}
|
||||
34
frontend/admin/js/url-state.js
Normal file
34
frontend/admin/js/url-state.js
Normal 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);
|
||||
};
|
||||
}
|
||||
24
frontend/admin/js/view-state.js
Normal file
24
frontend/admin/js/view-state.js
Normal 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');
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { api } from '../api.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
|
||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
|
||||
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
||||
@@ -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() {
|
||||
const academicYearForm = document.getElementById('academic-year-form');
|
||||
const academicYearFormTitle = document.getElementById('academic-year-form-title');
|
||||
const academicYearIdInput = document.getElementById('academic-year-id');
|
||||
const academicYearTitleInput = document.getElementById('academic-year-title');
|
||||
const academicYearStartInput = document.getElementById('academic-year-start');
|
||||
const academicYearEndInput = document.getElementById('academic-year-end');
|
||||
const academicYearResetButton = document.getElementById('academic-year-reset');
|
||||
const academicYearsTbody = document.getElementById('academic-years-tbody');
|
||||
const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
|
||||
|
||||
const semesterForm = document.getElementById('semester-form');
|
||||
const semesterFormTitle = document.getElementById('semester-form-title');
|
||||
const semesterIdInput = document.getElementById('semester-id');
|
||||
const semesterYearSelect = document.getElementById('semester-year');
|
||||
const semesterTypeSelect = document.getElementById('semester-type');
|
||||
const semesterStartInput = document.getElementById('semester-start');
|
||||
const semesterEndInput = document.getElementById('semester-end');
|
||||
const semesterResetButton = document.getElementById('semester-reset');
|
||||
const academicYearSemesterInputs = {
|
||||
autumn: {
|
||||
id: document.getElementById('academic-year-autumn-id'),
|
||||
start: document.getElementById('academic-year-autumn-start'),
|
||||
end: document.getElementById('academic-year-autumn-end')
|
||||
},
|
||||
spring: {
|
||||
id: document.getElementById('academic-year-spring-id'),
|
||||
start: document.getElementById('academic-year-spring-start'),
|
||||
end: document.getElementById('academic-year-spring-end')
|
||||
}
|
||||
};
|
||||
|
||||
const calendarForm = document.getElementById('academic-calendar-form');
|
||||
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
|
||||
@@ -119,12 +207,19 @@ export async function initAcademicCalendar() {
|
||||
const subjectCount = document.getElementById('calendar-subject-count');
|
||||
|
||||
let academicYears = [];
|
||||
let semesters = [];
|
||||
let specialties = [];
|
||||
let profilesBySpecialty = new Map();
|
||||
let studyForms = [];
|
||||
let activityTypes = [];
|
||||
let calendars = [];
|
||||
let calendarPage = 0;
|
||||
let calendarSize = 25;
|
||||
let calendarRequest = 0;
|
||||
const calendarCache = new Map();
|
||||
const calendarComboboxes = new Map();
|
||||
const calendarSearch = document.getElementById('academic-calendars-search');
|
||||
const calendarYearFilter = document.getElementById('academic-calendars-year-filter');
|
||||
const calendarPagination = document.getElementById('academic-calendars-pagination');
|
||||
let subjects = [];
|
||||
let calendarSubjectRows = [];
|
||||
let timeSlotScopes = [];
|
||||
@@ -138,6 +233,28 @@ export async function initAcademicCalendar() {
|
||||
let isSyncingCalendarDayDialog = false;
|
||||
let calendarDayTooltip = null;
|
||||
let calendarDayTooltipPositionAnimation = null;
|
||||
let loadedGridCalendarId = '';
|
||||
let loadedSubjectCalendarId = '';
|
||||
let subjectLoadRequest = 0;
|
||||
let gridLoadRequest = 0;
|
||||
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
|
||||
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
|
||||
|
||||
[editorCalendarSelect, subjectCalendarSelect].filter(Boolean).forEach(select => {
|
||||
select.innerHTML = '<option value="">Выберите график</option>';
|
||||
calendarComboboxes.set(select, new AsyncCombobox(select, {
|
||||
placeholder: 'Поиск по направлению, профилю, форме или году',
|
||||
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title,
|
||||
optionDescription: calendar => calendar.specialtyName || '',
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: 20 });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/admin/academic-calendars/options?${params}`);
|
||||
items.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
|
||||
return items;
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -158,21 +275,32 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function bindEvents() {
|
||||
[
|
||||
academicYearStartInput,
|
||||
academicYearEndInput,
|
||||
semesterStartInput,
|
||||
semesterEndInput,
|
||||
academicYearSemesterInputs.autumn.start,
|
||||
academicYearSemesterInputs.autumn.end,
|
||||
academicYearSemesterInputs.spring.start,
|
||||
academicYearSemesterInputs.spring.end,
|
||||
fillStartInput,
|
||||
fillEndInput
|
||||
].forEach(bindDateInputMask);
|
||||
|
||||
calendarTabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
|
||||
button.addEventListener('click', async () => {
|
||||
if (!await confirmAbandonDirtyChanges()) return;
|
||||
activateCalendarTab(button.dataset.calendarTab);
|
||||
});
|
||||
});
|
||||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||||
calendarSearch?.addEventListener('input', debounce(() => {
|
||||
calendarPage = 0;
|
||||
loadCalendars();
|
||||
}, 300));
|
||||
calendarYearFilter?.addEventListener('change', () => {
|
||||
calendarPage = 0;
|
||||
loadCalendars();
|
||||
});
|
||||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||||
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
||||
semesterResetButton?.addEventListener('click', resetSemesterForm);
|
||||
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
|
||||
calendarResetButton?.addEventListener('click', resetCalendarForm);
|
||||
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
|
||||
editorLoadButton?.addEventListener('click', loadCalendarGrid);
|
||||
@@ -188,6 +316,7 @@ export async function initAcademicCalendar() {
|
||||
if (event.target.matches('.calendar-day-activity')) {
|
||||
updateCalendarDayVisual(event.target.closest('.calendar-day'));
|
||||
renderCalendarTotals();
|
||||
markGridDirty();
|
||||
}
|
||||
});
|
||||
calendarGrid?.addEventListener('mousedown', (event) => {
|
||||
@@ -239,12 +368,12 @@ export async function initAcademicCalendar() {
|
||||
});
|
||||
updateCalendarDayDialogSummaryForSelection(days);
|
||||
renderCalendarTotals();
|
||||
markGridDirty();
|
||||
});
|
||||
calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog);
|
||||
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
|
||||
|
||||
academicYearForm?.addEventListener('submit', saveAcademicYear);
|
||||
semesterForm?.addEventListener('submit', saveSemester);
|
||||
calendarForm?.addEventListener('submit', saveCalendar);
|
||||
|
||||
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
|
||||
@@ -269,9 +398,13 @@ export async function initAcademicCalendar() {
|
||||
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||
renderAcademicYears();
|
||||
populateYearSelects();
|
||||
const selectedYear = calendarYearFilter.value;
|
||||
calendarYearFilter.innerHTML = '<option value="">Все учебные годы</option>' + academicYears.map(year =>
|
||||
`<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
calendarYearFilter.value = selectedYear;
|
||||
refreshSelectVisuals(calendarYearFilter);
|
||||
} catch (error) {
|
||||
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
throw error;
|
||||
@@ -319,17 +452,19 @@ export async function initAcademicCalendar() {
|
||||
event.preventDefault();
|
||||
hideAlert('academic-year-alert');
|
||||
try {
|
||||
const defaults = applyAcademicYearDateDefaults(true);
|
||||
const { startDate, endDate } = defaults;
|
||||
const payload = {
|
||||
title: academicYearTitleInput.value.trim(),
|
||||
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
|
||||
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
|
||||
startDate,
|
||||
endDate,
|
||||
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;
|
||||
await (id
|
||||
? api.put('/api/admin/calendar/years/' + id, payload)
|
||||
: api.post('/api/admin/calendar/years', payload));
|
||||
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
|
||||
showAlert('academic-year-alert', 'Учебный год и семестры сохранены', 'success');
|
||||
resetAcademicYearForm();
|
||||
await loadYears();
|
||||
await loadCalendars();
|
||||
@@ -346,33 +481,27 @@ export async function initAcademicCalendar() {
|
||||
if (editYearButton) {
|
||||
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
|
||||
if (!year) return;
|
||||
academicYearFormTitle.textContent = 'Редактирование учебного года';
|
||||
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' });
|
||||
fillAcademicYearForm(year);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editSemesterButton) {
|
||||
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
||||
if (!semester) return;
|
||||
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
|
||||
semesterIdInput.value = semester.id;
|
||||
semesterYearSelect.value = semester.academicYearId || '';
|
||||
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' });
|
||||
const year = academicYears.find(item => (item.semesters || [])
|
||||
.some(semester => String(semester.id) === String(editSemesterButton.dataset.id)));
|
||||
if (!year) return;
|
||||
const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id));
|
||||
fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType));
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteYearButton) {
|
||||
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return;
|
||||
const confirmed = await confirmAction({
|
||||
title: 'Удаление учебного года',
|
||||
message: 'Удалить учебный год вместе с семестрами и календарными графиками?',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
|
||||
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
|
||||
@@ -386,42 +515,73 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function resetAcademicYearForm() {
|
||||
academicYearForm.reset();
|
||||
academicYearFormTitle.textContent = 'Учебный год';
|
||||
academicYearFormTitle.textContent = 'Учебный год и семестры';
|
||||
academicYearIdInput.value = '';
|
||||
Object.values(academicYearSemesterInputs).forEach(inputs => {
|
||||
inputs.id.value = '';
|
||||
});
|
||||
hideAlert('academic-year-alert');
|
||||
}
|
||||
|
||||
async function saveSemester(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('semester-alert');
|
||||
const yearId = semesterYearSelect.value;
|
||||
try {
|
||||
const payload = {
|
||||
semesterType: semesterTypeSelect.value,
|
||||
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
|
||||
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
|
||||
};
|
||||
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
|
||||
throw new Error('Заполните учебный год, тип и даты семестра');
|
||||
}
|
||||
const id = semesterIdInput.value;
|
||||
await (id
|
||||
? api.put('/api/admin/calendar/semesters/' + id, payload)
|
||||
: 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 fillAcademicYearForm(year, focusedSemesterType = '') {
|
||||
academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim();
|
||||
academicYearIdInput.value = year.id;
|
||||
academicYearStartInput.value = String(year.startDate || '').slice(0, 4);
|
||||
academicYearEndInput.value = String(year.endDate || '').slice(0, 4);
|
||||
['autumn', 'spring'].forEach(type => {
|
||||
const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type);
|
||||
const inputs = academicYearSemesterInputs[type];
|
||||
inputs.id.value = semester?.id || '';
|
||||
setDateInputIso(inputs.start, semester?.startDate || '');
|
||||
setDateInputIso(inputs.end, semester?.endDate || '');
|
||||
});
|
||||
hideAlert('academic-year-alert');
|
||||
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) {
|
||||
academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function resetSemesterForm() {
|
||||
semesterForm.reset();
|
||||
semesterFormTitle.textContent = 'Семестр';
|
||||
semesterIdInput.value = '';
|
||||
syncSelects(semesterYearSelect, semesterTypeSelect);
|
||||
hideAlert('semester-alert');
|
||||
function handleAcademicYearStartInput() {
|
||||
academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4);
|
||||
if (academicYearStartInput.value.length !== 4) {
|
||||
academicYearEndInput.value = '';
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
@@ -448,13 +608,28 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
async function loadCalendars() {
|
||||
const request = ++calendarRequest;
|
||||
calendarPagination?.replaceChildren();
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
calendars = await api.get('/api/admin/academic-calendars');
|
||||
const params = new URLSearchParams({ page: calendarPage, size: calendarSize, query: calendarSearch?.value.trim() || '' });
|
||||
if (calendarYearFilter?.value) params.set('academicYearId', calendarYearFilter.value);
|
||||
const page = await api.get(`/api/admin/academic-calendars/page?${params}`);
|
||||
if (request !== calendarRequest) return;
|
||||
if (calendarPage > 0 && calendarPage >= page.totalPages) {
|
||||
calendarPage = Math.max(0, page.totalPages - 1);
|
||||
return loadCalendars();
|
||||
}
|
||||
calendars = page.items;
|
||||
calendars.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
|
||||
renderCalendars();
|
||||
populateEditorCalendarSelect();
|
||||
populateSubjectCalendarSelect();
|
||||
renderPagination(calendarPagination, page, {
|
||||
label: 'графиков',
|
||||
onPage: value => { calendarPage = value; loadCalendars(); },
|
||||
onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); }
|
||||
});
|
||||
} catch (error) {
|
||||
if (request !== calendarRequest) return;
|
||||
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
throw error;
|
||||
}
|
||||
@@ -462,7 +637,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function renderCalendars() {
|
||||
if (!calendars.length) {
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не созданы</td></tr>';
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||||
@@ -502,6 +677,10 @@ export async function initAcademicCalendar() {
|
||||
const saved = id
|
||||
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
||||
: await api.post('/api/admin/academic-calendars', payload);
|
||||
calendarCache.set(String(saved.id), saved);
|
||||
calendarComboboxes.forEach((combobox, select) => {
|
||||
if (select.value === String(saved.id)) setCalendarSelection(select, saved.id);
|
||||
});
|
||||
const savedTitle = buildAcademicCalendarTitle(saved);
|
||||
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
|
||||
resetCalendarForm();
|
||||
@@ -536,7 +715,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
if (gridButton) {
|
||||
activateCalendarTab('grid');
|
||||
editorCalendarSelect.value = gridButton.dataset.id;
|
||||
setCalendarSelection(editorCalendarSelect, gridButton.dataset.id);
|
||||
refreshSelectVisuals(editorCalendarSelect);
|
||||
await loadCalendarGrid();
|
||||
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -545,7 +724,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
if (subjectsButton) {
|
||||
activateCalendarTab('subjects');
|
||||
subjectCalendarSelect.value = subjectsButton.dataset.id;
|
||||
setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id);
|
||||
refreshSelectVisuals(subjectCalendarSelect);
|
||||
await loadCalendarSubjects();
|
||||
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -553,9 +732,30 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить календарный график?')) return;
|
||||
const confirmed = await confirmAction({
|
||||
title: 'Удаление календарного графика',
|
||||
message: 'Удалить календарный график? Связанные настройки больше не будут доступны.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
|
||||
const deletedId = deleteButton.dataset.id;
|
||||
await api.delete('/api/admin/academic-calendars/' + deletedId);
|
||||
calendarCache.delete(deletedId);
|
||||
calendarComboboxes.forEach((combobox, select) => {
|
||||
if (select.value === deletedId) {
|
||||
setCalendarSelection(select, '');
|
||||
if (select === editorCalendarSelect) {
|
||||
loadedGridCalendarId = '';
|
||||
calendarGrid.replaceChildren();
|
||||
calendarTotals.replaceChildren();
|
||||
clearGridDirty();
|
||||
} else {
|
||||
loadCalendarSubjects();
|
||||
}
|
||||
}
|
||||
});
|
||||
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
||||
await loadCalendars();
|
||||
} catch (error) {
|
||||
@@ -589,20 +789,34 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
async function loadCalendarSubjects() {
|
||||
const request = ++subjectLoadRequest;
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||
setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId);
|
||||
refreshSelectVisuals(subjectCalendarSelect);
|
||||
return;
|
||||
}
|
||||
if (request !== subjectLoadRequest) return;
|
||||
populateSubjectSemesterSelect();
|
||||
calendarSubjectRows = [];
|
||||
loadedSubjectCalendarId = '';
|
||||
if (!calendar) {
|
||||
loadedSubjectCalendarId = '';
|
||||
clearSubjectDirty();
|
||||
renderCalendarSubjects();
|
||||
return;
|
||||
}
|
||||
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
try {
|
||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
||||
if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return;
|
||||
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
||||
loadedSubjectCalendarId = String(calendar.id);
|
||||
clearSubjectDirty();
|
||||
renderCalendarSubjects();
|
||||
} catch (error) {
|
||||
if (request !== subjectLoadRequest) return;
|
||||
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
||||
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
|
||||
}
|
||||
@@ -611,6 +825,10 @@ export async function initAcademicCalendar() {
|
||||
function addCalendarSubject() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
|
||||
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
|
||||
return;
|
||||
}
|
||||
const semesterNumber = Number(subjectSemesterSelect.value);
|
||||
const subjectId = Number(subjectSelect.value);
|
||||
if (!calendar || !semesterNumber || !subjectId) {
|
||||
@@ -641,12 +859,17 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
]);
|
||||
renderCalendarSubjects();
|
||||
markSubjectDirty();
|
||||
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
|
||||
}
|
||||
|
||||
async function saveCalendarSubjects() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
|
||||
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
|
||||
return;
|
||||
}
|
||||
if (!calendar) {
|
||||
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
|
||||
return;
|
||||
@@ -659,6 +882,8 @@ export async function initAcademicCalendar() {
|
||||
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
|
||||
calendarSubjectRows = sortCalendarSubjectRows(saved || []);
|
||||
renderCalendarSubjects();
|
||||
clearSubjectDirty();
|
||||
trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length });
|
||||
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
|
||||
} catch (error) {
|
||||
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
|
||||
@@ -674,6 +899,7 @@ export async function initAcademicCalendar() {
|
||||
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
|
||||
);
|
||||
renderCalendarSubjects();
|
||||
markSubjectDirty();
|
||||
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
|
||||
}
|
||||
|
||||
@@ -718,8 +944,14 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
async function loadCalendarGrid() {
|
||||
const request = ++gridLoadRequest;
|
||||
hideAlert('calendar-editor-alert');
|
||||
const calendar = selectedEditorCalendar();
|
||||
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||
setCalendarSelection(editorCalendarSelect, loadedGridCalendarId);
|
||||
refreshSelectVisuals(editorCalendarSelect);
|
||||
return;
|
||||
}
|
||||
if (!calendar) {
|
||||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||||
return;
|
||||
@@ -727,10 +959,15 @@ export async function initAcademicCalendar() {
|
||||
try {
|
||||
await loadTimeSlotDateAssignments();
|
||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
|
||||
if (request !== gridLoadRequest || String(calendar.id) !== editorCalendarSelect.value) return;
|
||||
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
|
||||
renderCalendarGrid(gridRows);
|
||||
loadedGridCalendarId = String(calendar.id);
|
||||
clearGridDirty();
|
||||
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
|
||||
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
|
||||
} catch (error) {
|
||||
if (request !== gridLoadRequest) return;
|
||||
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
|
||||
}
|
||||
}
|
||||
@@ -742,6 +979,10 @@ export async function initAcademicCalendar() {
|
||||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||||
return;
|
||||
}
|
||||
if (String(calendar.id) !== loadedGridCalendarId) {
|
||||
showAlert('calendar-editor-alert', 'Сначала загрузите сетку выбранного графика', 'error');
|
||||
return;
|
||||
}
|
||||
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
|
||||
calendarId: calendar.id,
|
||||
courseNumber: Number(day.dataset.course),
|
||||
@@ -756,6 +997,8 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
try {
|
||||
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
|
||||
clearGridDirty();
|
||||
trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length });
|
||||
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
|
||||
await loadCalendarGrid();
|
||||
} catch (error) {
|
||||
@@ -795,9 +1038,30 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
});
|
||||
renderCalendarTotals();
|
||||
if (changed) markGridDirty();
|
||||
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
|
||||
}
|
||||
|
||||
function markGridDirty() {
|
||||
gridDirtyScope.mark();
|
||||
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку •';
|
||||
}
|
||||
|
||||
function clearGridDirty() {
|
||||
gridDirtyScope.clear();
|
||||
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку';
|
||||
}
|
||||
|
||||
function markSubjectDirty() {
|
||||
subjectDirtyScope.mark();
|
||||
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки •';
|
||||
}
|
||||
|
||||
function clearSubjectDirty() {
|
||||
subjectDirtyScope.clear();
|
||||
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки';
|
||||
}
|
||||
|
||||
function renderCalendarGrid(rows) {
|
||||
activeCalendarDay = null;
|
||||
selectedCalendarDayKey = null;
|
||||
@@ -895,7 +1159,7 @@ export async function initAcademicCalendar() {
|
||||
const activity = activityById(selected) || activityTypes[0] || {};
|
||||
const code = activity.code || row.activityCode || 'Т';
|
||||
return `
|
||||
<td class="calendar-day"
|
||||
<td class="calendar-day ${activityColorClass(activity)}"
|
||||
data-course="${row.courseNumber}"
|
||||
data-date="${row.date}"
|
||||
data-week="${row.weekNumber}"
|
||||
@@ -967,9 +1231,11 @@ export async function initAcademicCalendar() {
|
||||
function updateCalendarDayDialogSummary(day) {
|
||||
const activity = activityById(day.querySelector('.calendar-day-activity').value);
|
||||
if (!activity) {
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, null);
|
||||
calendarDayDialogActivitySummary.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
|
||||
calendarDayDialogActivitySummary.innerHTML = `
|
||||
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
|
||||
<div>
|
||||
@@ -987,6 +1253,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
const activity = activityById(commonCalendarDayActivityValue(days));
|
||||
if (!activity) {
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, null);
|
||||
calendarDayDialogActivitySummary.innerHTML = `
|
||||
<span class="calendar-day-dialog-code">...</span>
|
||||
<div>
|
||||
@@ -997,6 +1264,7 @@ export async function initAcademicCalendar() {
|
||||
return;
|
||||
}
|
||||
|
||||
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
|
||||
calendarDayDialogActivitySummary.innerHTML = `
|
||||
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
|
||||
<div>
|
||||
@@ -1091,6 +1359,7 @@ export async function initAcademicCalendar() {
|
||||
const select = day.querySelector('.calendar-day-activity');
|
||||
const activity = activityById(select.value);
|
||||
const code = activity?.code || '-';
|
||||
applyActivityColorClass(day, activity);
|
||||
day.querySelector('.calendar-day-code').textContent = 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();
|
||||
tooltip.innerHTML = renderCalendarDayTooltip(day);
|
||||
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
|
||||
applyActivityColorClass(tooltip, activity);
|
||||
tooltip.classList.add('visible');
|
||||
positionCalendarDayTooltip(tooltip, day, event);
|
||||
}
|
||||
@@ -1285,7 +1556,9 @@ export async function initAcademicCalendar() {
|
||||
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
|
||||
const activity = activityById(select.value);
|
||||
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
|
||||
? selectedCalendarDays
|
||||
@@ -1296,19 +1569,18 @@ export async function initAcademicCalendar() {
|
||||
const selectedCode = selectedActivity?.code || null;
|
||||
calendarTotals.innerHTML = Array.from(counts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
|
||||
.map(([code, count]) => {
|
||||
.map(([code, entry]) => {
|
||||
const selectedClass = selectedCode === code ? ' is-selected' : '';
|
||||
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('');
|
||||
}
|
||||
|
||||
function populateYearSelects() {
|
||||
const yearOptions = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
semesterYearSelect.innerHTML = yearOptions;
|
||||
calendarYearSelect.innerHTML = yearOptions;
|
||||
syncSelects(semesterYearSelect, calendarYearSelect);
|
||||
syncSelects(calendarYearSelect);
|
||||
}
|
||||
|
||||
function populateSpecialtySelects() {
|
||||
@@ -1359,31 +1631,12 @@ export async function initAcademicCalendar() {
|
||||
syncSelects(fillActivitySelect);
|
||||
}
|
||||
|
||||
function populateEditorCalendarSelect() {
|
||||
const current = editorCalendarSelect.value;
|
||||
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
||||
calendars.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||
).join('');
|
||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||||
editorCalendarSelect.value = current;
|
||||
}
|
||||
syncSelects(editorCalendarSelect);
|
||||
}
|
||||
|
||||
function populateSubjectCalendarSelect() {
|
||||
if (!subjectCalendarSelect) return;
|
||||
const current = subjectCalendarSelect.value;
|
||||
subjectCalendarSelect.innerHTML = calendars.length
|
||||
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет графиков</option>';
|
||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||||
subjectCalendarSelect.value = current;
|
||||
}
|
||||
populateSubjectSemesterSelect();
|
||||
syncSelects(subjectCalendarSelect);
|
||||
function setCalendarSelection(select, id) {
|
||||
const calendar = calendarCache.get(String(id));
|
||||
select.innerHTML = calendar
|
||||
? `<option value="${calendar.id}" selected>${escapeHtml(buildAcademicCalendarTitle(calendar) || calendar.title)}</option>`
|
||||
: '<option value="">Выберите график</option>';
|
||||
calendarComboboxes.get(select)?.syncFromSelect();
|
||||
}
|
||||
|
||||
function populateSubjectSemesterSelect() {
|
||||
@@ -1427,11 +1680,11 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
function selectedEditorCalendar() {
|
||||
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
|
||||
return calendarCache.get(String(editorCalendarSelect.value));
|
||||
}
|
||||
|
||||
function selectedSubjectCalendar() {
|
||||
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value));
|
||||
return calendarCache.get(String(subjectCalendarSelect?.value));
|
||||
}
|
||||
|
||||
function calendarSemesterCount(calendar) {
|
||||
@@ -1474,6 +1727,16 @@ export async function initAcademicCalendar() {
|
||||
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) {
|
||||
return timeSlotDateAssignments.find(item => item.date === date);
|
||||
}
|
||||
@@ -1609,6 +1872,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function refreshSelectVisuals(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
calendarComboboxes.get(select)?.syncFromSelect();
|
||||
if (select.customSelectInstance) {
|
||||
select.customSelectInstance.rebuildMenu();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
|
||||
import { alertAction, confirmAction } from '../dialog.js';
|
||||
|
||||
async function fetchEquipments() {
|
||||
try {
|
||||
@@ -131,11 +132,16 @@ export async function initClassrooms() {
|
||||
const btnEdit = e.target.closest('.classroom-edit-action');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Вывод аудитории из эксплуатации',
|
||||
message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.',
|
||||
confirmLabel: 'Вывести из эксплуатации',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка архивирования'); }
|
||||
} catch (err) { await alertAction({ title: 'Ошибка архивирования', message: err.message || 'Не удалось архивировать аудиторию' }); }
|
||||
}
|
||||
|
||||
const btnRestore = e.target.closest('.btn-restore-classroom');
|
||||
@@ -143,7 +149,7 @@ export async function initClassrooms() {
|
||||
try {
|
||||
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка восстановления аудитории'); }
|
||||
} catch (err) { await alertAction({ title: 'Ошибка восстановления', message: err.message || 'Не удалось восстановить аудиторию' }); }
|
||||
}
|
||||
|
||||
if (btnToggleStatus) {
|
||||
@@ -152,7 +158,7 @@ export async function initClassrooms() {
|
||||
try {
|
||||
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка изменения статуса'); }
|
||||
} catch (err) { await alertAction({ title: 'Ошибка изменения статуса', message: err.message || 'Не удалось изменить доступность аудитории' }); }
|
||||
}
|
||||
|
||||
if (btnEdit) {
|
||||
@@ -178,7 +184,7 @@ export async function initClassrooms() {
|
||||
hideAlert('edit-classroom-alert');
|
||||
modalEditClassroom.classList.add('open');
|
||||
} catch (e) {
|
||||
alert('Ошибка загрузки данных аудитории');
|
||||
await alertAction({ title: 'Ошибка загрузки', message: e.message || 'Не удалось загрузить данные аудитории' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,12 +294,17 @@ export async function initClassrooms() {
|
||||
manageEquipmentTbody.addEventListener('click', async (e) => {
|
||||
const btnDelete = e.target.closest('.btn-delete-equipment');
|
||||
if (!btnDelete) return;
|
||||
if (!confirm('Удалить оборудование из каталога?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление оборудования',
|
||||
message: 'Удалить оборудование из каталога?',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/equipments/' + btnDelete.dataset.id);
|
||||
await loadManageEquipments();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка удаления');
|
||||
await alertAction({ title: 'Ошибка удаления', message: err.message || 'Не удалось удалить оборудование' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||
import { defaultSchedulePeriod } from '../schedule-period.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
|
||||
@@ -72,6 +73,7 @@ export async function initDepartmentWorkspace() {
|
||||
departmentField.hidden = true;
|
||||
departmentSelect.value = userDepartmentId || '';
|
||||
}
|
||||
await selectDefaultWorkloadPeriod(startInput, endInput);
|
||||
await loadAllTeachers();
|
||||
renderImportRows([{ code: '', name: '' }]);
|
||||
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) {
|
||||
const departments = await api.get('/api/departments');
|
||||
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { alertAction, confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, readUrlValues, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
async function fetchEducationForms() {
|
||||
try {
|
||||
@@ -24,6 +30,10 @@ export async function initGroups() {
|
||||
const filterEfMenu = document.getElementById('filter-ef-menu');
|
||||
const filterEfText = document.getElementById('filter-ef-text');
|
||||
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
|
||||
const searchInput = document.getElementById('groups-search');
|
||||
const sortSelect = document.getElementById('groups-sort');
|
||||
const filterResetButton = document.getElementById('groups-filter-reset');
|
||||
const pagination = document.getElementById('groups-pagination');
|
||||
|
||||
// Модалка группы
|
||||
const modalEditGroup = document.getElementById('modal-edit-group');
|
||||
@@ -65,9 +75,19 @@ export async function initGroups() {
|
||||
let specialties = [];
|
||||
let profilesBySpecialty = new Map();
|
||||
let academicYears = [];
|
||||
let calendars = [];
|
||||
let currentAssignments = [];
|
||||
let activeSubgroupGroup = null;
|
||||
let calendarCombobox = null;
|
||||
const restored = readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' });
|
||||
const pageState = {
|
||||
query: restored.groupQuery,
|
||||
page: Math.max(Number(restored.groupPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.groupSize)) ? Number(restored.groupSize) : 25,
|
||||
sort: restored.groupSort,
|
||||
educationFormIds: readUrlValues('groupEducationForm')
|
||||
};
|
||||
searchInput.value = pageState.query;
|
||||
sortSelect.value = pageState.sort;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
@@ -89,7 +109,30 @@ export async function initGroups() {
|
||||
|
||||
// Модалка календарей
|
||||
manageCalendarForm.addEventListener('submit', saveAssignment);
|
||||
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
|
||||
manageCalendarYearSelect.addEventListener('change', () => {
|
||||
calendarCombobox?.clear();
|
||||
populateCalendarSelect();
|
||||
});
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
pageState.query = searchInput.value.trim();
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
}));
|
||||
sortSelect.addEventListener('change', () => {
|
||||
pageState.sort = sortSelect.value;
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
});
|
||||
filterResetButton.addEventListener('click', () => {
|
||||
pageState.query = '';
|
||||
pageState.page = 0;
|
||||
pageState.sort = 'name,asc';
|
||||
pageState.educationFormIds = [];
|
||||
searchInput.value = '';
|
||||
sortSelect.value = pageState.sort;
|
||||
renderFilterEfCheckboxes(educationForms);
|
||||
loadGroups();
|
||||
});
|
||||
|
||||
groupsTbody.addEventListener('click', handleGroupTableClick);
|
||||
|
||||
@@ -143,20 +186,19 @@ export async function initGroups() {
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
[educationForms, departments, specialties, academicYears, calendars] = await Promise.all([
|
||||
[educationForms, departments, specialties, academicYears] = await Promise.all([
|
||||
fetchEducationForms(),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/specialties'),
|
||||
api.get('/api/admin/calendar/years'),
|
||||
api.get('/api/admin/academic-calendars')
|
||||
api.get('/api/admin/calendar/years')
|
||||
]);
|
||||
await loadProfiles();
|
||||
populateEfSelects(educationForms);
|
||||
populateDepartmentSelects(departments);
|
||||
populateSpecialitySelects(specialties);
|
||||
populateYearSelect();
|
||||
initCalendarCombobox();
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
@@ -171,28 +213,50 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
renderTableLoading(groupsTbody, 10, 'Загрузка групп...');
|
||||
try {
|
||||
allGroups = await api.get('/api/groups?includeArchived=true');
|
||||
applyGroupFilter();
|
||||
const [sort, direction] = pageState.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: pageState.query,
|
||||
page: String(pageState.page),
|
||||
size: String(pageState.size),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
pageState.educationFormIds.forEach(id => params.append('educationFormId', id));
|
||||
const result = await api.get(`/api/groups/page?${params}`);
|
||||
allGroups = result.items || [];
|
||||
if (!allGroups.length && result.totalItems > 0 && pageState.page > 0) {
|
||||
pageState.page = Math.max(0, result.totalPages - 1);
|
||||
return loadGroups();
|
||||
}
|
||||
renderGroups(allGroups);
|
||||
renderPagination(pagination, result, {
|
||||
label: 'групп',
|
||||
onPage: page => { pageState.page = page; loadGroups(); },
|
||||
onSize: size => { pageState.size = size; pageState.page = 0; loadGroups(); }
|
||||
});
|
||||
persistPageState();
|
||||
trackUxEvent('реестр_групп_загружен', { total: Number(result.totalItems || 0), page: pageState.page });
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
renderTableError(groupsTbody, 10, error.message || 'Не удалось загрузить группы', loadGroups);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroups() {
|
||||
async function loadSubgroups(groupId) {
|
||||
try {
|
||||
subgroups = await api.get('/api/subgroups');
|
||||
subgroups = groupId ? await api.get(`/api/groups/${groupId}/subgroups`) : [];
|
||||
} catch (error) {
|
||||
console.error('Не удалось загрузить подгруппы');
|
||||
subgroups = [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGroupFilter() {
|
||||
const selectedIds = selectedFilterEducationFormIds();
|
||||
const filtered = selectedIds.length
|
||||
? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
|
||||
: allGroups;
|
||||
renderGroups(filtered);
|
||||
pageState.educationFormIds = selectedFilterEducationFormIds();
|
||||
pageState.page = 0;
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
function handleFilterEfChange(event) {
|
||||
@@ -247,7 +311,9 @@ export async function initGroups() {
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
renderTableEmpty(groupsTbody, 10, pageState.query || pageState.educationFormIds.length
|
||||
? 'По заданным условиям группы не найдены'
|
||||
: 'Групп пока нет');
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(group => `
|
||||
@@ -273,6 +339,7 @@ export async function initGroups() {
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(groupsTbody);
|
||||
}
|
||||
|
||||
function populateEfSelects(forms) {
|
||||
@@ -282,7 +349,7 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function renderFilterEfCheckboxes(forms) {
|
||||
const selectedIds = new Set(selectedFilterEducationFormIds());
|
||||
const selectedIds = new Set(pageState.educationFormIds);
|
||||
const allChecked = !selectedIds.size;
|
||||
filterEfCheckboxes.innerHTML = `
|
||||
<label class="checkbox-item">
|
||||
@@ -372,23 +439,40 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function populateCalendarSelect() {
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
const yearId = manageCalendarYearSelect.value;
|
||||
const matching = group && yearId
|
||||
? calendars.filter(calendar =>
|
||||
String(calendar.academicYearId) === String(yearId)
|
||||
&& String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode)
|
||||
&& String(calendar.specialtyProfileId) === String(group.specialtyProfileId)
|
||||
&& String(calendar.studyFormId) === String(group.educationFormId)
|
||||
)
|
||||
: [];
|
||||
manageCalendarSelect.innerHTML = matching.length
|
||||
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет подходящих графиков</option>';
|
||||
syncSelects(manageCalendarSelect);
|
||||
calendarCombobox?.search('');
|
||||
}
|
||||
|
||||
function initCalendarCombobox() {
|
||||
calendarCombobox = new AsyncCombobox(manageCalendarSelect, {
|
||||
placeholder: 'Найдите совместимый график',
|
||||
emptyLabel: 'Совместимых графиков не найдено',
|
||||
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`,
|
||||
optionDescription: calendar => `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`,
|
||||
load: async (query, selectedId) => {
|
||||
const group = allGroups.find(item => String(item.id) === String(manageCalendarGroupIdInput.value));
|
||||
if (!group || !manageCalendarYearSelect.value) return [];
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
academicYearId: manageCalendarYearSelect.value,
|
||||
specialtyId: String(group.specialtyId || group.specialityCode),
|
||||
profileId: String(group.specialtyProfileId),
|
||||
studyFormId: String(group.educationFormId),
|
||||
limit: '30'
|
||||
});
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/admin/academic-calendars/options?${params}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistPageState() {
|
||||
writeUrlState({
|
||||
groupQuery: pageState.query,
|
||||
groupPage: pageState.page || '',
|
||||
groupSize: pageState.size === 25 ? '' : pageState.size,
|
||||
groupSort: pageState.sort === 'name,asc' ? '' : pageState.sort,
|
||||
groupEducationForm: pageState.educationFormIds
|
||||
});
|
||||
}
|
||||
|
||||
async function createGroup(event) {
|
||||
@@ -463,12 +547,17 @@ export async function initGroups() {
|
||||
const btnCalendar = event.target.closest('.btn-manage-calendar');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать группу?',
|
||||
message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка архивирования');
|
||||
await alertAction({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +566,7 @@ export async function initGroups() {
|
||||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка восстановления');
|
||||
await alertAction({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +577,7 @@ export async function initGroups() {
|
||||
if (btnSubgroups) {
|
||||
const groupId = btnSubgroups.dataset.id;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
if (group) openSubgroupsModal(group);
|
||||
if (group) await openSubgroupsModal(group);
|
||||
}
|
||||
|
||||
if (btnCalendar) {
|
||||
@@ -498,10 +587,10 @@ export async function initGroups() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditGroupModal(id) {
|
||||
async function openEditGroupModal(id) {
|
||||
const group = allGroups.find(item => item.id == id);
|
||||
if (!group) {
|
||||
alert('Группа не найдена');
|
||||
await alertAction({ title: 'Группа не найдена', message: 'Обновите список и повторите действие.' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -519,13 +608,20 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
// --- Управление подгруппами ---
|
||||
function openSubgroupsModal(group) {
|
||||
async function openSubgroupsModal(group) {
|
||||
activeSubgroupGroup = group;
|
||||
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||||
manageSubgroupGroupIdInput.value = group.id;
|
||||
resetSubgroupForm();
|
||||
modalManageSubgroups.classList.add('open');
|
||||
renderSubgroupsList(group.id);
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка подгрупп...</td></tr>';
|
||||
try {
|
||||
await loadSubgroups(group.id);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(group.id);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Не удалось загрузить подгруппы', 'error');
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Не удалось загрузить подгруппы</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubgroupsList(groupId) {
|
||||
@@ -633,7 +729,7 @@ export async function initGroups() {
|
||||
try {
|
||||
await syncSubgroups(groupId, existingSubgroups, capacities);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
|
||||
await loadSubgroups();
|
||||
await loadSubgroups(groupId);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
@@ -660,7 +756,7 @@ export async function initGroups() {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||||
}
|
||||
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
|
||||
await loadSubgroups();
|
||||
await loadSubgroups(groupId);
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
@@ -727,12 +823,17 @@ export async function initGroups() {
|
||||
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Удалить подгруппу?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить подгруппу?',
|
||||
message: 'Удаление возможно только если подгруппа не используется в расписании.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
|
||||
resetSubgroupForm();
|
||||
await loadSubgroups();
|
||||
await loadSubgroups(groupId);
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||||
@@ -778,10 +879,7 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function assignmentCalendarTitle(assignment) {
|
||||
const calendar = calendars.find(item =>
|
||||
String(item.id) === String(assignment.calendarId)
|
||||
);
|
||||
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
|
||||
return assignment.calendarTitle || '-';
|
||||
}
|
||||
|
||||
function renderAssignmentSubjects(subjects) {
|
||||
@@ -831,7 +929,12 @@ export async function initGroups() {
|
||||
manageCalendarTbody.addEventListener('click', async (event) => {
|
||||
const deleteButton = event.target.closest('.btn-delete-assignment');
|
||||
if (!deleteButton) return;
|
||||
if (!confirm('Удалить назначение графика?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить назначение графика?',
|
||||
message: 'Группа потеряет связь с этим графиком на выбранный учебный год.',
|
||||
confirmLabel: 'Удалить назначение',
|
||||
danger: true
|
||||
})) return;
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
|
||||
const ACTION_LABELS = {
|
||||
@@ -83,7 +84,30 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
};
|
||||
|
||||
registryButton.hidden = false;
|
||||
fillReferenceSelects();
|
||||
const teacherCombobox = new AsyncCombobox(teacherSelect, {
|
||||
placeholder: 'Найдите преподавателя',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/users/teachers/options?${params}`);
|
||||
mergeDictionary('teachers', items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
const classroomCombobox = new AsyncCombobox(classroomSelect, {
|
||||
placeholder: 'Найдите аудиторию',
|
||||
optionLabel: classroomOptionLabel,
|
||||
optionDescription: room => [room.building, room.floor ? `${room.floor} этаж` : null, room.capacity ? `${room.capacity} мест` : null].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/classrooms/options?${params}`);
|
||||
mergeDictionary('classrooms', items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
bindEvents();
|
||||
renderRegistry();
|
||||
|
||||
@@ -226,15 +250,6 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
};
|
||||
}
|
||||
|
||||
function fillReferenceSelects() {
|
||||
teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher =>
|
||||
`<option value="${escapeHtml(teacher.id)}">${escapeHtml(teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`)}</option>`
|
||||
).join('');
|
||||
classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom =>
|
||||
`<option value="${escapeHtml(classroom.id)}">${escapeHtml(classroomOptionLabel(classroom))}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function setEditorValues(existing) {
|
||||
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
|
||||
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
|
||||
@@ -250,6 +265,8 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
);
|
||||
teacherSelect.value = String(effectiveTeacherId);
|
||||
classroomSelect.value = String(effectiveClassroomId);
|
||||
teacherCombobox.syncFromSelect();
|
||||
classroomCombobox.syncFromSelect();
|
||||
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
|
||||
commentInput.value = existing?.comment || '';
|
||||
}
|
||||
@@ -515,6 +532,12 @@ export function createScheduleOverridePanel({ role, dictionaries, getRange, onCh
|
||||
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
|
||||
return classroom ? classroomOptionLabel(classroom) : '';
|
||||
}
|
||||
|
||||
function mergeDictionary(key, items) {
|
||||
const current = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
|
||||
(items || []).forEach(item => current.set(String(item.id), item));
|
||||
dictionaries[key] = [...current.values()];
|
||||
}
|
||||
}
|
||||
|
||||
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
let qualityState = null;
|
||||
|
||||
export async function initScheduleQuality() {
|
||||
const root = document.querySelector('.quality-lab');
|
||||
if (!root) return;
|
||||
qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false };
|
||||
qualityState = {
|
||||
root,
|
||||
analysis: null,
|
||||
semesters: [],
|
||||
academicYearId: '',
|
||||
versions: [],
|
||||
visibleProblems: [],
|
||||
selectedProblemId: null,
|
||||
applying: false
|
||||
};
|
||||
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
|
||||
root.querySelector('#quality-year')?.addEventListener('change', handleYearChange);
|
||||
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
|
||||
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
|
||||
@@ -20,26 +36,18 @@ async function loadSemesters() {
|
||||
const select = qualityState?.root.querySelector('#quality-semester');
|
||||
if (!select) return;
|
||||
try {
|
||||
const semesters = await api.get('/api/schedule/semesters');
|
||||
select.innerHTML = (semesters || []).map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">
|
||||
${escapeHtml(semesterLabel(semester))}
|
||||
</option>
|
||||
`).join('');
|
||||
if (!semesters?.length) {
|
||||
select.innerHTML = '<option value="">Семестры не настроены</option>';
|
||||
qualityState.semesters = await api.get('/api/schedule/semesters');
|
||||
if (!qualityState.semesters?.length) {
|
||||
renderPeriodSelects('', '');
|
||||
qualityStatus('Сначала создайте учебный год и семестр.', 'warning');
|
||||
return;
|
||||
}
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester =>
|
||||
semester.startDate <= today && semester.endDate >= today
|
||||
);
|
||||
select.value = String(current?.id ?? semesters[0].id);
|
||||
const savedSemesterId = localStorage.getItem('magistr.quality.semesterId');
|
||||
if (semesters.some(semester => String(semester.id) === savedSemesterId)) {
|
||||
select.value = savedSemesterId;
|
||||
}
|
||||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.quality.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.quality.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
await loadVersions();
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
@@ -47,6 +55,39 @@ async function loadSemesters() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearChange() {
|
||||
const yearId = qualityState?.root.querySelector('#quality-year')?.value || '';
|
||||
const selected = resolveAcademicPeriod(qualityState.semesters, {
|
||||
academicYearId: yearId,
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(yearId, selected.semesterId);
|
||||
await loadVersions();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const yearSelect = qualityState?.root.querySelector('#quality-year');
|
||||
const semesterSelect = qualityState?.root.querySelector('#quality-semester');
|
||||
if (!yearSelect || !semesterSelect) return;
|
||||
const years = academicYearsFromSemesters(qualityState.semesters);
|
||||
qualityState.academicYearId = String(academicYearId || years[0]?.id || '');
|
||||
yearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
yearSelect.value = qualityState.academicYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
|
||||
const select = qualityState?.root.querySelector('#quality-version');
|
||||
@@ -69,6 +110,7 @@ async function loadVersions() {
|
||||
const saved = qualityState.versions.find(version => String(version.id) === savedVersionId);
|
||||
const published = qualityState.versions.find(version => version.status === 'PUBLISHED');
|
||||
select.value = String((saved || published || qualityState.versions[0]).id);
|
||||
localStorage.setItem('magistr.quality.yearId', qualityState.academicYearId);
|
||||
localStorage.setItem('magistr.quality.semesterId', semesterId);
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
@@ -100,6 +142,11 @@ async function runAnalysis() {
|
||||
);
|
||||
qualityState.selectedProblemId = null;
|
||||
renderAnalysis();
|
||||
trackUxEvent('качество_расписания_проверено', {
|
||||
semesterId,
|
||||
versionId,
|
||||
score: Number(qualityState.analysis.score || 0)
|
||||
});
|
||||
qualityStatus(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
|
||||
@@ -261,6 +308,7 @@ function handleRulePanelClick(event) {
|
||||
|
||||
function openRuleInConstructor(problem) {
|
||||
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
|
||||
localStorage.setItem('magistr.schedule.semesterId', String(qualityState.analysis.semesterId));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
|
||||
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
|
||||
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
|
||||
@@ -291,7 +339,7 @@ export function filterQualityProblems(problems, severity = '', category = '') {
|
||||
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
||||
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
|
||||
return severityMatches && categoryMatches;
|
||||
});
|
||||
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
|
||||
}
|
||||
|
||||
function problemCategory(type = '') {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
let versionState = null;
|
||||
|
||||
@@ -9,6 +15,7 @@ export async function initScheduleVersions() {
|
||||
versionState = {
|
||||
root,
|
||||
semesters: [],
|
||||
academicYearId: '',
|
||||
versions: [],
|
||||
history: [],
|
||||
modal: null,
|
||||
@@ -20,6 +27,7 @@ export async function initScheduleVersions() {
|
||||
|
||||
function bindEvents() {
|
||||
const root = versionState.root;
|
||||
root.querySelector('#version-year')?.addEventListener('change', handleYearChange);
|
||||
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
|
||||
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
|
||||
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
|
||||
@@ -37,34 +45,62 @@ function bindEvents() {
|
||||
}
|
||||
|
||||
async function loadSemesters() {
|
||||
const select = versionState.root.querySelector('#version-semester');
|
||||
try {
|
||||
versionState.semesters = await api.get('/api/schedule/semesters');
|
||||
select.innerHTML = versionState.semesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('');
|
||||
if (!versionState.semesters.length) {
|
||||
select.innerHTML = '<option value="">Семестры не настроены</option>';
|
||||
renderPeriodSelects('', '');
|
||||
setStatus('Сначала создайте учебный год и семестр.', 'error');
|
||||
return;
|
||||
}
|
||||
const savedSemester = localStorage.getItem('magistr.schedule.semesterId');
|
||||
const today = localIsoDate(new Date());
|
||||
const current = versionState.semesters.find(item => item.startDate <= today && item.endDate >= today);
|
||||
const selected = versionState.semesters.find(item => String(item.id) === savedSemester)
|
||||
|| current
|
||||
|| versionState.semesters[0];
|
||||
select.value = String(selected.id);
|
||||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearChange() {
|
||||
const yearId = versionState.root.querySelector('#version-year')?.value || '';
|
||||
const selected = resolveAcademicPeriod(versionState.semesters, {
|
||||
academicYearId: yearId,
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderPeriodSelects(yearId, selected.semesterId);
|
||||
await loadWorkspace();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const yearSelect = versionState.root.querySelector('#version-year');
|
||||
const semesterSelect = versionState.root.querySelector('#version-semester');
|
||||
const years = academicYearsFromSemesters(versionState.semesters);
|
||||
versionState.academicYearId = String(academicYearId || years[0]?.id || '');
|
||||
yearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
yearSelect.value = versionState.academicYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(versionState.semesters, versionState.academicYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
const semesterId = selectedSemesterId();
|
||||
if (!semesterId || versionState.loading) return;
|
||||
versionState.loading = true;
|
||||
localStorage.setItem('magistr.schedule.yearId', versionState.academicYearId);
|
||||
localStorage.setItem('magistr.schedule.semesterId', semesterId);
|
||||
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
|
||||
try {
|
||||
@@ -73,6 +109,7 @@ async function loadWorkspace() {
|
||||
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
|
||||
]);
|
||||
renderWorkspace();
|
||||
trackUxEvent('версии_расписания_загружены', { semesterId, total: versionState.versions.length });
|
||||
setStatus('Контур версий синхронизирован.', 'success');
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
|
||||
@@ -197,6 +234,7 @@ async function handleVersionAction(event) {
|
||||
if (!version) return;
|
||||
const action = button.dataset.action;
|
||||
if (action === 'edit') {
|
||||
localStorage.setItem('magistr.schedule.semesterId', String(version.semesterId));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
|
||||
navigateTo('schedule');
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { preferredScheduleSemester } from '../schedule-period.js';
|
||||
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
const TARGET_GROUPS = 'groups';
|
||||
@@ -61,7 +70,18 @@ export async function initScheduleView() {
|
||||
const tables = document.getElementById('schedule-view-tables');
|
||||
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
|
||||
const departmentSelect = document.getElementById('schedule-view-department');
|
||||
const academicYearSelect = document.getElementById('schedule-view-year');
|
||||
const semesterSelect = document.getElementById('schedule-view-semester');
|
||||
const restored = readUrlState({
|
||||
scheduleTarget: TARGET_GROUPS,
|
||||
scheduleEntity: '',
|
||||
scheduleDate: '',
|
||||
scheduleYear: '',
|
||||
scheduleSemester: '',
|
||||
scheduleSubject: '',
|
||||
scheduleLessonType: '',
|
||||
scheduleParity: ''
|
||||
});
|
||||
|
||||
const state = {
|
||||
dictionaries: {
|
||||
@@ -82,6 +102,10 @@ export async function initScheduleView() {
|
||||
const mobileMedia = window.matchMedia(MOBILE_QUERY);
|
||||
|
||||
setDefaultDate(dateInput);
|
||||
if (restored.scheduleDate) dateInput.value = restored.scheduleDate;
|
||||
if (targetSelect && role !== ROLE_DEPARTMENT) targetSelect.value = restored.scheduleTarget;
|
||||
restoreEntitySelect(restored.scheduleTarget, restored.scheduleEntity);
|
||||
const entityComboboxes = setupEntityComboboxes(state.dictionaries, role, userDepartmentId);
|
||||
configureDepartmentTarget();
|
||||
updatePeriodLabel();
|
||||
updateEntityFieldVisibility();
|
||||
@@ -91,6 +115,7 @@ export async function initScheduleView() {
|
||||
clearInactiveEntityValues();
|
||||
updateEntityFieldVisibility();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
loadButton?.addEventListener('click', loadData);
|
||||
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
|
||||
@@ -102,11 +127,22 @@ export async function initScheduleView() {
|
||||
dateInput?.addEventListener('change', () => {
|
||||
updatePeriodLabel();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
academicYearSelect?.addEventListener('change', () => {
|
||||
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, academicYearSelect.value);
|
||||
const selected = resolveAcademicPeriod(yearSemesters, { date: dateInput.value });
|
||||
renderPeriodSelects(academicYearSelect.value, selected.semesterId);
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
semesterSelect?.addEventListener('change', () => {
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
persistFilters();
|
||||
});
|
||||
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
|
||||
resultNextButton?.addEventListener('click', () => moveResult(1));
|
||||
@@ -131,8 +167,10 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
try {
|
||||
state.dictionaries = await loadDictionaries(role, userDepartmentId);
|
||||
selectCurrentSemester();
|
||||
Object.assign(state.dictionaries, await loadDictionaries(role, userDepartmentId));
|
||||
mergeLoadedDictionaries(state.dictionaries, entityComboboxes);
|
||||
restoreSimpleFilters(restored);
|
||||
selectCurrentSemester(restored.scheduleYear, restored.scheduleSemester);
|
||||
overridePanel = createScheduleOverridePanel({
|
||||
role,
|
||||
dictionaries: state.dictionaries,
|
||||
@@ -148,6 +186,11 @@ export async function initScheduleView() {
|
||||
resetResults(message);
|
||||
}
|
||||
|
||||
['schedule-view-group', 'schedule-view-teacher', 'schedule-view-classroom', 'schedule-view-department',
|
||||
'schedule-view-subject', 'schedule-view-lesson-type', 'schedule-view-parity'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('change', persistFilters);
|
||||
});
|
||||
|
||||
function configureDepartmentTarget() {
|
||||
if (role !== ROLE_DEPARTMENT) return;
|
||||
targetSelect.value = TARGET_DEPARTMENTS;
|
||||
@@ -186,8 +229,12 @@ export async function initScheduleView() {
|
||||
].forEach(([fieldTarget, id]) => {
|
||||
if (fieldTarget === target) return;
|
||||
if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return;
|
||||
const select = document.getElementById(id);
|
||||
if (select) select.value = '';
|
||||
if (entityComboboxes[fieldTarget]) {
|
||||
entityComboboxes[fieldTarget].clear();
|
||||
} else {
|
||||
const select = document.getElementById(id);
|
||||
if (select) select.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,6 +301,8 @@ export async function initScheduleView() {
|
||||
state.overrides = overrides;
|
||||
overridePanel.setRegistry(overrides, range);
|
||||
renderLessons(lessons, range, target, previousKey);
|
||||
persistFilters();
|
||||
trackUxEvent('расписание_найдено', { target, lessons: lessons.length });
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки расписания';
|
||||
showAlert('schedule-view-alert', message, 'error');
|
||||
@@ -321,17 +370,7 @@ export async function initScheduleView() {
|
||||
|
||||
function buildSectionContext(target) {
|
||||
const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : '';
|
||||
const selectedDepartmentId = role === ROLE_DEPARTMENT
|
||||
? userDepartmentId
|
||||
: (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : '');
|
||||
const allowedGroupIds = new Set();
|
||||
if (selectedGroupId) {
|
||||
allowedGroupIds.add(selectedGroupId);
|
||||
} else if (selectedDepartmentId) {
|
||||
state.dictionaries.groups
|
||||
.filter(group => String(group.departmentId) === String(selectedDepartmentId))
|
||||
.forEach(group => allowedGroupIds.add(String(group.id)));
|
||||
}
|
||||
const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId);
|
||||
return {
|
||||
selectedGroupId,
|
||||
selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '',
|
||||
@@ -421,6 +460,11 @@ export async function initScheduleView() {
|
||||
|
||||
function renderDesktopSchedule(section, range) {
|
||||
const slots = collectSlots(section.lessons);
|
||||
const semester = selectedSemester();
|
||||
if (semester) {
|
||||
renderDesktopSemesterSchedule(section, slots, semester);
|
||||
return;
|
||||
}
|
||||
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
|
||||
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
|
||||
if (!blocks.length || !slots.length) {
|
||||
@@ -432,6 +476,50 @@ export async function initScheduleView() {
|
||||
.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() {
|
||||
dayTabs.hidden = false;
|
||||
dayTabs.innerHTML = DAY_ORDER.map(day => `
|
||||
@@ -451,6 +539,11 @@ export async function initScheduleView() {
|
||||
|
||||
function renderMobileSchedule(section, range) {
|
||||
const slots = collectSlots(section.lessons);
|
||||
const semester = selectedSemester();
|
||||
if (semester) {
|
||||
renderMobileSemesterSchedule(section, slots, semester);
|
||||
return;
|
||||
}
|
||||
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
|
||||
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
|
||||
if (!blocks.length || !slots.length) {
|
||||
@@ -460,6 +553,34 @@ export async function initScheduleView() {
|
||||
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) {
|
||||
const dayDates = block.datesByDay[state.mobileDay] || {};
|
||||
const rows = slots.map(slot => {
|
||||
@@ -537,6 +658,12 @@ export async function initScheduleView() {
|
||||
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) {
|
||||
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
|
||||
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) {
|
||||
if (slotStatesEqual(states[0].state, states[1].state)) {
|
||||
return renderUnifiedSlot(states[0].state);
|
||||
@@ -640,6 +815,9 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
function getLessonWeeksText(lesson) {
|
||||
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
|
||||
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
|
||||
}
|
||||
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
|
||||
return '';
|
||||
}
|
||||
@@ -667,42 +845,7 @@ export async function initScheduleView() {
|
||||
if (startWeek <= 0) startWeek = 1;
|
||||
if (endWeek < startWeek) endWeek = startWeek;
|
||||
|
||||
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
|
||||
}
|
||||
|
||||
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;
|
||||
return formatLessonWeeksText(startWeek, endWeek);
|
||||
}
|
||||
|
||||
function renderLessonCard(lesson) {
|
||||
@@ -799,14 +942,38 @@ export async function initScheduleView() {
|
||||
.find(semester => String(semester.id) === String(semesterId)) || null;
|
||||
}
|
||||
|
||||
function selectCurrentSemester() {
|
||||
if (!semesterSelect) return;
|
||||
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value);
|
||||
semesterSelect.value = currentSemester ? String(currentSemester.id) : '';
|
||||
function selectCurrentSemester(academicYearId = '', semesterId = '') {
|
||||
if (!academicYearSelect || !semesterSelect) return;
|
||||
const selected = resolveAcademicPeriod(state.dictionaries.semesters, {
|
||||
academicYearId,
|
||||
semesterId,
|
||||
date: dateInput.value
|
||||
});
|
||||
renderPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
}
|
||||
|
||||
function renderPeriodSelects(academicYearId, semesterId) {
|
||||
const years = academicYearsFromSemesters(state.dictionaries.semesters);
|
||||
const selectedYearId = String(academicYearId || years[0]?.id || '');
|
||||
academicYearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
academicYearSelect.value = selectedYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, selectedYearId);
|
||||
semesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterOptionLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| preferredScheduleSemester(yearSemesters, dateInput.value)
|
||||
|| yearSemesters[0];
|
||||
semesterSelect.value = String(selectedSemester?.id || '');
|
||||
}
|
||||
|
||||
function synchronizeDateWithSemester() {
|
||||
const semester = selectedSemester();
|
||||
if (!semester) {
|
||||
@@ -826,6 +993,26 @@ export async function initScheduleView() {
|
||||
if (!range) return 1;
|
||||
return isoDay(dateInput.value || range.startDate);
|
||||
}
|
||||
|
||||
function persistFilters() {
|
||||
const target = activeTarget();
|
||||
const entitySelectId = {
|
||||
groups: 'schedule-view-group',
|
||||
teachers: 'schedule-view-teacher',
|
||||
classrooms: 'schedule-view-classroom',
|
||||
departments: 'schedule-view-department'
|
||||
}[target];
|
||||
writeUrlState({
|
||||
scheduleTarget: role === ROLE_DEPARTMENT ? '' : target,
|
||||
scheduleEntity: entitySelectId ? valueOf(entitySelectId) : '',
|
||||
scheduleDate: dateInput.value,
|
||||
scheduleYear: academicYearSelect?.value || '',
|
||||
scheduleSemester: semesterSelect?.value || '',
|
||||
scheduleSubject: valueOf('schedule-view-subject'),
|
||||
scheduleLessonType: valueOf('schedule-view-lesson-type'),
|
||||
scheduleParity: valueOf('schedule-view-parity')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyOverridePanel() {
|
||||
@@ -837,37 +1024,111 @@ function createEmptyOverridePanel() {
|
||||
}
|
||||
|
||||
async function loadDictionaries(role, userDepartmentId) {
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/schedule/semesters')
|
||||
]);
|
||||
|
||||
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
|
||||
? groups.filter(group => String(group.departmentId) === String(userDepartmentId))
|
||||
: groups;
|
||||
|
||||
fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы');
|
||||
fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели');
|
||||
fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории');
|
||||
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
|
||||
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
|
||||
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
|
||||
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
|
||||
|
||||
return {
|
||||
groups,
|
||||
teachers,
|
||||
classrooms,
|
||||
groups: [],
|
||||
teachers: [],
|
||||
classrooms: [],
|
||||
departments,
|
||||
semesters: semesters || []
|
||||
};
|
||||
}
|
||||
|
||||
function setupEntityComboboxes(dictionaries, role, userDepartmentId) {
|
||||
const definitions = {
|
||||
groups: {
|
||||
select: document.getElementById('schedule-view-group'),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
if (role === ROLE_DEPARTMENT && userDepartmentId) params.set('departmentId', userDepartmentId);
|
||||
return api.get(`/api/groups/options?${params}`);
|
||||
},
|
||||
label: item => item.name,
|
||||
description: item => [item.specialtyCode, item.specialtyProfileName, item.educationFormName].filter(Boolean).join(' · ')
|
||||
},
|
||||
teachers: {
|
||||
select: document.getElementById('schedule-view-teacher'),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/users/teachers/options?${params}`);
|
||||
},
|
||||
label: item => item.fullName || item.username,
|
||||
description: item => [item.jobTitle, item.departmentName].filter(Boolean).join(' · ')
|
||||
},
|
||||
classrooms: {
|
||||
select: document.getElementById('schedule-view-classroom'),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/classrooms/options?${params}`);
|
||||
},
|
||||
label: roomLabel,
|
||||
description: item => [item.capacity ? `${item.capacity} мест` : null, item.building].filter(Boolean).join(' · ')
|
||||
}
|
||||
};
|
||||
return Object.fromEntries(Object.entries(definitions).map(([key, definition]) => {
|
||||
const combo = new AsyncCombobox(definition.select, {
|
||||
placeholder: `Найдите ${key === 'groups' ? 'группу' : key === 'teachers' ? 'преподавателя' : 'аудиторию'}`,
|
||||
optionLabel: definition.label,
|
||||
optionDescription: definition.description,
|
||||
load: async (query, selectedId) => {
|
||||
const items = await definition.load(query, selectedId);
|
||||
mergeItems(dictionaries, key, items);
|
||||
return items;
|
||||
}
|
||||
});
|
||||
combo.preload('');
|
||||
return [key, combo];
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeLoadedDictionaries(dictionaries, combos) {
|
||||
Object.keys(combos).forEach(key => mergeItems(dictionaries, key, combos[key].items || []));
|
||||
}
|
||||
|
||||
function mergeItems(dictionaries, key, items) {
|
||||
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
|
||||
(items || []).forEach(item => merged.set(String(item.id), item));
|
||||
dictionaries[key] = [...merged.values()];
|
||||
}
|
||||
|
||||
function restoreEntitySelect(target, value) {
|
||||
if (!value) return;
|
||||
const id = {
|
||||
groups: 'schedule-view-group',
|
||||
teachers: 'schedule-view-teacher',
|
||||
classrooms: 'schedule-view-classroom',
|
||||
departments: 'schedule-view-department'
|
||||
}[target];
|
||||
const select = document.getElementById(id);
|
||||
if (select) select.innerHTML = `<option value="${escapeHtml(value)}" selected>Загрузка...</option>`;
|
||||
}
|
||||
|
||||
function restoreSimpleFilters(restored) {
|
||||
[
|
||||
['schedule-view-subject', restored.scheduleSubject],
|
||||
['schedule-view-lesson-type', restored.scheduleLessonType],
|
||||
['schedule-view-parity', restored.scheduleParity]
|
||||
].forEach(([id, value]) => {
|
||||
const select = document.getElementById(id);
|
||||
if (select && value && Array.from(select.options).some(option => option.value === String(value))) {
|
||||
select.value = String(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setDefaultDate(dateInput) {
|
||||
const today = new Date();
|
||||
dateInput.value = toIsoDate(today);
|
||||
@@ -960,6 +1221,12 @@ function buildScheduleSections(lessons, splitType, context) {
|
||||
.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) {
|
||||
if (splitType === TARGET_TEACHERS) {
|
||||
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
|
||||
@@ -1108,10 +1375,36 @@ export function findSemesterForDate(semesters, date) {
|
||||
}
|
||||
|
||||
export function scheduleRangeForSemester(selectedDate, semester) {
|
||||
const anchorDate = semester && !dateWithinSemester(selectedDate, semester)
|
||||
? semester.startDate
|
||||
: selectedDate;
|
||||
return twoWeekRange(anchorDate);
|
||||
if (semester?.startDate && semester?.endDate) {
|
||||
return {
|
||||
startDate: semester.startDate,
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { api } from '../api.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { refreshCustomDropdowns } from '../dropdown.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 1, label: 'Понедельник' },
|
||||
@@ -29,11 +41,42 @@ const SEMESTER_LABELS = {
|
||||
|
||||
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||||
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) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
}
|
||||
|
||||
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
|
||||
return !programmatic && !(type === 'input' && fromAsyncCombobox);
|
||||
}
|
||||
|
||||
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
|
||||
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
return available.find(version => String(version.id) === String(requestedVersionId))
|
||||
@@ -73,11 +116,16 @@ const LESSON_TYPE_LIMITS = [
|
||||
];
|
||||
|
||||
export async function initSchedule() {
|
||||
const dirtyScope = createDirtyScope('schedule-constructor', 'В конструкторе есть несохранённые изменения правила. Покинуть раздел?');
|
||||
const initialState = document.getElementById('schedule-initial-state');
|
||||
const initialContent = document.getElementById('schedule-initial-content');
|
||||
const ruleForm = document.getElementById('schedule-rule-form');
|
||||
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
|
||||
const ruleIdInput = document.getElementById('schedule-rule-id');
|
||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
||||
const scheduleVersionYearSelect = document.getElementById('schedule-version-year');
|
||||
const scheduleVersionSemesterSelect = document.getElementById('schedule-version-semester');
|
||||
const scheduleVersionSelect = document.getElementById('schedule-version-select');
|
||||
const scheduleVersionContext = document.getElementById('schedule-version-context');
|
||||
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
|
||||
@@ -101,6 +149,7 @@ export async function initSchedule() {
|
||||
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
|
||||
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
|
||||
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
|
||||
const ruleGroupSearch = document.getElementById('schedule-rule-group-search');
|
||||
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
|
||||
const ruleResetButton = document.getElementById('schedule-rule-reset');
|
||||
const slotAddButton = document.getElementById('schedule-slot-add');
|
||||
@@ -141,10 +190,16 @@ export async function initSchedule() {
|
||||
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
|
||||
|
||||
let rules = [];
|
||||
let visualRules = [];
|
||||
let rulesPage = 0;
|
||||
let rulesSize = 25;
|
||||
let rulesRequest = 0;
|
||||
let visualRequest = 0;
|
||||
let rulesVersionId = '';
|
||||
const rulesSearch = document.getElementById('schedule-rules-search');
|
||||
const rulesPagination = document.getElementById('schedule-rules-pagination');
|
||||
let subjects = [];
|
||||
let groups = [];
|
||||
let teachers = [];
|
||||
let classrooms = [];
|
||||
let lessonTypes = [];
|
||||
let subgroups = [];
|
||||
let timeSlots = [];
|
||||
@@ -160,8 +215,17 @@ export async function initSchedule() {
|
||||
let syncingVisualPeriod = false;
|
||||
let pendingConflictResolution = null;
|
||||
let visualContextTarget = null;
|
||||
let visualContextMenuPositionAnimation = null;
|
||||
let pendingSlotMove = null;
|
||||
let creatingDraft = false;
|
||||
let lastVersionYearValue = '';
|
||||
let lastVersionSemesterValue = '';
|
||||
let lastVersionValue = '';
|
||||
let slotComboboxes = [];
|
||||
let conflictComboboxes = [];
|
||||
let groupSearchRequest = 0;
|
||||
let subgroupRequest = 0;
|
||||
let syncingRuleForm = false;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -173,13 +237,30 @@ export async function initSchedule() {
|
||||
syncRuleSemesterWithVersion();
|
||||
setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
|
||||
await loadRules();
|
||||
dirtyScope.clear();
|
||||
} catch (error) {
|
||||
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
|
||||
} finally {
|
||||
initialContent?.classList.remove('is-loading');
|
||||
initialContent?.setAttribute('aria-busy', 'false');
|
||||
if (initialState) initialState.hidden = true;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
rulesSearch?.addEventListener('input', debounce(() => {
|
||||
rulesPage = 0;
|
||||
loadRules();
|
||||
}, 300));
|
||||
refreshRulesButton?.addEventListener('click', loadRules);
|
||||
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
|
||||
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
|
||||
scheduleVersionSelect?.addEventListener('change', async () => {
|
||||
const requestedValue = scheduleVersionSelect.value;
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
scheduleVersionSelect.value = lastVersionValue;
|
||||
return;
|
||||
}
|
||||
lastVersionValue = requestedValue;
|
||||
if (scheduleVersionSelect.value) {
|
||||
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
|
||||
}
|
||||
@@ -199,7 +280,10 @@ export async function initSchedule() {
|
||||
});
|
||||
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
|
||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||
slotAddButton?.addEventListener('click', () => {
|
||||
renderRuleSlots([...readRuleSlots(false), {}]);
|
||||
dirtyScope.mark();
|
||||
});
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||||
@@ -207,6 +291,7 @@ export async function initSchedule() {
|
||||
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||||
visualStage?.addEventListener('click', handleVisualStageClick);
|
||||
visualDrawer?.addEventListener('scroll', closeVisualContextMenu, true);
|
||||
conflictCloseButton?.addEventListener('click', closeConflictModal);
|
||||
conflictCancelButton?.addEventListener('click', closeConflictModal);
|
||||
conflictModal?.addEventListener('click', (event) => {
|
||||
@@ -276,6 +361,7 @@ export async function initSchedule() {
|
||||
const slots = readRuleSlots(false);
|
||||
slots.splice(Number(removeButton.dataset.index), 1);
|
||||
renderRuleSlots(slots.length ? slots : [{}]);
|
||||
dirtyScope.mark();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,9 +378,11 @@ export async function initSchedule() {
|
||||
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
|
||||
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
|
||||
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
|
||||
ruleGroupCheckboxes.addEventListener('change', () => {
|
||||
ruleGroupSearch?.addEventListener('input', debounce(() => searchRuleGroups(ruleGroupSearch.value), 320));
|
||||
ruleGroupCheckboxes.addEventListener('change', async () => {
|
||||
const slots = readRuleSlots(false);
|
||||
updateRuleGroupsText();
|
||||
renderRuleSlots(readRuleSlots(false));
|
||||
if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots);
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
closeRuleGroupsMenu();
|
||||
@@ -311,25 +399,39 @@ export async function initSchedule() {
|
||||
updatePreferenceHints();
|
||||
});
|
||||
ruleForm.addEventListener('submit', saveRule);
|
||||
ruleForm.addEventListener('input', event => {
|
||||
if (shouldMarkRuleFormDirty({
|
||||
programmatic: syncingRuleForm,
|
||||
type: event.type,
|
||||
fromAsyncCombobox: Boolean(event.target.closest('.async-combobox'))
|
||||
})) dirtyScope.mark();
|
||||
});
|
||||
ruleForm.addEventListener('change', event => {
|
||||
if (shouldMarkRuleFormDirty({ programmatic: syncingRuleForm, type: event.type })) dirtyScope.mark();
|
||||
});
|
||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||
}
|
||||
|
||||
async function loadBaseLists() {
|
||||
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
|
||||
[subjects, lessonTypes] = await Promise.all([
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/subgroups')
|
||||
api.get('/api/lesson-types')
|
||||
]);
|
||||
subgroups = [];
|
||||
populateSubjectSelects();
|
||||
await searchRuleGroups('');
|
||||
}
|
||||
|
||||
async function loadYears() {
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||
populateSemesterSelects();
|
||||
const selected = resolveAcademicPeriod(semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderVersionPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
|
||||
@@ -339,15 +441,27 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadScheduleVersions(preferredVersionId = null) {
|
||||
const responses = await Promise.all(semesters.map(semester =>
|
||||
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
|
||||
));
|
||||
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
|
||||
const semesterId = scheduleVersionSemesterSelect?.value || defaultScheduleSemesterId();
|
||||
if (!semesterId) {
|
||||
scheduleVersions = [];
|
||||
scheduleVersionSelect.innerHTML = '<option value="">Сначала выберите семестр</option>';
|
||||
updateVersionContext();
|
||||
setRuleEditorEnabled(false);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('magistr.schedule.yearId', scheduleVersionYearSelect?.value || '');
|
||||
localStorage.setItem('magistr.schedule.semesterId', semesterId);
|
||||
scheduleVersions = (await api.get(
|
||||
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
|
||||
)).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId
|
||||
|| localStorage.getItem('magistr.schedule.openVersionId')
|
||||
|| localStorage.getItem('magistr.schedule.versionId')
|
||||
|| '';
|
||||
localStorage.removeItem('magistr.schedule.openVersionId');
|
||||
const selected = chooseScheduleVersion(
|
||||
scheduleVersions,
|
||||
ruleSemesterSelect.value || defaultScheduleSemesterId(),
|
||||
semesterId,
|
||||
requestedVersionId
|
||||
);
|
||||
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
|
||||
@@ -359,6 +473,7 @@ export async function initSchedule() {
|
||||
].filter(Boolean).join('')
|
||||
: '<option value="">Расписания не созданы</option>';
|
||||
scheduleVersionSelect.value = selected ? String(selected.id) : '';
|
||||
lastVersionValue = scheduleVersionSelect.value;
|
||||
if (selected) {
|
||||
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
|
||||
}
|
||||
@@ -366,6 +481,64 @@ export async function initSchedule() {
|
||||
setRuleEditorEnabled(Boolean(selected));
|
||||
}
|
||||
|
||||
async function handleVersionYearChange() {
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
|
||||
return;
|
||||
}
|
||||
const yearId = scheduleVersionYearSelect?.value || '';
|
||||
const yearSemesters = semestersForAcademicYear(semesters, yearId);
|
||||
const selected = resolveAcademicPeriod(yearSemesters, { date: localIsoDate(new Date()) });
|
||||
renderVersionPeriodSelects(yearId, selected.semesterId);
|
||||
await applyVersionPeriodChange();
|
||||
}
|
||||
|
||||
async function handleVersionSemesterChange() {
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
|
||||
return;
|
||||
}
|
||||
await applyVersionPeriodChange();
|
||||
}
|
||||
|
||||
async function applyVersionPeriodChange() {
|
||||
lastVersionYearValue = scheduleVersionYearSelect?.value || '';
|
||||
lastVersionSemesterValue = scheduleVersionSemesterSelect?.value || '';
|
||||
localStorage.setItem('magistr.schedule.yearId', lastVersionYearValue);
|
||||
localStorage.setItem('magistr.schedule.semesterId', lastVersionSemesterValue);
|
||||
try {
|
||||
await loadScheduleVersions();
|
||||
resetRuleForm();
|
||||
syncRuleSemesterWithVersion();
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
showAlert('schedule-rule-alert', error.message || 'Не удалось сменить учебный период', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderVersionPeriodSelects(academicYearId, semesterId) {
|
||||
if (!scheduleVersionYearSelect || !scheduleVersionSemesterSelect) return;
|
||||
const years = academicYearsFromSemesters(semesters);
|
||||
const selectedYearId = String(academicYearId || years[0]?.id || '');
|
||||
scheduleVersionYearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
scheduleVersionYearSelect.value = selectedYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(semesters, selectedYearId);
|
||||
scheduleVersionSemesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(shortSemesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
|
||||
lastVersionYearValue = scheduleVersionYearSelect.value;
|
||||
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
|
||||
refreshCustomDropdowns(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
|
||||
}
|
||||
|
||||
function versionOptionGroup(label, versions) {
|
||||
if (!versions.length) return '';
|
||||
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
|
||||
@@ -376,12 +549,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function defaultScheduleSemesterId() {
|
||||
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId');
|
||||
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId));
|
||||
if (saved) return saved.id;
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
|
||||
return current?.id || semesters[0]?.id || '';
|
||||
return resolveAcademicPeriod(semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
}).semesterId;
|
||||
}
|
||||
|
||||
function updateVersionContext() {
|
||||
@@ -476,6 +648,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
const request = ++rulesRequest;
|
||||
++visualRequest;
|
||||
visualRules = [];
|
||||
rules = [];
|
||||
rulesPagination?.replaceChildren();
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
const version = selectedScheduleVersion();
|
||||
if (!version) {
|
||||
@@ -484,26 +661,43 @@ export async function initSchedule() {
|
||||
setVisualState('Нет выбранного расписания');
|
||||
return;
|
||||
}
|
||||
if (rulesVersionId !== String(version.id)) {
|
||||
rulesPage = 0;
|
||||
rulesVersionId = String(version.id);
|
||||
}
|
||||
try {
|
||||
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
||||
const params = new URLSearchParams({ versionId: version.id, page: rulesPage, size: rulesSize, query: rulesSearch?.value.trim() || '' });
|
||||
const page = await api.get(`/api/admin/schedule-rules/page?${params}`);
|
||||
if (request !== rulesRequest) return;
|
||||
if (rulesPage > 0 && rulesPage >= page.totalPages) {
|
||||
rulesPage = Math.max(0, page.totalPages - 1);
|
||||
return loadRules();
|
||||
}
|
||||
rules = page.items;
|
||||
renderPagination(rulesPagination, page, {
|
||||
label: 'правил',
|
||||
onPage: value => { rulesPage = value; loadRules(); },
|
||||
onSize: value => { rulesSize = value; rulesPage = 0; loadRules(); }
|
||||
});
|
||||
if (!visualPeriodTouched) {
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
renderRules();
|
||||
renderVisualSchedule();
|
||||
openRequestedRule();
|
||||
if (visualDrawer?.classList.contains('open')) await loadVisualRules();
|
||||
await openRequestedRule();
|
||||
} catch (error) {
|
||||
if (request !== rulesRequest) return;
|
||||
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
setVisualState('Ошибка загрузки правил расписания');
|
||||
}
|
||||
}
|
||||
|
||||
function openRequestedRule() {
|
||||
async function openRequestedRule() {
|
||||
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
|
||||
if (!requestedRuleId) return;
|
||||
localStorage.removeItem('magistr.schedule.openRuleId');
|
||||
const rule = rules.find(item => String(item.id) === requestedRuleId);
|
||||
if (!rule) {
|
||||
const rule = await api.get(`/api/admin/schedule-rules/${encodeURIComponent(requestedRuleId)}`);
|
||||
if (!rule || String(rule.scheduleVersionId) !== String(selectedScheduleVersion()?.id)) {
|
||||
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
|
||||
return;
|
||||
}
|
||||
@@ -512,7 +706,7 @@ export async function initSchedule() {
|
||||
|
||||
function renderRules() {
|
||||
if (!rules.length) {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила не найдены</td></tr>';
|
||||
renderVisualSchedule();
|
||||
return;
|
||||
}
|
||||
@@ -551,10 +745,12 @@ export async function initSchedule() {
|
||||
visualToggleButton?.setAttribute('aria-expanded', 'true');
|
||||
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
|
||||
if (visualBackdrop) visualBackdrop.hidden = false;
|
||||
loadVisualRules();
|
||||
}
|
||||
|
||||
function closeVisualDrawer() {
|
||||
if (!visualDrawer) return;
|
||||
closeVisualContextMenu();
|
||||
visualDrawer.classList.remove('open');
|
||||
visualDrawer.setAttribute('aria-hidden', 'true');
|
||||
visualToggleButton?.classList.remove('open');
|
||||
@@ -563,34 +759,49 @@ export async function initSchedule() {
|
||||
if (visualBackdrop) visualBackdrop.hidden = true;
|
||||
}
|
||||
|
||||
async function loadVisualRules() {
|
||||
const version = selectedScheduleVersion();
|
||||
if (!version) return;
|
||||
const request = ++visualRequest;
|
||||
setVisualState('Загрузка визуального расписания...');
|
||||
try {
|
||||
const loaded = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
||||
if (request !== visualRequest) return;
|
||||
visualRules = loaded;
|
||||
renderVisualSchedule();
|
||||
} catch (error) {
|
||||
if (request === visualRequest) setVisualState(error.message || 'Ошибка загрузки визуального расписания');
|
||||
}
|
||||
}
|
||||
|
||||
function renderVisualSchedule() {
|
||||
if (!visualStage || !visualSummary) return;
|
||||
if (!visualStage || !visualSummary || !visualDrawer?.classList.contains('open')) return;
|
||||
if (!selectedVisualSemesterId) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||||
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||||
return;
|
||||
}
|
||||
if (!rules.length) {
|
||||
if (!visualRules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Нет сохранённых правил';
|
||||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||||
return;
|
||||
}
|
||||
|
||||
const visualRules = visualRulesForSelectedPeriod();
|
||||
const periodRules = visualRulesForSelectedPeriod();
|
||||
const periodLabel = visualPeriodLabel();
|
||||
if (!visualRules.length) {
|
||||
if (!periodRules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = `${periodLabel} · правил нет`;
|
||||
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
||||
return;
|
||||
}
|
||||
|
||||
const allGroups = collectVisualGroups(visualRules);
|
||||
const allGroups = collectVisualGroups(periodRules);
|
||||
syncVisualGroupFilter(allGroups);
|
||||
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
|
||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(periodRules);
|
||||
if (!allGroups.length || !slots.length) {
|
||||
visualSummary.textContent = 'Недостаточно данных для построения';
|
||||
setVisualState('У правил должны быть группы и слоты занятий');
|
||||
@@ -602,7 +813,7 @@ export async function initSchedule() {
|
||||
return;
|
||||
}
|
||||
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, periodRules);
|
||||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||||
if (!rows.length) {
|
||||
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||
@@ -610,7 +821,7 @@ export async function initSchedule() {
|
||||
return;
|
||||
}
|
||||
|
||||
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||
visualSummary.textContent = `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||
visualStage.innerHTML = `
|
||||
<div class="schedule-visual-table-wrap">
|
||||
<table class="schedule-visual-table">
|
||||
@@ -870,7 +1081,7 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function openVisualContextMenu(button) {
|
||||
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(button.dataset.ruleId));
|
||||
if (!rule) {
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
return;
|
||||
@@ -882,6 +1093,23 @@ export async function initSchedule() {
|
||||
};
|
||||
if (!visualContextMenu) return;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -889,6 +1117,8 @@ export async function initSchedule() {
|
||||
visualContextTarget = null;
|
||||
if (!visualContextMenu) return;
|
||||
visualContextMenu.hidden = true;
|
||||
visualContextMenuPositionAnimation?.cancel();
|
||||
visualContextMenuPositionAnimation = null;
|
||||
}
|
||||
|
||||
async function handleVisualContextMenuClick(event) {
|
||||
@@ -896,7 +1126,7 @@ export async function initSchedule() {
|
||||
if (!actionButton || !visualContextTarget) return;
|
||||
const action = actionButton.dataset.action;
|
||||
const target = { ...visualContextTarget };
|
||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
|
||||
if (!rule) {
|
||||
closeVisualContextMenu();
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
@@ -923,7 +1153,12 @@ export async function initSchedule() {
|
||||
|
||||
async function deleteRuleFromVisual(rule) {
|
||||
closeVisualContextMenu();
|
||||
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить правило расписания?',
|
||||
message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`,
|
||||
confirmLabel: 'Удалить правило',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/schedule-rules/' + rule.id);
|
||||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||||
@@ -934,7 +1169,7 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function openMoveSlotModal(target) {
|
||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
|
||||
const slotIndex = findRuleSlotIndex(rule, target);
|
||||
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
|
||||
if (!rule || !slot) {
|
||||
@@ -977,7 +1212,7 @@ export async function initSchedule() {
|
||||
event.preventDefault();
|
||||
if (!pendingSlotMove) return;
|
||||
hideAlert('schedule-move-slot-alert');
|
||||
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === pendingSlotMove.ruleId);
|
||||
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
|
||||
if (!rule || !slot) {
|
||||
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
||||
@@ -1147,7 +1382,7 @@ export async function initSchedule() {
|
||||
|
||||
function visualRulesForSelectedPeriod() {
|
||||
if (!selectedVisualSemesterId) return [];
|
||||
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||
return visualRules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||
}
|
||||
|
||||
function firstSemesterWithRules() {
|
||||
@@ -1209,14 +1444,19 @@ export async function initSchedule() {
|
||||
const deleteButton = event.target.closest('.btn-delete-rule');
|
||||
|
||||
if (editButton) {
|
||||
const rule = rules.find(item => item.id == editButton.dataset.id)
|
||||
const rule = [...rules, ...visualRules].find(item => item.id == editButton.dataset.id)
|
||||
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
|
||||
fillRuleForm(rule);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить правило расписания?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить правило расписания?',
|
||||
message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.',
|
||||
confirmLabel: 'Удалить правило',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
|
||||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||||
@@ -1253,7 +1493,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function finishSuccessfulRuleSave(saved) {
|
||||
const mode = ruleIdInput.value ? 'update' : 'create';
|
||||
const versionId = scheduleVersionSelect.value;
|
||||
dirtyScope.clear();
|
||||
resetRuleForm();
|
||||
trackUxEvent('правило_расписания_сохранено', { mode, versionId });
|
||||
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
|
||||
await loadRules();
|
||||
}
|
||||
@@ -1378,6 +1622,8 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function renderConflictSlots(rule, conflictFields = []) {
|
||||
conflictComboboxes.forEach(combobox => combobox.destroy());
|
||||
conflictComboboxes = [];
|
||||
const slots = rule.slots || [];
|
||||
if (!conflictSlotsContainer) return;
|
||||
if (!slots.length) {
|
||||
@@ -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-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" aria-label="Преподаватель конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" aria-label="Аудитория конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
initializeEntityComboboxes(conflictSlotsContainer, '.conflict-slot-teacher', '.conflict-slot-classroom', conflictComboboxes);
|
||||
}
|
||||
|
||||
function conflictFieldClass(field, fields) {
|
||||
@@ -1529,7 +1776,7 @@ export async function initSchedule() {
|
||||
|
||||
function buildRulePayload() {
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
const slots = readRuleSlots(true).map(ruleSlotToPayload);
|
||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
|
||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||
@@ -1548,17 +1795,22 @@ export async function initSchedule() {
|
||||
};
|
||||
}
|
||||
|
||||
function fillRuleForm(rule) {
|
||||
async function fillRuleForm(rule) {
|
||||
ruleFormTitle.textContent = 'Редактирование правила расписания';
|
||||
ruleIdInput.value = rule.id;
|
||||
ruleSubjectSelect.value = rule.subjectId || '';
|
||||
ruleSemesterSelect.value = rule.semesterId || '';
|
||||
fillTypeLimits(rule);
|
||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||
cacheRuleGroups(rule.groupIds || [], rule.groupNames || []);
|
||||
renderRuleGroups(rule.groupIds || []);
|
||||
searchRuleGroups(ruleGroupSearch?.value || '');
|
||||
ruleSlotsContainer.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Загрузка подгрупп выбранных групп...</div>';
|
||||
if (!await loadSubgroupsForGroups(rule.groupIds || [])) return;
|
||||
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
||||
syncRuleSemesterWithVersion();
|
||||
hideAlert('schedule-rule-alert');
|
||||
dirtyScope.clear();
|
||||
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
@@ -1567,11 +1819,14 @@ export async function initSchedule() {
|
||||
ruleFormTitle.textContent = 'Новое правило расписания';
|
||||
ruleIdInput.value = '';
|
||||
fillTypeLimits({});
|
||||
subgroupRequest += 1;
|
||||
subgroups = [];
|
||||
renderRuleGroups([]);
|
||||
renderRuleSlots([{}]);
|
||||
syncRuleSemesterWithVersion();
|
||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||
hideAlert('schedule-rule-alert');
|
||||
dirtyScope.clear();
|
||||
}
|
||||
|
||||
function renderRuleGroups(selectedIds) {
|
||||
@@ -1591,7 +1846,60 @@ export async function initSchedule() {
|
||||
updateRuleGroupsText();
|
||||
}
|
||||
|
||||
async function searchRuleGroups(query = '') {
|
||||
const request = ++groupSearchRequest;
|
||||
const selectedBeforeSearch = selectedRuleGroupIds();
|
||||
if (!groups.length) {
|
||||
ruleGroupCheckboxes.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Поиск групп...</div>';
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
|
||||
const loaded = await api.get(`/api/groups/options?${params}`);
|
||||
if (request !== groupSearchRequest) return;
|
||||
const selectedIds = selectedRuleGroupIds().length ? selectedRuleGroupIds() : selectedBeforeSearch;
|
||||
const selectedGroups = groups.filter(group => selectedIds.includes(String(group.id)));
|
||||
groups = mergeEntitiesById([...selectedGroups, ...(loaded || [])]);
|
||||
renderRuleGroups(selectedIds);
|
||||
} catch (error) {
|
||||
if (request !== groupSearchRequest) return;
|
||||
ruleGroupCheckboxes.innerHTML = `<div class="loading-row view-state-error">${escapeHtml(error.message || 'Не удалось найти группы')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroupsForGroups(groupIds) {
|
||||
const request = ++subgroupRequest;
|
||||
const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))];
|
||||
if (!normalizedIds.length) {
|
||||
subgroups = [];
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
normalizedIds.forEach(groupId => params.append('groupId', groupId));
|
||||
const loaded = await api.get(`/api/subgroups?${params}`);
|
||||
if (request !== subgroupRequest) return false;
|
||||
subgroups = loaded || [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (request !== subgroupRequest) return false;
|
||||
subgroups = [];
|
||||
showAlert('schedule-rule-alert', error.message || 'Не удалось загрузить подгруппы выбранных групп', 'error');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheRuleGroups(ids, names) {
|
||||
const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` }));
|
||||
groups = mergeEntitiesById([...groups, ...additions]);
|
||||
}
|
||||
|
||||
function mergeEntitiesById(items) {
|
||||
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
|
||||
}
|
||||
|
||||
function renderRuleSlots(slots) {
|
||||
slotComboboxes.forEach(combobox => combobox.destroy());
|
||||
slotComboboxes = [];
|
||||
const normalizedSlots = slots?.length ? slots : [{}];
|
||||
const isSingleSlot = normalizedSlots.length === 1;
|
||||
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
|
||||
@@ -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-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" aria-label="Преподаватель слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" aria-label="Аудитория слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
@@ -1610,6 +1918,7 @@ export async function initSchedule() {
|
||||
<div class="schedule-preference-hint" data-preference-hint></div>
|
||||
</div>
|
||||
`).join('');
|
||||
initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes);
|
||||
updatePreferenceHints();
|
||||
}
|
||||
|
||||
@@ -1672,6 +1981,8 @@ export async function initSchedule() {
|
||||
subgroupIds,
|
||||
teacherId: Number(row.querySelector('.slot-teacher').value),
|
||||
classroomId: Number(row.querySelector('.slot-classroom').value),
|
||||
teacherName: selectedOptionLabel(row.querySelector('.slot-teacher')),
|
||||
classroomName: selectedOptionLabel(row.querySelector('.slot-classroom')),
|
||||
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
||||
lessonFormat: row.querySelector('.slot-format').value
|
||||
};
|
||||
@@ -1973,13 +2284,49 @@ export async function initSchedule() {
|
||||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||||
}
|
||||
|
||||
function toTeacherOption(teacher) {
|
||||
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
|
||||
function initializeEntityComboboxes(container, teacherSelector, classroomSelector, collector) {
|
||||
container.querySelectorAll(teacherSelector).forEach(select => {
|
||||
collector.push(new AsyncCombobox(select, {
|
||||
allowEmpty: false,
|
||||
placeholder: 'Найдите преподавателя',
|
||||
emptyLabel: 'Преподаватели не найдены',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/users/teachers/options?${params}`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
container.querySelectorAll(classroomSelector).forEach(select => {
|
||||
collector.push(new AsyncCombobox(select, {
|
||||
allowEmpty: false,
|
||||
placeholder: 'Найдите аудиторию',
|
||||
emptyLabel: 'Аудитории не найдены',
|
||||
optionLabel: classroom => classroom.name || `Аудитория ${classroom.id}`,
|
||||
optionDescription: classroom => [
|
||||
classroom.building,
|
||||
classroom.floor != null ? `${classroom.floor} этаж` : '',
|
||||
classroom.capacity ? `${classroom.capacity} мест` : ''
|
||||
].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/classrooms/options?${params}`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function toClassroomOption(classroom) {
|
||||
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
|
||||
return { value: classroom.id, label: `${classroom.name}${capacity}` };
|
||||
function selectedEntityOption(id, label) {
|
||||
return id
|
||||
? `<option value="${escapeHtml(String(id))}" selected>${escapeHtml(label || `ID ${id}`)}</option>`
|
||||
: '<option value="" selected></option>';
|
||||
}
|
||||
|
||||
function selectedOptionLabel(select) {
|
||||
return select?.selectedOptions?.[0]?.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
function toLessonTypeOption(lessonType) {
|
||||
@@ -2049,9 +2396,16 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function syncSelects(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
const targets = selects.filter(Boolean);
|
||||
const previousSyncState = syncingRuleForm;
|
||||
if (targets.some(select => ruleForm.contains(select))) syncingRuleForm = true;
|
||||
try {
|
||||
targets.forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
} finally {
|
||||
syncingRuleForm = previousSyncState;
|
||||
}
|
||||
}
|
||||
|
||||
function visualCellKey(groupId, dayOfWeek, slotKey) {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
export async function initSubjects() {
|
||||
const subjectsTbody = document.getElementById('subjects-tbody');
|
||||
const createSubjectForm = document.getElementById('create-subject-form');
|
||||
const newSubjectDeptSelect = document.getElementById('new-subject-department');
|
||||
const departmentFilter = document.getElementById('subjects-department-filter');
|
||||
const searchInput = document.getElementById('subjects-search');
|
||||
const sortSelect = document.getElementById('subjects-sort');
|
||||
const pagination = document.getElementById('subjects-pagination');
|
||||
|
||||
// Модалка управления преподавателями
|
||||
const modalManageTeachers = document.getElementById('modal-manage-teachers');
|
||||
@@ -16,9 +26,19 @@ export async function initSubjects() {
|
||||
const manageTsTbody = document.getElementById('manage-ts-tbody');
|
||||
|
||||
let allSubjects = [];
|
||||
let allTeachers = [];
|
||||
let allDepartments = [];
|
||||
let teacherSubjects = [];
|
||||
let teacherCombobox = null;
|
||||
const restored = readUrlState({ subjectQuery: '', subjectDepartment: '', subjectPage: '0', subjectSize: '25', subjectSort: 'name,asc' });
|
||||
const pageState = {
|
||||
query: restored.subjectQuery,
|
||||
departmentId: restored.subjectDepartment,
|
||||
page: Math.max(Number(restored.subjectPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.subjectSize)) ? Number(restored.subjectSize) : 25,
|
||||
sort: restored.subjectSort
|
||||
};
|
||||
searchInput.value = pageState.query;
|
||||
sortSelect.value = pageState.sort;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
@@ -34,15 +54,43 @@ export async function initSubjects() {
|
||||
modalManageTeachers.addEventListener('click', (e) => {
|
||||
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
|
||||
});
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
pageState.query = searchInput.value.trim();
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
}));
|
||||
departmentFilter.addEventListener('change', () => {
|
||||
pageState.departmentId = departmentFilter.value;
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
});
|
||||
sortSelect.addEventListener('change', () => {
|
||||
pageState.sort = sortSelect.value;
|
||||
pageState.page = 0;
|
||||
loadSubjects();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
[allTeachers, allDepartments] = await Promise.all([
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/departments')
|
||||
]);
|
||||
allDepartments = await api.get('/api/departments');
|
||||
teacherCombobox = new AsyncCombobox(manageTsTeacherSelect, {
|
||||
placeholder: 'Найдите преподавателя по ФИО',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const teachers = await api.get(`/api/users/teachers/options?${params}`);
|
||||
const subjectId = manageTsSubjectIdInput.value;
|
||||
const assigned = new Set(teacherSubjects
|
||||
.filter(item => String(item.subjectId) === String(subjectId))
|
||||
.map(item => String(item.userId)));
|
||||
return teachers.filter(teacher => !assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
|
||||
}
|
||||
});
|
||||
populateDepartmentsSelect();
|
||||
populateDepartmentFilter();
|
||||
await loadSubjects();
|
||||
} catch (e) {
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
|
||||
@@ -58,22 +106,58 @@ export async function initSubjects() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateDepartmentFilter() {
|
||||
departmentFilter.innerHTML = '<option value="">Все кафедры</option>' + allDepartments.map(department =>
|
||||
`<option value="${department.id}">${escapeHtml(department.departmentName || department.name || department.id)}</option>`
|
||||
).join('');
|
||||
departmentFilter.value = pageState.departmentId;
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
renderTableLoading(subjectsTbody, 6, 'Загрузка дисциплин...');
|
||||
hideAlert('subjects-alert');
|
||||
try {
|
||||
[allSubjects, teacherSubjects] = await Promise.all([
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/teacher-subjects')
|
||||
]);
|
||||
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) {
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||
renderTableError(subjectsTbody, 6, e.message || 'Не удалось загрузить дисциплины', loadSubjects);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeacherAssignments(subjectIds) {
|
||||
if (!subjectIds.length) return [];
|
||||
const params = new URLSearchParams();
|
||||
subjectIds.forEach(subjectId => params.append('subjectId', subjectId));
|
||||
return api.get(`/api/teacher-subjects?${params}`);
|
||||
}
|
||||
|
||||
function renderSubjects() {
|
||||
if (!allSubjects || !allSubjects.length) {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
|
||||
renderTableEmpty(subjectsTbody, 6, pageState.query || pageState.departmentId
|
||||
? 'Дисциплины не найдены'
|
||||
: 'Дисциплин пока нет');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,6 +190,7 @@ export async function initSubjects() {
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
markTableReady(subjectsTbody);
|
||||
}
|
||||
|
||||
async function createSubject(e) {
|
||||
@@ -139,12 +224,17 @@ export async function initSubjects() {
|
||||
const btnTeachers = e.target.closest('.btn-manage-teachers');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать дисциплину?',
|
||||
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/subjects/' + btnDelete.dataset.id);
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления');
|
||||
showAlert('subjects-alert', error.message || 'Ошибка архивирования', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,22 +272,10 @@ export async function initSubjects() {
|
||||
}
|
||||
|
||||
function populateTeachersSelect(subjectId) {
|
||||
// Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине
|
||||
const assignedUserIds = new Set(
|
||||
teacherSubjects
|
||||
.filter(ts => String(ts.subjectId) === String(subjectId))
|
||||
.map(ts => String(ts.userId))
|
||||
);
|
||||
|
||||
const availableTeachers = allTeachers.filter(t => !assignedUserIds.has(String(t.id)));
|
||||
|
||||
if (!availableTeachers.length) {
|
||||
manageTsTeacherSelect.innerHTML = '<option value="">Все преподаватели привязаны</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
manageTsTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||
availableTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)} (${escapeHtml(t.username)})</option>`).join('');
|
||||
manageTsTeacherSelect.innerHTML = '<option value=""></option>';
|
||||
manageTsTeacherSelect.value = '';
|
||||
teacherCombobox?.syncFromSelect();
|
||||
teacherCombobox?.search('');
|
||||
}
|
||||
|
||||
async function saveTeacherAssignment(e) {
|
||||
@@ -219,7 +297,7 @@ export async function initSubjects() {
|
||||
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
|
||||
|
||||
// Перезагрузим привязки и обновим интерфейс
|
||||
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjectTeachers(subjectId);
|
||||
populateTeachersSelect(subjectId);
|
||||
|
||||
@@ -233,7 +311,12 @@ export async function initSubjects() {
|
||||
async function handleTeacherAssignmentTableClick(e) {
|
||||
const btnDelete = e.target.closest('.btn-delete-ts');
|
||||
if (!btnDelete) return;
|
||||
if (!confirm('Удалить привязку преподавателя?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить привязку преподавателя?',
|
||||
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
|
||||
confirmLabel: 'Удалить привязку',
|
||||
danger: true
|
||||
})) return;
|
||||
const subjectId = manageTsSubjectIdInput.value;
|
||||
|
||||
try {
|
||||
@@ -243,14 +326,14 @@ export async function initSubjects() {
|
||||
});
|
||||
|
||||
// Перезагрузим привязки
|
||||
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||||
renderSubjectTeachers(subjectId);
|
||||
populateTeachersSelect(subjectId);
|
||||
|
||||
// Обновим главную таблицу
|
||||
renderSubjects();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления привязки');
|
||||
showAlert('manage-ts-alert', error.message || 'Ошибка удаления привязки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,4 +343,14 @@ export async function initSubjects() {
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
subjectQuery: pageState.query,
|
||||
subjectDepartment: pageState.departmentId,
|
||||
subjectPage: pageState.page || '',
|
||||
subjectSize: pageState.size === 25 ? '' : pageState.size,
|
||||
subjectSort: pageState.sort === 'name,asc' ? '' : pageState.sort
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { api, getSession } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
import { alertAction } from '../dialog.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
|
||||
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
|
||||
|
||||
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return `/api/users/teachers/options?${params}`;
|
||||
}
|
||||
|
||||
export async function initTeacherAbsences() {
|
||||
const session = getSession();
|
||||
const role = session?.role;
|
||||
@@ -27,6 +36,20 @@ export async function initTeacherAbsences() {
|
||||
let preferenceItems = [];
|
||||
let changeItems = [];
|
||||
|
||||
new AsyncCombobox(teacherSelect, {
|
||||
placeholder: 'Найдите преподавателя по ФИО',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username || `ID ${teacher.id}`,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
return api.get(teacherAbsenceTeacherOptionsUrl({
|
||||
query,
|
||||
selectedId,
|
||||
role,
|
||||
departmentId: session?.departmentId
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
setDefaultPeriod(startInput, endInput);
|
||||
form?.addEventListener('submit', createAbsence);
|
||||
statusFilter?.addEventListener('change', loadAbsences);
|
||||
@@ -46,21 +69,7 @@ export async function initTeacherAbsences() {
|
||||
document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews);
|
||||
changeReviewList?.addEventListener('click', handleChangeReview);
|
||||
|
||||
await Promise.all([loadTeachers(), loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
|
||||
|
||||
async function loadTeachers() {
|
||||
try {
|
||||
let teachers = await api.get('/api/users/teachers');
|
||||
if (role === 'DEPARTMENT' && session?.departmentId) {
|
||||
teachers = teachers.filter(item => String(item.departmentId) === String(session.departmentId));
|
||||
}
|
||||
teacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' + teachers.map(item => `
|
||||
<option value="${escapeHtml(item.id)}">${escapeHtml(item.fullName || item.username || `ID ${item.id}`)}</option>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
teacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Не удалось загрузить преподавателей')}</option>`;
|
||||
}
|
||||
}
|
||||
await Promise.all([loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
|
||||
|
||||
async function loadAbsences() {
|
||||
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
@@ -167,7 +176,7 @@ export async function initTeacherAbsences() {
|
||||
await openWizard(id);
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error.message || 'Не удалось выполнить действие');
|
||||
await alertAction({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
@@ -328,7 +337,7 @@ export async function initTeacherAbsences() {
|
||||
const approved = button.dataset.preferenceReview === 'approve';
|
||||
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
|
||||
if (!approved && !comment) {
|
||||
window.alert('При отклонении укажите комментарий');
|
||||
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
@@ -339,7 +348,7 @@ export async function initTeacherAbsences() {
|
||||
});
|
||||
await loadPreferenceReviews();
|
||||
} catch (error) {
|
||||
window.alert(error.message || 'Не удалось рассмотреть пожелание');
|
||||
await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -397,7 +406,7 @@ export async function initTeacherAbsences() {
|
||||
const approved = button.dataset.changeReview === 'approve';
|
||||
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
|
||||
if (!approved && !comment) {
|
||||
window.alert('При отклонении укажите комментарий');
|
||||
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
@@ -405,7 +414,7 @@ export async function initTeacherAbsences() {
|
||||
await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
|
||||
await Promise.all([loadChangeReviews(), loadAbsences()]);
|
||||
} catch (error) {
|
||||
window.alert(error.message || 'Не удалось применить решение');
|
||||
await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js';
|
||||
import { promptAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
export async function initTeacherRequests() {
|
||||
const requestsTbody = document.getElementById('teacher-requests-tbody');
|
||||
const refreshButton = document.getElementById('teacher-requests-refresh');
|
||||
const requestsCount = document.getElementById('teacher-requests-count');
|
||||
const searchInput = document.getElementById('teacher-requests-search');
|
||||
const statusSelect = document.getElementById('teacher-requests-status');
|
||||
const pagination = document.getElementById('teacher-requests-pagination');
|
||||
let departments = [];
|
||||
const restored = readUrlState({ requestQuery: '', requestStatus: 'PENDING', requestPage: '0', requestSize: '25' });
|
||||
const state = {
|
||||
query: restored.requestQuery,
|
||||
status: restored.requestStatus,
|
||||
page: Math.max(Number(restored.requestPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.requestSize)) ? Number(restored.requestSize) : 25
|
||||
};
|
||||
searchInput.value = state.query;
|
||||
statusSelect.value = state.status;
|
||||
|
||||
async function loadDepartments() {
|
||||
departments = await api.get('/api/departments');
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
renderTableLoading(requestsTbody, 7, 'Загрузка заявок...');
|
||||
hideAlert('teacher-requests-alert');
|
||||
try {
|
||||
const requests = await api.get('/api/teacher-requests?status=PENDING');
|
||||
const normalizedRequests = Array.isArray(requests) ? requests : [];
|
||||
const params = new URLSearchParams({
|
||||
query: state.query,
|
||||
page: String(state.page),
|
||||
size: String(state.size),
|
||||
sort: 'createdAt',
|
||||
direction: 'desc'
|
||||
});
|
||||
if (state.status) params.set('status', state.status);
|
||||
const page = await api.get(`/api/teacher-requests/page?${params}`);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && state.page > 0) {
|
||||
state.page = Math.max(0, page.totalPages - 1);
|
||||
return loadRequests();
|
||||
}
|
||||
const normalizedRequests = page.items || [];
|
||||
renderRequests(normalizedRequests);
|
||||
updateCount(normalizedRequests.length);
|
||||
updateCount(Number(page.totalItems || 0));
|
||||
renderPagination(pagination, page, {
|
||||
label: 'заявок',
|
||||
onPage: nextPage => { state.page = nextPage; loadRequests(); },
|
||||
onSize: size => { state.size = size; state.page = 0; loadRequests(); }
|
||||
});
|
||||
persistState();
|
||||
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
|
||||
detail: { count: normalizedRequests.length }
|
||||
detail: { count: state.status === 'PENDING' && !state.query ? Number(page.totalItems || 0) : undefined }
|
||||
}));
|
||||
trackUxEvent('реестр_заявок_загружен', { total: Number(page.totalItems || 0), page: state.page });
|
||||
} catch (error) {
|
||||
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
|
||||
renderTableError(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей', loadRequests);
|
||||
updateCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRequests(requests) {
|
||||
if (!requests.length) {
|
||||
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
|
||||
renderTableEmpty(requestsTbody, 7, state.query ? 'Заявки не найдены' : 'Заявок с выбранным статусом нет');
|
||||
return;
|
||||
}
|
||||
requestsTbody.innerHTML = requests.map(request => `
|
||||
@@ -51,12 +87,15 @@ export async function initTeacherRequests() {
|
||||
<td>${escapeHtml(request.comment || '-')}</td>
|
||||
<td>
|
||||
<div class="teacher-request-actions">
|
||||
<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>
|
||||
${request.status === 'PENDING' ? `
|
||||
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
|
||||
` : `<span class="badge">${escapeHtml(request.status === 'APPROVED' ? 'Одобрена' : 'Отклонена')}</span>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(requestsTbody);
|
||||
}
|
||||
|
||||
function updateCount(count) {
|
||||
@@ -98,7 +137,18 @@ export async function initTeacherRequests() {
|
||||
}
|
||||
|
||||
async function rejectRequest(requestId) {
|
||||
const reviewComment = prompt('Причина отклонения');
|
||||
const reviewComment = await promptAction({
|
||||
title: 'Отклонить заявку?',
|
||||
message: 'Комментарий увидит кафедра, отправившая заявку.',
|
||||
confirmLabel: 'Отклонить',
|
||||
danger: true,
|
||||
input: {
|
||||
label: 'Причина отклонения',
|
||||
placeholder: 'Укажите, что нужно исправить',
|
||||
required: true,
|
||||
requiredMessage: 'Укажите причину отклонения'
|
||||
}
|
||||
});
|
||||
if (reviewComment === null) return;
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
|
||||
@@ -110,6 +160,16 @@ export async function initTeacherRequests() {
|
||||
}
|
||||
|
||||
refreshButton?.addEventListener('click', loadRequests);
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
state.query = searchInput.value.trim();
|
||||
state.page = 0;
|
||||
loadRequests();
|
||||
}));
|
||||
statusSelect.addEventListener('change', () => {
|
||||
state.status = statusSelect.value;
|
||||
state.page = 0;
|
||||
loadRequests();
|
||||
});
|
||||
|
||||
requestsTbody.addEventListener('click', async (event) => {
|
||||
const approveButton = event.target.closest('.teacher-request-approve');
|
||||
@@ -133,4 +193,13 @@ export async function initTeacherRequests() {
|
||||
} catch (error) {
|
||||
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
|
||||
}
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
requestQuery: state.query,
|
||||
requestStatus: state.status === 'PENDING' ? '' : state.status,
|
||||
requestPage: state.page || '',
|
||||
requestSize: state.size === 25 ? '' : state.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
|
||||
export async function initUniversityStructure() {
|
||||
const deptTbody = document.getElementById('departments-tbody');
|
||||
@@ -51,6 +54,29 @@ export async function initUniversityStructure() {
|
||||
let specialties = [];
|
||||
let currentProfiles = [];
|
||||
let allProfiles = [];
|
||||
const listStates = Object.fromEntries(['departments', 'specialties', 'profiles'].map(key => [key, { page: 0, size: 25 }]));
|
||||
const listRenderers = { departments: renderDepartments, specialties: renderSpecialties, profiles: renderAllProfiles };
|
||||
Object.keys(listStates).forEach(key => {
|
||||
document.getElementById(`${key}-search`)?.addEventListener('input', debounce(() => {
|
||||
listStates[key].page = 0;
|
||||
listRenderers[key]();
|
||||
}, 250));
|
||||
});
|
||||
|
||||
function listPage(key, items, fields) {
|
||||
const query = document.getElementById(`${key}-search`).value.trim().toLocaleLowerCase('ru');
|
||||
const filtered = items.filter(item => fields(item).join(' ').toLocaleLowerCase('ru').includes(query));
|
||||
const state = listStates[key];
|
||||
const totalPages = Math.ceil(filtered.length / state.size);
|
||||
state.page = Math.min(state.page, Math.max(0, totalPages - 1));
|
||||
renderPagination(document.getElementById(`${key}-pagination`), {
|
||||
...state, totalItems: filtered.length, totalPages
|
||||
}, {
|
||||
onPage: value => { state.page = value; listRenderers[key](); },
|
||||
onSize: value => { state.size = value; state.page = 0; listRenderers[key](); }
|
||||
});
|
||||
return filtered.slice(state.page * state.size, (state.page + 1) * state.size);
|
||||
}
|
||||
|
||||
// --- Логика переключения табов ---
|
||||
const structureTabs = [
|
||||
@@ -104,11 +130,12 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
function renderDepartments() {
|
||||
if (!departments || !departments.length) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
|
||||
const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
|
||||
if (!visible.length) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
deptTbody.innerHTML = departments.map(d => `
|
||||
deptTbody.innerHTML = visible.map(d => `
|
||||
<tr>
|
||||
<td>${d.id}</td>
|
||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||
@@ -122,11 +149,12 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
function renderSpecialties() {
|
||||
if (!specialties || !specialties.length) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
|
||||
const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
|
||||
if (!visible.length) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
specTbody.innerHTML = specialties.map(s => `
|
||||
specTbody.innerHTML = visible.map(s => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||
@@ -179,7 +207,7 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Удалить кафедру?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление кафедры', message: 'Удалить кафедру?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/departments/' + deleteBtn.dataset.id);
|
||||
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
|
||||
@@ -252,7 +280,7 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Удалить специальность?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление специальности', message: 'Удалить специальность?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
|
||||
showAlert('create-spec-alert', 'Специальность удалена', 'success');
|
||||
@@ -390,7 +418,7 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Удалить профиль обучения?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
|
||||
showAlert('manage-profile-alert', 'Профиль удален', 'success');
|
||||
@@ -427,11 +455,12 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
function renderAllProfiles() {
|
||||
if (!allProfiles || !allProfiles.length) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
|
||||
const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
|
||||
if (!visible.length) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
tabProfileTbody.innerHTML = allProfiles.map(p => `
|
||||
tabProfileTbody.innerHTML = visible.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
|
||||
@@ -517,7 +546,7 @@ export async function initUniversityStructure() {
|
||||
if (deleteBtn) {
|
||||
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
|
||||
if (!profile) return;
|
||||
if (!confirm('Удалить профиль обучения?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
|
||||
showAlert('tab-profile-alert', 'Профиль удален', 'success');
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
|
||||
const ROLE_LABELS = {
|
||||
ADMIN: 'Администратор',
|
||||
@@ -21,21 +26,53 @@ const ROLE_BADGE = {
|
||||
export async function initUsers() {
|
||||
const usersTbody = document.getElementById('users-tbody');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const searchInput = document.getElementById('users-search');
|
||||
const roleFilter = document.getElementById('users-role-filter');
|
||||
const sortSelect = document.getElementById('users-sort');
|
||||
const pagination = document.getElementById('users-pagination');
|
||||
const restored = readUrlState({ userQuery: '', userRole: '', userPage: '0', userSize: '25', userSort: 'fullName,asc' });
|
||||
const state = {
|
||||
query: restored.userQuery,
|
||||
role: restored.userRole,
|
||||
page: Math.max(Number(restored.userPage) || 0, 0),
|
||||
size: [25, 50, 100].includes(Number(restored.userSize)) ? Number(restored.userSize) : 25,
|
||||
sort: restored.userSort
|
||||
};
|
||||
searchInput.value = state.query;
|
||||
roleFilter.value = state.role;
|
||||
sortSelect.value = state.sort;
|
||||
|
||||
async function loadUsers() {
|
||||
renderTableLoading(usersTbody, 7, 'Загрузка пользователей...');
|
||||
hideAlert('users-alert');
|
||||
try {
|
||||
const users = await api.get('/api/users?includeArchived=true');
|
||||
renderUsers(users);
|
||||
const [sort, direction] = state.sort.split(',');
|
||||
const params = new URLSearchParams({
|
||||
query: state.query,
|
||||
page: String(state.page),
|
||||
size: String(state.size),
|
||||
sort,
|
||||
direction,
|
||||
includeArchived: 'true'
|
||||
});
|
||||
if (state.role) params.set('role', state.role);
|
||||
const result = await api.get(`/api/users/page?${params}`);
|
||||
renderUsers(result.items || []);
|
||||
renderPagination(pagination, result, {
|
||||
label: 'пользователей',
|
||||
onPage: page => { state.page = page; loadUsers(); },
|
||||
onSize: size => { state.size = size; state.page = 0; loadUsers(); }
|
||||
});
|
||||
persistState();
|
||||
trackUxEvent('реестр_пользователей_загружен', { total: Number(result.totalItems || 0), page: state.page });
|
||||
} catch (e) {
|
||||
usersTbody.innerHTML =
|
||||
'<tr><td colspan="7" class="loading-row">Ошибка загрузки: ' +
|
||||
escapeHtml(e.message) + '</td></tr>';
|
||||
renderTableError(usersTbody, 7, e.message || 'Не удалось загрузить пользователей', loadUsers);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
if (!users || !users.length) {
|
||||
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
|
||||
renderTableEmpty(usersTbody, 7, state.query || state.role ? 'Пользователи не найдены' : 'Пользователей пока нет');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,8 +91,25 @@ export async function initUsers() {
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
markTableReady(usersTbody);
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
state.query = searchInput.value.trim();
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
}));
|
||||
roleFilter.addEventListener('change', () => {
|
||||
state.role = roleFilter.value;
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
});
|
||||
sortSelect.addEventListener('change', () => {
|
||||
state.sort = sortSelect.value;
|
||||
state.page = 0;
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
createForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-alert');
|
||||
@@ -97,12 +151,17 @@ export async function initUsers() {
|
||||
const deleteBtn = e.target.closest('.btn-archive-user');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Архивировать пользователя?',
|
||||
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/users/' + deleteBtn.dataset.id);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка архивирования');
|
||||
showAlert('users-alert', err.message || 'Ошибка архивирования', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,9 +173,19 @@ export async function initUsers() {
|
||||
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка восстановления');
|
||||
showAlert('users-alert', err.message || 'Ошибка восстановления', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function persistState() {
|
||||
writeUrlState({
|
||||
userQuery: state.query,
|
||||
userRole: state.role,
|
||||
userPage: state.page || '',
|
||||
userSize: state.size === 25 ? '' : state.size,
|
||||
userSort: state.sort === 'fullName,asc' ? '' : state.sort
|
||||
});
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
|
||||
import { alertAction, confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
export async function initDatabase() {
|
||||
const tenantsTbody = document.getElementById('tenants-tbody');
|
||||
@@ -140,14 +141,19 @@ export async function initDatabase() {
|
||||
if (!btn) return;
|
||||
|
||||
const domain = btn.dataset.domain;
|
||||
if (!confirm(`Удалить тенант "${domain}"? Пул соединений будет закрыт.`)) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление тенанта',
|
||||
message: `Удалить тенант «${domain}»? Пул соединений будет закрыт.`,
|
||||
confirmLabel: 'Удалить тенант',
|
||||
danger: true
|
||||
})) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/database/tenants/${domain}`);
|
||||
loadTenants();
|
||||
loadStatus();
|
||||
} catch (e) {
|
||||
alert('Не удалось удалить тенант');
|
||||
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить тенант' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
|
||||
import { alertAction, confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
export let allEducationForms = [];
|
||||
|
||||
@@ -58,12 +59,12 @@ export async function initEduForms() {
|
||||
efTbody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-delete-edu-form');
|
||||
if (!btn) return;
|
||||
if (!confirm('Удалить форму обучения?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление формы обучения', message: 'Удалить форму обучения?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/education-forms/' + btn.dataset.id);
|
||||
loadEF();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Ошибка удаления');
|
||||
await alertAction({ title: 'Ошибка удаления', message: e.message || 'Не удалось удалить форму обучения' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../../../js/api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
|
||||
import { confirmAction } from '../../../js/dialog.js';
|
||||
|
||||
const APPLY_MODE_LABELS = {
|
||||
DEFAULT: 'применяется по умолчанию',
|
||||
@@ -185,7 +186,12 @@ export async function initTimeSlotsSettings() {
|
||||
async function deleteActiveScope() {
|
||||
const scope = selectedScope();
|
||||
if (!scope || scope.systemScope) return;
|
||||
if (!confirm(`Удалить сетку "${scope.name}" вместе с её слотами и ручными назначениями?`)) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удаление сетки времени',
|
||||
message: `Удалить сетку «${scope.name}» вместе с её слотами и ручными назначениями?`,
|
||||
confirmLabel: 'Удалить сетку',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
|
||||
activeScopeId = defaultScope()?.id || null;
|
||||
@@ -244,7 +250,7 @@ export async function initTimeSlotsSettings() {
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить временной слот?')) return;
|
||||
if (!await confirmAction({ title: 'Удаление временного слота', message: 'Удалить временной слот?', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
|
||||
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
|
||||
|
||||
@@ -5,30 +5,6 @@
|
||||
</div>
|
||||
|
||||
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
|
||||
<div class="card create-card">
|
||||
<h2 id="academic-year-form-title">Учебный год</h2>
|
||||
<form id="academic-year-form" novalidate>
|
||||
<input type="hidden" id="academic-year-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-title">Название</label>
|
||||
<input type="text" id="academic-year-title" placeholder="2026-2027" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-start">Начало</label>
|
||||
<input type="text" id="academic-year-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-end">Окончание</label>
|
||||
<input type="text" id="academic-year-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-year-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Учебные годы</h2>
|
||||
@@ -53,39 +29,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2 id="semester-form-title">Семестр</h2>
|
||||
<form id="semester-form" novalidate>
|
||||
<input type="hidden" id="semester-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="semester-year">Учебный год</label>
|
||||
<select id="semester-year" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="semester-type">Тип семестра</label>
|
||||
<select id="semester-type" required>
|
||||
<option value="autumn">Осенний</option>
|
||||
<option value="spring">Весенний</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="semester-start">Начало</label>
|
||||
<input type="text" id="semester-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="semester-end">Окончание</label>
|
||||
<input type="text" id="semester-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="semester-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<div class="card-header-row">
|
||||
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
|
||||
@@ -133,6 +76,16 @@
|
||||
<h2>Календарные графики</h2>
|
||||
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="academic-calendars-search">Поиск графиков</label>
|
||||
<input type="search" id="academic-calendars-search" placeholder="Направление, профиль, форма или год">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendars-year-filter">Учебный год</label>
|
||||
<select id="academic-calendars-year-filter" data-native-select="true"><option value="">Все учебные годы</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -152,6 +105,83 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="academic-calendars-pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card academic-year-builder-card">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
|
||||
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-year-form" novalidate>
|
||||
<input type="hidden" id="academic-year-id">
|
||||
<div class="academic-year-period-grid">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-start">Год начала учебного года</label>
|
||||
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
|
||||
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-end">Год окончания учебного года</label>
|
||||
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
|
||||
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
|
||||
<div class="academic-year-semester-builder-head">
|
||||
<div>
|
||||
<h3 id="academic-year-semesters-title">Семестры</h3>
|
||||
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
|
||||
</div>
|
||||
<span class="academic-year-required-badge">Обязательно</span>
|
||||
</div>
|
||||
<div class="academic-year-semester-grid">
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
|
||||
<input type="hidden" id="academic-year-autumn-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
|
||||
<strong>Осенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-start">Начало</label>
|
||||
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-autumn-end">Окончание</label>
|
||||
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="academic-year-semester-card" data-academic-year-semester="spring">
|
||||
<input type="hidden" id="academic-year-spring-id">
|
||||
<div class="academic-year-semester-card-title">
|
||||
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
|
||||
<strong>Весенний семестр</strong>
|
||||
</div>
|
||||
<div class="academic-year-semester-dates">
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-start">Начало</label>
|
||||
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-year-spring-end">Окончание</label>
|
||||
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="academic-year-form-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-year-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
@@ -162,7 +192,7 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-editor-calendar">График</label>
|
||||
<select id="calendar-editor-calendar">
|
||||
<select id="calendar-editor-calendar" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -207,7 +237,7 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-calendar">График</label>
|
||||
<select id="calendar-subject-calendar">
|
||||
<select id="calendar-subject-calendar" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="registry-toolbar">
|
||||
<div class="form-group registry-search">
|
||||
<label for="groups-search">Поиск</label>
|
||||
<input type="search" id="groups-search" placeholder="Название, код специальности, профиль">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="groups-sort">Сортировка</label>
|
||||
<select id="groups-sort" data-native-select="true">
|
||||
<option value="name,asc">По названию А–Я</option>
|
||||
<option value="name,desc">По названию Я–А</option>
|
||||
<option value="yearStartStudy,desc">Сначала новые наборы</option>
|
||||
<option value="groupSize,desc">Сначала крупные</option>
|
||||
<option value="status,asc">По статусу</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="groups-filter-reset">Сбросить фильтры</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="groups-table">
|
||||
<thead>
|
||||
@@ -94,6 +111,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="groups-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования группы -->
|
||||
@@ -232,7 +250,7 @@
|
||||
</div>
|
||||
<div class="form-group" style="flex: 2; margin-bottom: 0;">
|
||||
<label for="manage-calendar-id">Календарный график</label>
|
||||
<select id="manage-calendar-id" required>
|
||||
<select id="manage-calendar-id" data-native-select="true" required>
|
||||
<option value="">Выберите учебный год</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="quality-run-panel">
|
||||
<label for="quality-year">Учебный год</label>
|
||||
<select id="quality-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
<label for="quality-semester">Семестр</label>
|
||||
<select id="quality-semester" data-native-select="true">
|
||||
<option value="">Загрузка семестров…</option>
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
<label for="quality-version">Версия</label>
|
||||
<select id="quality-version" data-native-select="true">
|
||||
|
||||
@@ -6,10 +6,20 @@
|
||||
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
|
||||
</div>
|
||||
<div class="version-hero-controls">
|
||||
<label for="version-semester">Рабочий семестр</label>
|
||||
<select id="version-semester" data-native-select="true">
|
||||
<option value="">Загрузка семестров…</option>
|
||||
</select>
|
||||
<div class="version-hero-period-grid">
|
||||
<div>
|
||||
<label for="version-year">Учебный год</label>
|
||||
<select id="version-year" data-native-select="true">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="version-semester">Рабочий семестр</label>
|
||||
<select id="version-semester" data-native-select="true">
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-primary" id="version-create">Новый черновик</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -21,21 +21,21 @@
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
|
||||
<label for="schedule-view-group">Группа</label>
|
||||
<select id="schedule-view-group">
|
||||
<select id="schedule-view-group" data-native-select="true">
|
||||
<option value="">Все группы</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
|
||||
<label for="schedule-view-teacher">Преподаватель</label>
|
||||
<select id="schedule-view-teacher">
|
||||
<select id="schedule-view-teacher" data-native-select="true">
|
||||
<option value="">Все преподаватели</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
|
||||
<label for="schedule-view-classroom">Аудитория</label>
|
||||
<select id="schedule-view-classroom">
|
||||
<select id="schedule-view-classroom" data-native-select="true">
|
||||
<option value="">Все аудитории</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -53,10 +53,16 @@
|
||||
<details class="schedule-view-extra-filters">
|
||||
<summary>Дополнительные фильтры</summary>
|
||||
<div class="schedule-view-extra-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-year">Учебный год</label>
|
||||
<select id="schedule-view-year">
|
||||
<option value="">Загрузка годов…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-semester">Семестр</label>
|
||||
<select id="schedule-view-semester">
|
||||
<option value="">По текущей дате</option>
|
||||
<option value="">Сначала выберите год</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user