8 Commits

Author SHA1 Message Date
dipatrik10
97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00
dipatrik10
c6083b35e0 Поправил шрифты и некоторые визуальные косяки 2026-09-27 17:47:36 +03:00
Zuev
e3b6797713 много всего и тестовые данные 2026-09-06 19:17:06 +03:00
Zuev
a1c4ee4298 Много разных изменений 2026-08-20 00:55:27 +03:00
Zuev
3599d850f6 merge: объединить изменения команды в Redesign 2026-08-15 00:25:58 +03:00
Zuev
52d516dd8f feat: улучшить учебные периоды и просмотр расписаний 2026-08-15 00:20:00 +03:00
dipatrik10
ca0b9352f9 Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-08-14 23:41:59 +03:00
dipatrik10
d384a43aa2 Поправил графики 2026-08-14 23:41:55 +03:00
248 changed files with 37745 additions and 17701 deletions

View File

@@ -4,7 +4,7 @@
Проект представляет собой систему управления университетским расписанием.
- **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента)
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
- **Frontend**: поэтапный переход с Vanilla JavaScript на React 19 + HTML/CSS
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
- **Локальный URL**: localhost:80
- **Продакшн URL**: https://magistr.zuev.company
@@ -25,6 +25,7 @@ magistr/
│ ├── utils/ # Утилиты
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
├── frontend/ # Статические файлы
│ ├── react/ # React entrypoint и компоненты уже перенесённых страниц
│ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
@@ -62,8 +63,14 @@ docker compose logs -f backend
## Критические правила для агентов
### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- До отдельного решения владельца проект работает в режиме пересобираемого baseline:
**все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
только в `V1__init.sql`**.
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
### Языковые требования

View File

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

676
UX_IMPROVEMENT_PLAN.md Normal file
View File

@@ -0,0 +1,676 @@
# План улучшения пользовательского взаимодействия Magistr
## 1. Назначение документа
Этот документ описывает целевое пользовательское взаимодействие для всех ролей Magistr и поэтапный план его улучшения. Главная цель — сделать систему удобной не только на демонстрационных данных, но и в реальной работе университета: при сотнях групп и преподавателей, десятках календарных графиков, большом аудиторном фонде, нескольких версиях расписания и постоянном потоке изменений.
План составлен по текущей реализации frontend, ролевой матрице, API и бизнес-логике проекта. Это экспертный UX-аудит кода и документации, а не замена наблюдению за реальными пользователями. Перед крупными изменениями гипотезы следует проверить на сотрудниках учебного отдела, кафедр, преподавателях и студентах.
## 2. Краткий вывод
В Magistr уже реализована значительная часть предметной логики: ролевой доступ, календарные графики, конструктор и версии расписания, анализ качества, разовые изменения, отсутствия, пожелания преподавателей, нагрузка и отдельные кабинеты конечных пользователей.
Основная UX-проблема — функции организованы преимущественно как набор отдельных форм, таблиц и справочников. Живой пользователь мыслит не сущностями базы данных, а задачами:
- «подготовить новый учебный год»;
- «проверить готовность данных кафедр»;
- «составить и опубликовать расписание»;
- «найти замену отсутствующему преподавателю»;
- «быстро понять, где у меня следующая пара»;
- «узнать, приняли ли мою заявку».
При росте данных длинные селекты, таблицы без серверной пагинации, ручная настройка каждой группы и графика, а также потеря контекста между разделами станут главным ограничением. Поэтому приоритет — не визуальный редизайн сам по себе, а переход к интерфейсу задач, очередей, массовых операций и устойчивого рабочего контекста.
## 3. Принципы целевого интерфейса
1. **Сначала текущая задача, затем справочник.** Главный экран роли должен отвечать на вопрос «что требует моего внимания сегодня?».
2. **Контекст показывается только там, где влияет на результат.** Учебный год, семестр и версия выбираются каскадом внутри профильной вкладки и сохраняются при возврате; общая панель не занимает место в остальных разделах.
3. **Поиск вместо прокрутки.** Любая коллекция более 20–30 элементов должна иметь поиск, фильтры, сортировку и понятный счётчик результатов.
4. **Массовые действия — обязательны.** Повторяющиеся операции над группами, графиками, дисциплинами и заявками нельзя заставлять выполнять по одной записи.
5. **Безопасность изменений.** До сохранения пользователь видит последствия, конфликты и область действия; после сохранения — подтверждение, журнал и возможность отмены там, где это допустимо.
6. **Прогрессивное раскрытие.** Редкие и сложные параметры скрываются до необходимости, а основной сценарий остаётся коротким.
7. **Одинаковые паттерны во всех кабинетах.** Поиск, фильтры, статусы, пустые состояния, подтверждения, боковые панели и сообщения работают одинаково.
8. **Мобильный интерфейс ориентирован на просмотр и оперативные действия.** Большие редакторы могут оставаться desktop-first, но расписание, заявки, статусы и замены должны быть удобны с телефона.
9. **Доступность является критерием готовности.** Полная клавиатурная навигация, видимый фокус, корректные подписи, контраст, понятные ошибки и отсутствие зависимости только от цвета.
## 4. Текущее состояние и основные риски
### Что уже сделано хорошо
- Роли направляются в подходящие кабинеты, а недоступные разделы скрываются.
- Учебный отдел и администратор могут пройти путь от правил до версии, анализа качества и публикации.
- Для точечных изменений есть сравнение «было / станет» и журнал изменений.
- Кафедра имеет собственную рабочую область, а преподаватель — пожелания, отсутствия и заявки.
- Студенческое и преподавательское расписание имеют недельную навигацию и сохраняют часть локальных настроек.
- У календарного графика есть диапазонное заполнение, а не только изменение каждой даты по отдельности.
- Система использует архивирование и сохраняет исторический контекст.
### Критические UX-риски при больших объёмах
| Риск | Как проявится у пользователя | Последствие |
|---|---|---|
| Полная загрузка коллекций | Долгое открытие разделов групп, пользователей, дисциплин и расписания | Ощущение зависания, лишний трафик, тяжёлый DOM |
| Длинные селекты | Поиск нужной группы, преподавателя или графика среди сотен элементов | Ошибочный выбор и потеря времени |
| Нет системной пагинации и сортировки | Таблицы становятся длинными, позиция после действия теряется | Невозможность эффективно обрабатывать реестры |
| Операции по одной записи | Назначение графиков, подготовка групп и привязок повторяется вручную | Большое число кликов и высокий риск пропусков |
| Контекст хранится фрагментарно | Год, семестр, версия и фильтры приходится выбирать повторно | Ошибки работы «не в том семестре» или «не в той версии» |
| Разделы отражают модель данных | Пользователь сам собирает бизнес-процесс из нескольких вкладок | Непонятно, что делать дальше и готов ли процесс |
| Нативные `confirm` и `prompt` | Опасные действия имеют мало контекста, комментарии вводятся в системное окно | Слабая предсказуемость и доступность |
| Нет общего центра уведомлений | Пользователь узнаёт о новых заявках и решениях только после открытия раздела | Задержка согласований и публикаций |
| Нет общей модели несохранённых изменений | Переход может привести к потере сложной настройки | Повторная работа и недоверие к системе |
| Ограниченный экспорт и обмен | Расписание и отчёты сложно передать вне системы | Возврат к скриншотам и ручным таблицам |
## 5. Пользовательские сценарии по ролям
### 5.1. Администратор
#### Реальные цели
- подготовить структуру и справочники университета;
- создать и сопровождать пользователей;
- контролировать качество исходных данных;
- одобрять заявки кафедр на преподавателей;
- помогать устранять ошибки доступа и конфигурации;
- видеть состояние системы, а не вручную обходить все разделы.
#### Типичный сценарий сейчас
Администратор входит на дашборд, затем отдельно открывает пользователей, структуру вуза, группы, дисциплины, аудитории, календарные графики и заявки. Формы создания постоянно видимы над реестрами, даже если большую часть времени пользователь только ищет или проверяет записи.
#### Целевой сценарий
1. На главной странице администратор видит очередь внимания: ожидающие заявки, неполные данные, архивные зависимости, ошибки проверки и недавние действия.
2. Выбирает учебный год внутри мастера подготовки периода.
3. Открывает мастер «Подготовить учебный год» с шагами и прогрессом.
4. Исправляет только отмеченные проблемы через глубокие ссылки на конкретную запись.
5. Выполняет массовое создание или импорт, получает предварительный просмотр и отчёт по строкам.
6. После завершения видит статус готовности по каждому блоку.
#### Улучшения
- заменить постоянные формы создания кнопкой «Добавить», открывающей боковую панель;
- добавить глобальный поиск по пользователям, группам, кафедрам, дисциплинам и аудиториям;
- добавить фильтры по статусу, роли, кафедре и периоду действия;
- показывать историю записи и зависимые объекты до архивирования;
- добавить импорт пользователей, групп, аудиторий и дисциплин с dry-run-проверкой;
- ввести мастер первоначальной настройки тенанта и нового учебного года;
- показывать «здоровье данных»: группы без графика, дисциплины без преподавателей, аудитории без вместимости/оборудования, незакрытые заявки.
### 5.2. Учебный отдел
#### Реальные цели
- подготовить календарную и временную основу семестра;
- убедиться, что кафедры предоставили данные;
- составить расписание без конфликтов;
- сравнить, проверить и опубликовать версию;
- быстро обрабатывать отсутствия, переносы и замены;
- находить свободные аудитории и понимать последствия изменений.
#### Целевой основной путь «Составить и опубликовать расписание»
1. Выбрать учебный год, семестр и версию в локальной панели конструктора расписания.
2. Открыть чек-лист готовности: календарные графики назначены, дисциплины заполнены, преподаватели привязаны, пожелания рассмотрены, временная сетка настроена.
3. Создать черновик из опубликованной версии или с нуля.
4. Работать в конструкторе с поиском, пакетным добавлением и видимой матрицей ресурсов.
5. Получать конфликт сразу в месте редактирования, с предложенными вариантами решения.
6. Запустить анализ качества, перейти из проблемы прямо к правилу, вернуться с сохранёнными фильтрами.
7. Сравнить версию, проверить число изменений и затронутых групп/преподавателей.
8. Опубликовать с причиной и получить подтверждение доставки изменений конечным пользователям.
#### Целевой оперативный путь «Изменение в течение семестра»
1. Открыть единую очередь инцидентов: отсутствия, заявки преподавателей, конфликты и ручные изменения.
2. Увидеть приоритет, срок, автора и затронутые занятия.
3. Выбрать предложенную замену или найти ресурс через контекстный поиск.
4. До подтверждения увидеть «было / станет», новые конфликты и список уведомляемых людей.
5. Применить решение пакетом и получить запись в журнале.
#### Улучшения
- объединить версии, качество и конструктор общим пошаговым контуром, не удаляя отдельные экспертные экраны;
- добавить глобальную панель «Учебный год · Семестр · Версия»;
- ввести автосохранение черновика или явный индикатор «Сохранено / Есть изменения / Ошибка»;
- добавить undo для локально обратимых действий и журнал последних операций;
- сделать массовое назначение правил и слотов нескольким группам;
- добавить боковую инспекцию группы, преподавателя и аудитории без ухода со страницы;
- показывать конфликты и пожелания непосредственно в визуальной сетке;
- добавить командную палитру или быстрый поиск для опытных диспетчеров;
- сохранить представления фильтров: «1 курс ИТ», «вечерние аудитории корпуса Б», «кафедра ВТ».
### 5.3. Кафедра
#### Реальные цели
- поддерживать список дисциплин и преподавателей своей кафедры;
- назначать преподавателей на дисциплины;
- передавать учебному отделу корректные исходные данные;
- контролировать нагрузку;
- согласовывать пожелания и отсутствия;
- отслеживать результат заявок.
#### Проблема текущей компоновки
В одной рабочей области одновременно находятся период, импорт дисциплин, добавление преподавателя, заявка на преподавателя, нагрузка, заявки, дисциплины и комментарии. При повседневной работе это создаёт длинную страницу и смешивает редкие настройки с ежедневными задачами.
#### Целевой сценарий
1. Главная кафедры показывает готовность к семестру и очередь действий.
2. Раздел «Подготовка семестра» группирует дисциплины, преподавателей, привязки и пожелания.
3. Раздел «Нагрузка» показывает отклонения, недогруз и перегруз, а не только список метрик.
4. Раздел «Заявки» объединяет создание преподавателя, отсутствия и изменения занятий со статусами и сроками.
5. После отправки данных кафедра видит, что принято, отклонено или требует уточнения.
#### Улучшения
- разделить рабочую область на «Обзор», «Дисциплины», «Преподаватели и нагрузка», «Заявки»;
- добавить поиск и фильтры по дисциплинам и преподавателям;
- поддержать вставку строк из Excel/буфера и загрузку файла вместо ручного добавления строк импорта;
- добавить массовую привязку преподавателей к дисциплинам;
- показывать полноту данных по каждой дисциплине;
- отображать нагрузку с нормой, отклонением и объяснением расчёта;
- дать кафедре читаемый timeline заявки и явное следующее действие;
- уведомлять о решениях учебного отдела и администратора.
### 5.4. Пользователь просмотра расписаний
#### Реальные цели
- быстро найти расписание группы, преподавателя, аудитории или кафедры;
- переключаться между несколькими результатами;
- распечатать, экспортировать или отправить ссылку;
- понимать, актуально ли расписание и есть ли разовые изменения.
#### Улучшения
- единая поисковая строка с типизированными результатами: «группа», «преподаватель», «аудитория»;
- недавние и избранные расписания;
- URL, содержащий выбранный объект, семестр, дату и режим представления;
- кнопки «Скопировать ссылку», «Печать», «PDF», «ICS»;
- явная отметка «Опубликовано …», легенда замен и отмен;
- режим сравнения двух расписаний для поиска общего свободного времени;
- сохранение фильтров после перезагрузки и возврата на страницу.
### 5.5. Преподаватель
#### Реальные цели
- за несколько секунд увидеть ближайшую пару и аудиторию;
- понять, что изменилось с последнего просмотра;
- сообщить об отсутствии;
- задать доступность и пожелания до составления расписания;
- запросить перенос, аудиторию или отмену и отследить решение.
#### Целевой сценарий
1. После входа преподаватель видит «Сегодня» и карточку следующего занятия.
2. Изменённые, перенесённые и отменённые пары выделены и объяснены текстом.
3. Из карточки занятия доступны контекстные действия: маршрут до аудитории, заявка на изменение, добавление в календарь.
4. Заявка создаётся с уже заполненными данными занятия; система сразу показывает допустимые даты, пары и аудитории.
5. Статус заявки меняется в понятном timeline и сопровождается уведомлением.
#### Улучшения
- сделать «Сегодня / Неделя / Семестр» тремя явными режимами;
- добавить карточку следующего занятия и блок недавних изменений;
- показывать корпус, этаж и оборудование аудитории;
- сохранить черновик длинной заявки при случайном закрытии;
- объяснять разницу между строгой недоступностью и мягким пожеланием до выбора режима;
- добавить массовое заполнение предпочтений: копирование дня, диапазон пар, типовая неделя;
- показывать дедлайн подачи пожеланий и статус согласования;
- добавить экспорт в личный календарь и подписку ICS.
### 5.6. Студент
#### Реальные цели
- один раз выбрать свою группу;
- быстро увидеть занятия сегодня и завтра;
- узнать об отмене, переносе или смене аудитории;
- перейти к нужной неделе без знания чётности;
- поделиться расписанием или добавить его в календарь.
#### Проблема масштаба
Выбор группы обычным списком неудобен при сотнях групп. Пользователь не должен каждый раз разбираться в полном университетском справочнике.
#### Улучшения
- первый запуск: поиск группы по названию с подсказками по специальности, курсу и кафедре;
- после выбора сделать группу постоянным профилем, а смену вынести в отдельное действие;
- стартовый режим «Сегодня», ниже — «Завтра» и ближайшее изменение;
- добавить быстрый переход «Эта неделя / Следующая неделя»;
- показывать статус обновления расписания и понятную легенду изменений;
- добавить shareable URL, печать, PDF и ICS;
- предусмотреть кеш последнего расписания для слабой сети;
- на мобильном устройстве использовать карточки дней и sticky-навигацию, а не уменьшенную широкую таблицу.
## 6. Сквозные процессы между ролями
### 6.1. Подготовка семестра
```text
Администратор настраивает структуру и справочники
↓
Кафедра заполняет дисциплины, преподавателей и пожелания
↓
Учебный отдел проверяет готовность календарей и данных
↓
Учебный отдел создаёт и проверяет черновик
↓
Публикация → преподаватели, студенты и просмотр расписаний
```
Для процесса нужен единый статус готовности с владельцем каждой проблемы. Пользователь должен видеть не просто «ошибка», а «что не готово → кто исправляет → куда перейти».
### 6.2. Отсутствие и замена
```text
Преподаватель сообщает об отсутствии
↓
Кафедра подтверждает факт
↓
Учебный отдел выбирает решения по затронутым занятиям
↓
Расписание изменяется, решение попадает в аудит
↓
Преподаватель и затронутые группы получают уведомление
```
Все роли должны видеть один и тот же номер инцидента, единые статусы и хронологию, но разные доступные действия.
### 6.3. Заявка на изменение занятия
Нужна единая модель статусов: `Черновик → Отправлена → Проверяется → Требует уточнения / Одобрена / Отклонена → Применена`. Технические статусы API можно сохранить, но UI должен переводить их в понятный язык и показывать следующее действие.
## 7. Масштабирование интерфейса
### 7.1. Реестры
Для групп, пользователей, аудиторий, дисциплин, заявок и правил необходимо:
- серверная пагинация или cursor pagination;
- серверные поиск, фильтрация и сортировка;
- размер страницы 25/50/100;
- строка поиска с задержкой 250–400 мс и отменой предыдущего запроса;
- sticky-заголовок таблицы;
- сохранение фильтров в URL;
- выбор строк и массовая панель действий;
- счётчик «Показано N из M»;
- настраиваемые столбцы для сложных реестров;
- виртуализация только там, где пагинация мешает задаче, например в большой визуальной матрице.
### 7.2. Поиск сущностей
Селекты с большими наборами следует заменить асинхронным комбобоксом:
- поиск начинается после 2 символов;
- результат показывает различающие атрибуты;
- группы: название, курс, специальность, форма, кафедра;
- преподаватели: ФИО, должность, кафедры;
- аудитории: название, корпус, этаж, вместимость, оборудование;
- графики: учебный год, специальность, профиль, форма, число курсов;
- выбранное значение остаётся видимым чипом;
- клавиатура и экранный диктор поддерживаются полностью.
### 7.3. Календарные графики
При десятках похожих графиков требуются:
- пользовательское название или короткий alias графика;
- поиск и фасетные фильтры по году, специальности, профилю и форме;
- клонирование графика из прошлого года с предварительным просмотром сдвига дат;
- массовое создание на основе шаблона;
- массовое назначение выбранным совместимым группам;
- индикатор использования: сколько групп назначено;
- сравнение двух графиков и подсветка различий;
- защита от потери несохранённой сетки;
- автосохранение черновика сетки локально или на сервере;
- undo/redo для заполнения диапазона и изменения ячеек;
- проверка до сохранения с переходом к ошибочной дате;
- закреплённые заголовки курса, месяца и легенды при прокрутке.
### 7.4. Конструктор расписания
- master-detail-компоновка: слева фильтруемый список правил, справа редактор выбранного правила;
- создание из шаблона и дублирование правила;
- массовое назначение групп, преподавателя, слотов и диапазонов недель;
- фильтры по заполненности, конфликтам, кафедре, курсу и дисциплине;
- визуальная сетка загружает только выбранный набор групп;
- конфликт объясняется человеческим языком и содержит ссылку на конфликтующее правило;
- перед удалением показывается количество затронутых занятий;
- публикация заблокирована до прохождения обязательных проверок.
## 8. Целевая информационная архитектура
### Администратор
- Обзор и задачи
- Подготовка учебного года
- Пользователи и доступ
- Структура и справочники
- Расписание
- Оперативные изменения
- Отчёты и аудит
- Настройки
### Учебный отдел
- Обзор и очередь
- Подготовка семестра
- Конструктор расписания
- Проверка и публикация
- Просмотр расписаний
- Изменения и замены
- Ресурсы и загруженность
### Кафедра
- Обзор
- Подготовка семестра
- Дисциплины
- Преподаватели и нагрузка
- Расписание кафедры
- Заявки и согласования
Названия меню должны описывать рабочие задачи. Технические сущности остаются внутри экранов и документации, но не обязаны быть верхним уровнем навигации.
## 9. Общие компоненты и паттерны
Необходимо сформировать единый набор компонентов для Vanilla JS:
- асинхронный combobox;
- панель фильтров с чипами активных условий и кнопкой «Сбросить»;
- таблица с серверной пагинацией, сортировкой, выбором строк и массовыми действиями;
- боковая панель просмотра/редактирования;
- диалог подтверждения с описанием последствий;
- toast для краткого результата и inline-сообщение для ошибки поля/раздела;
- skeleton-состояние вместо скачка пустой таблицы;
- стандартизированные empty/error/permission states;
- status badge с текстом и иконкой;
- breadcrumb или ссылка «Назад к результатам» с восстановлением позиции;
- индикатор сохранения и защита несохранённых изменений;
- timeline для заявок и аудита;
- центр уведомлений и счётчики очередей.
Системные `confirm()` и `prompt()` следует постепенно заменить доступными проектными диалогами. Сообщение об успешном действии должно называть объект и результат: «График назначен 18 группам», а не просто «Успешно».
## 10. Состояния интерфейса
Каждый рабочий экран должен проектироваться минимум для следующих состояний:
1. первоначальная загрузка;
2. данные загружены;
3. пустой справочник;
4. фильтр не дал результатов;
5. частичная ошибка;
6. полная ошибка с повтором;
7. нет прав;
8. объект изменён другим пользователем;
9. есть несохранённые изменения;
10. действие выполняется;
11. действие успешно завершено;
12. действие завершено частично, например при массовом импорте.
Для конкурентного редактирования версий, календарей и правил желательно использовать optimistic locking и понятный диалог: кто и когда изменил объект, можно ли обновить данные или сохранить копию.
## 11. Уведомления
Минимально необходимы уведомления внутри приложения:
- новая заявка для ответственной роли;
- решение по заявке;
- приближение дедлайна пожеланий;
- публикация новой версии;
- перенос, отмена или смена аудитории;
- конфликт или ошибка массовой операции.
Уведомление должно вести к конкретному объекту, иметь статус прочтения и не раскрывать данные другой кафедры. Email/web-push можно добавить позднее, используя те же события.
## 12. Доступность и адаптивность
- обеспечить WCAG 2.2 AA как целевой уровень;
- проверить контраст обеих тем;
- добавить `aria-sort` таблицам и объявление количества результатов;
- реализовать корректные combobox/listbox-роли для поиска;
- возвращать фокус после закрытия модального окна;
- закрывать диалоги по `Escape`, удерживать фокус внутри открытого диалога;
- не использовать цвет как единственный признак конфликта, статуса или чётности;
- увеличить интерактивные зоны минимум до 44×44 px на мобильных экранах;
- для расписаний на телефоне показывать один день/список занятий, а не горизонтально сжатую таблицу;
- уважать `prefers-reduced-motion`;
- сделать печатные стили для расписаний и отчётов.
## 13. Приоритетный roadmap
### Этап 0. Проверка гипотез и базовые метрики — 1–2 недели
- провести 5–7 интервью с учебным отделом, 3–5 с кафедрами, 5 с преподавателями и 5 со студентами;
- наблюдать выполнение реальных задач без подсказок;
- замерить время, число кликов, ошибки и точки возврата;
- зафиксировать объёмы: группы, графики, правила, преподаватели, аудитории, заявки;
- построить карту основных процессов и согласовать владельцев статусов.
**Результат:** подтверждённый список top-10 проблем и исходные значения метрик.
### Этап 1. Быстрые улучшения frontend — 2–4 недели
- сохранять в URL и восстанавливать фильтры, вкладки, семестр и выбранный объект;
- добавить поиск, сортировку и sticky-заголовки в текущие таблицы;
- заменить опасные `confirm/prompt` проектными диалогами;
- унифицировать загрузку, пустые состояния и ошибки;
- добавить защиту несохранённых изменений в календарь и конструктор;
- улучшить первый выбор группы студента;
- добавить режим «Сегодня» преподавателю и студенту;
- добавить ссылки «Скопировать» и печатные стили просмотра расписания.
**Результат:** заметное сокращение ежедневного трения без крупной переделки backend.
### Этап 2. Масштабные реестры и поиск — 4–8 недель
- добавить API пагинации, поиска, сортировки и фасетных фильтров;
- внедрить общий компонент серверной таблицы;
- внедрить асинхронный combobox;
- добавить массовые операции для групп, графиков и привязок;
- добавить предварительный просмотр и отчёт массовых операций;
- обеспечить стабильные deep links на сущности.
**Результат:** интерфейс остаётся работоспособным на полном объёме данных университета.
### Этап 3. Интерфейс сквозных задач — 6–10 недель
- локальные каскады учебного года, семестра и версии в профильных вкладках;
- чек-лист готовности семестра;
- ролевые дашборды с очередями действий;
- единая очередь инцидентов и согласований;
- timeline заявок;
- связанный путь «конструктор → качество → сравнение → публикация»;
- центр уведомлений.
**Результат:** пользователь проходит бизнес-процесс, не собирая его вручную из вкладок.
### Этап 4. Продвинутые инструменты — после стабилизации
- шаблоны и клонирование календарных графиков;
- импорт из Excel с сопоставлением колонок;
- сравнение расписаний и поиск общего свободного времени;
- экспорт PDF/Excel/ICS и календарная подписка;
- оптимистические блокировки и совместное редактирование;
- командная палитра и горячие клавиши для опытных пользователей;
- персональные сохранённые представления.
## 14. Приоритеты по модели RICE без числовой фиксации
| Инициатива | Охват | Влияние | Сложность | Приоритет |
|---|---|---|---|---|
| Серверный поиск и пагинация | Все внутренние роли | Очень высокое | Высокая | P0 |
| Локальные каскады года/семестра/версии | Администратор, учебный отдел, кафедра | Очень высокое | Средняя | P0 |
| Защита несохранённых изменений | Учебный отдел | Очень высокое | Средняя | P0 |
| Асинхронные комбобоксы | Все роли | Высокое | Средняя | P0 |
| URL-состояние и глубокие ссылки | Все роли | Высокое | Низкая/средняя | P0 |
| Массовое назначение графиков группам | Администратор, учебный отдел | Очень высокое | Средняя/высокая | P0 |
| Ролевые очереди задач | Внутренние роли | Высокое | Средняя | P1 |
| Единый контур публикации | Учебный отдел | Высокое | Средняя | P1 |
| Уведомления | Все роли | Высокое | Высокая | P1 |
| Импорт с предварительной проверкой | Администратор, кафедра | Высокое | Высокая | P1 |
| «Сегодня» и ближайшая пара | Преподаватель, студент | Высокое | Низкая | P1 |
| PDF/ICS/ссылки | Просмотр, преподаватель, студент | Среднее | Средняя | P2 |
| Командная палитра | Опытные внутренние пользователи | Среднее | Средняя | P2 |
## 15. Изменения backend, необходимые для полноценного UX
Frontend-улучшений недостаточно для больших объёмов. Понадобятся:
- единый контракт пагинации и сортировки для реестров;
- полнотекстовый или индексированный поиск по нормализованным полям;
- batch-endpoints с атомарным и частичным режимами;
- dry-run для импорта, назначения и публикации;
- endpoint готовности семестра с кодами проблем и deep-link metadata;
- endpoint глобального поиска в рамках прав пользователя;
- события и хранилище уведомлений;
- optimistic locking/version field для редактируемых сущностей;
- агрегированные endpoints для дашбордов, чтобы не собирать их множеством клиентских запросов;
- экспортные задания для тяжёлых PDF/Excel;
- аудит массовых операций с автором, причиной и набором затронутых объектов.
Все контракты должны сохранять мультитенантную и ролевую изоляцию. Сервер обязан повторно применять ограничения роли независимо от фильтров frontend.
## 16. UX-метрики
### Операционные
- медианное время создания и назначения календарного графика;
- время поиска конкретной группы, преподавателя и аудитории;
- время от регистрации отсутствия до применения всех решений;
- время от создания черновика до публикации;
- число ручных действий на 10 групп или 10 правил;
- процент массовых операций с частичными ошибками.
### Качественные
- success rate ключевого сценария без помощи;
- число возвратов и повторных сохранений;
- доля ошибок «не тот семестр / не та версия / не та кафедра»;
- доля пользователей, понимающих следующий шаг;
- SUS или UMUX-Lite по каждой роли;
- CSAT после обработки заявки или публикации.
### Технические UX-метрики
- p75 времени появления полезного содержимого;
- p75 ответа поиска;
- размер DOM больших таблиц и сеток;
- частота ошибок API по экрану и действию;
- доля прерванных загрузок и повторных запросов;
- Core Web Vitals для публичных/конечных кабинетов.
Целевые значения следует зафиксировать после этапа 0. Без исходных измерений произвольные проценты улучшения будут недостоверны.
## 17. Критерии готовности ключевых улучшений
### Реестр больших данных готов, если
- 10 000 записей не загружаются целиком в браузер;
- поиск возвращает результат за приемлемое для проекта целевое время;
- фильтры и страница восстанавливаются после возврата;
- массовая операция показывает область действия до подтверждения;
- частичные ошибки перечислены по объектам и доступны для выгрузки;
- управление возможно с клавиатуры.
### Календарный график готов, если
- пользователь находит нужный график без просмотра полного списка;
- может создать его из шаблона и назначить нескольким совместимым группам;
- видит несохранённые изменения;
- может отменить последнее редактирование;
- ошибка указывает точный курс и дату;
- уход со страницы не приводит к молчаливой потере работы.
### Публикация готова, если
- текущий семестр и версия видны постоянно;
- до публикации показаны ошибки, предупреждения и diff;
- указано число затронутых групп, преподавателей и занятий;
- причина обязательна и попадает в аудит;
- пользователь получает однозначное подтверждение результата;
- конечные роли видят актуальность и изменения.
## 18. План пользовательского тестирования
Для каждого прототипа проводить короткие сценарные тесты:
| Роль | Проверочное задание |
|---|---|
| Администратор | Создать 20 групп импортом, исправить ошибки и назначить графики |
| Учебный отдел | Создать черновик, устранить конфликт, проверить качество и опубликовать |
| Учебный отдел | Обработать отсутствие с несколькими затронутыми занятиями |
| Кафедра | Добавить дисциплины и преподавателей, проверить готовность семестра |
| Просмотр | Найти расписание аудитории и отправить ссылку коллеге |
| Преподаватель | Указать недоступность и подать заявку на перенос |
| Студент | Выбрать группу и найти аудиторию следующей пары |
Фиксировать время, ошибки, вопросы пользователя, неверные ожидания и субъективную уверенность. Тестировать минимум на реальных объёмах, а не на 3–5 элементах.
## 19. Что не следует делать первым
- полностью менять визуальный стиль без исправления поиска, масштаба и процессов;
- объединять все роли в один универсальный экран;
- скрывать сложность только дополнительными модальными окнами;
- переносить всю фильтрацию на клиент;
- добавлять массовые действия без preview, отчёта и аудита;
- полагаться только на цвет для статусов;
- автоматически применять пожелания преподавателей без явного решения ответственной роли;
- внедрять drag-and-drop как единственный способ редактирования расписания.
## 20. Рекомендуемый первый пакет реализации
Первый практический пакет должен дать максимальный эффект без полной перестройки системы:
1. локальные каскады «Учебный год / Семестр / Версия» только в профильных вкладках;
2. URL-состояние для вкладок и фильтров;
3. асинхронный поиск групп, преподавателей, аудиторий и графиков;
4. серверная пагинация для групп, пользователей, дисциплин и заявок;
5. массовое назначение календарного графика выбранным группам с dry-run;
6. предупреждение о несохранённых изменениях календаря и конструктора;
7. проектные диалоги вместо `confirm/prompt`;
8. «Сегодня» и «Следующая пара» в кабинетах преподавателя и студента;
9. единые состояния загрузки, отсутствия данных и ошибки;
10. базовые события аналитики для измерения результата.
После этого пакета следует переходить к чек-листу готовности семестра, ролевым очередям и единому процессу публикации.
### 20.1. Статус реализации первого пакета — 19 августа 2026
| № | Статус | Что реализовано |
|---|---|---|
| 1 | Выполнено, решение уточнено | Общая панель контекста удалена. В `schedule`, `schedule-versions` и `schedule-quality` используются локальные каскады учебного года, семестра и версии; в `schedule-view` — года и семестра, поскольку экран показывает только опубликованное расписание. |
| 2 | Выполнено | Вкладки, фильтры, сортировка, размер и номер страницы сохраняются в query string. Просмотр расписания сохраняет локальные год и семестр; преподаватель — вкладку и неделю, студент — группу и неделю. |
| 3 | Выполнено | Добавлены серверные endpoints и общий `AsyncCombobox` для групп, преподавателей, аудиторий и совместимых календарных графиков. Первый выбор группы студента и выбор преподавателя при регистрации отсутствия больше не загружают полный справочник. |
| 4 | Выполнено | Группы, пользователи, дисциплины и заявки преподавателей используют серверные поиск, фильтры, сортировку и пагинацию 25/50/100 с общим `PageResponse`. Для страницы дисциплин загружаются только её привязки преподавателей, а подгруппы запрашиваются только для открытой или выбранной в конструкторе группы. |
| 5 | Отложено по решению заказчика | Массовое назначение одного календарного графика нескольким совместимым группам и dry-run в этот пакет не входят. Текущее одиночное назначение сохранено. |
| 6 | Выполнено | Календарная сетка, привязки дисциплин и конструктор правил отслеживают несохранённые изменения, показывают индикатор на кнопке и предупреждают при уходе, смене графика или версии. |
| 7 | Выполнено | Нативные `confirm`, `prompt` и `alert` в административных и settings-модулях заменены единым доступным проектным диалогом. |
| 8 | Выполнено | В кабинетах преподавателя и студента появились блок «Сегодня» и карточка «Следующая пара» с поиском занятия в горизонте 14 дней. |
| 9 | Выполнено | Для серверных реестров внедрены общие loading/empty/error-состояния с повтором; для ближайших занятий применяется единый компонент состояния. |
| 10 | Выполнено | Добавлены прикладные UX-события: смена раздела, загрузка реестра/расписания, выбор группы, сохранение правила, сетки и привязок. В production события преобразуются в OpenTelemetry spans. |
Дополнительно закрыты три согласованных требования масштабирования: серверные
поиск/фильтрация/сортировка/пагинация реестров, асинхронные селекты больших справочников и
восстановление фильтров/страницы из URL. Ролевое ограничение кафедры повторно применяется
backend и не зависит от переданного frontend-фильтра.
Проверка реализации: `npm run check` выполняет синтаксический контроль новых модулей и все
18 frontend-наборов; отдельный тест закрепляет выбор сегодняшних занятий, текущей и следующей
пары. Production-сборка frontend и 339 backend-тестов через полный `mvn test` в Java 17
контейнере проходят успешно. Массовое назначение из пункта 5 остаётся единственным
сознательно незавершённым пунктом этого пакета.
## 21. Источники анализа в проекте
- `docs/BUSINESS_LOGIC.md` — роли и бизнес-процессы;
- `docs/FRONTEND.md` — маршруты и текущее поведение экранов;
- `docs/API.md` — возможности и ограничения API;
- `frontend/admin/js/role-capabilities.js` — фактическая матрица вкладок;
- `frontend/admin/views/` и `frontend/admin/js/views/` — административные сценарии;
- `frontend/teacher/` — кабинет преподавателя;
- `frontend/student/` — кабинет студента.

55
UX_SITE_CHECKLIST.md Normal file
View File

@@ -0,0 +1,55 @@
# Чек-лист проверки UX-изменений на сайте
## Перед проверкой
1. Запустите проект: `docker compose up -d --build`.
2. Откройте [http://localhost:80](http://localhost:80) или production-сайт.
3. Для полной проверки нужны пользователи с ролями `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `TEACHER` и `STUDENT`.
4. Пагинацию можно полноценно проверить, если в справочнике больше 25 записей.
## Что проверить
| № | Роль и куда зайти | Что нажать | Что должно быть видно |
|---|---|---|---|
| 1 | `ADMIN` → **Дашборд**, **Группы обучения** или **Пользователи** | Просто откройте вкладки | Под заголовком нет общей панели **КОНТЕКСТ**; она не занимает место в разделах, которым учебный период не нужен |
| 2 | `ADMIN` → **Группы обучения** | Введите текст в **Поиск**, выберите форму обучения и сортировку, затем обновите страницу | Фильтры остаются выбранными, а в адресной строке появляются параметры `groupQuery`, `groupEducationForm`, `groupSort`, `groupPage` |
| 3 | `ADMIN` → **Группы обучения**, **Пользователи**, **Дисциплины**, **Заявки** | Используйте поиск, фильтры, сортировку, **Назад / Далее** и список **На странице: 25 / 50 / 100** | Таблица обновляется без полной загрузки всех записей; показан диапазон вида «Показано 1–25 из …» |
| 4 | `ADMIN` или `EDUCATION_OFFICE` → **Просмотр расписаний** | Откройте вкладку, не нажимая на поле **Группа**; затем раскройте **Дополнительные фильтры**, выберите **Учебный год** и **Семестр** и нажмите **Показать** | При открытии список групп свёрнут; семестры ограничиваются выбранным годом, значения сохраняются в URL; экран показывает опубликованное расписание |
| 5 | `ADMIN` → **Группы обучения** → у группы кнопка **Календарь** | Выберите учебный год и начните печатать название графика | Совместимые календарные графики ищутся асинхронно; весь справочник заранее не загружается. Сохранять назначение для проверки не обязательно |
| 6 | `ADMIN` или `EDUCATION_OFFICE` → **Конструктор правил** | В верхней карточке по очереди выберите **Учебный год**, **Семестр**, **Версию расписания**; затем раскройте **Группы** и найдите преподавателя или аудиторию | Каждый селект ограничивает следующий; загружаются версии только выбранного семестра, а большие справочники ищутся через поисковые списки |
| 7 | `ADMIN` или `EDUCATION_OFFICE` → **Запросы преподавателей** → блок **Зарегистрировать отсутствие** | Начните вводить ФИО преподавателя из любой кафедры | Поиск находит преподавателя, а список результатов отображается поверх блока **Реестр инцидентов**, не прячется за ним |
| 8 | `ADMIN` → **Календарный график** → **Сетки** | Выберите график → **Загрузить сетку** → измените ячейку или примените **Заполнить диапазон** → нажмите другую вкладку слева | Кнопка меняется на **Сохранить сетку •**, затем появляется диалог **Несохранённые изменения** с действиями **Остаться** и **Покинуть без сохранения** |
| 9 | `ADMIN` → **Конструктор правил** | Сначала сразу перейдите в другой раздел, затем вернитесь, измените любое поле правила и снова попробуйте уйти | Без изменений переход выполняется без предупреждения; после ручного изменения появляется диалог **Несохранённые изменения** |
| 10 | `ADMIN` → **Группы обучения** | У активной группы нажмите **Архив**, затем в диалоге нажмите **Отмена** | Появляется проектный диалог **Архивировать группу?**, а не системное окно браузера; данные после отмены не меняются |
| 11 | `TEACHER` → **Расписание** | Просто откройте кабинет; затем нажмите **Сегодня** | Над недельным расписанием видны карточки **Сегодня** и **Следующая пара**; кнопка возвращает к текущей неделе |
| 12 | `STUDENT` → страница расписания | Начните вводить название группы, выберите её и нажмите **Сегодня** | Группа ищется асинхронно; появляются карточки **Сегодня** и **Следующая пара**; выбранная группа сохраняется после перезагрузки |
| 13 | `TEACHER` → **Мои пожелания** или **Заявки на изменение** | Переключите вкладку и обновите страницу | Открытая вкладка восстанавливается из URL; выбранная неделя также сохраняется |
| 14 | Любой серверный реестр, например `ADMIN` → **Дисциплины** | Введите заведомо отсутствующую строку, например `___нет_такой_записи___` | Появляется понятное пустое состояние **Дисциплины не найдены**, а не пустая таблица |
| 15 | `ADMIN` или `EDUCATION_OFFICE` → **Версии расписания**, затем **Анализ качества** | На обеих вкладках смените **Учебный год** и **Семестр**; в анализе дополнительно выберите **Версию** | В «Версиях» обновляются карточки публикации/черновиков выбранного семестра, в «Анализе качества» — список доступных версий |
## Дополнительные проверки
### Ошибка и повторная загрузка
1. Откройте DevTools → **Network** → включите **Offline**.
2. Обновите реестр групп, пользователей, дисциплин или заявок.
3. Должно появиться сообщение об ошибке и кнопка **Повторить**.
4. Верните **Online** и нажмите **Повторить** — данные должны загрузиться.
### UX-аналитика
До выполнения действий вставьте в консоль браузера:
```javascript
document.addEventListener('magistr:ux-event', event => console.log(event.detail));
```
После смены вкладки, фильтра или загрузки расписания в консоли должны появляться UX-события. На production они также преобразуются в OpenTelemetry spans.
### Ролевое ограничение кафедры
Войдите как `DEPARTMENT` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий.
## Что пока не проверяем
Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**.

View File

@@ -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("Слишком много дисциплин для одного графика");

View File

@@ -10,11 +10,15 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ArrayList;
import java.util.Locale;
@RestController
@RequestMapping("/api/classrooms")
@@ -40,6 +44,26 @@ public class ClassroomController {
.toList();
}
@GetMapping("/options")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<ClassroomResponse> searchClassroomOptions(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long selectedId,
@RequestParam(defaultValue = "20") int limit) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
List<ClassroomResponse> options = new ArrayList<>(classroomRepository.searchActiveOptions(
normalizedQuery,
LifecycleEntity.STATUS_ARCHIVED,
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("name").ascending())
).stream()
.map(this::mapToResponse)
.toList());
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
classroomRepository.findById(selectedId).map(this::mapToResponse).ifPresent(options::add);
}
return options;
}
@PostMapping
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
if (request.getName() == null || request.getName().isBlank()) {

View File

@@ -1,10 +1,13 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
@@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -98,6 +108,118 @@ public class GroupController {
}
}
@GetMapping("/page")
public PageResponse<GroupResponse> getGroupsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) List<Long> educationFormId,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Integer yearStartStudy,
@RequestParam(defaultValue = "true") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "name") String sort,
@RequestParam(defaultValue = "asc") String direction) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
Specification<StudentGroup> specification = groupSpecification(
query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived
);
Page<StudentGroup> groups = groupRepository.findAll(
specification,
PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction))
);
return PageResponse.from(groups.map(this::mapToResponse));
}
@GetMapping("/options")
public List<GroupResponse> searchGroupOptions(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Long selectedId,
@RequestParam(defaultValue = "20") int limit) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<GroupResponse> options = new ArrayList<>(groupRepository.findAll(
groupSpecification(query, null, effectiveDepartmentId, null, false),
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("name").ascending())
).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.map(this::mapToResponse)
.toList());
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
groupRepository.findById(selectedId)
.filter(group -> effectiveDepartmentId == null
|| Objects.equals(group.getDepartmentId(), effectiveDepartmentId))
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.map(this::mapToResponse)
.ifPresent(options::add);
}
return options;
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser != null && currentUser.role() == Role.DEPARTMENT
? currentUser.departmentId()
: requestedDepartmentId;
}
private Specification<StudentGroup> groupSpecification(String query,
List<Long> educationFormIds,
Long departmentId,
Integer yearStartStudy,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (!includeArchived) {
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
}
if (educationFormIds != null && !educationFormIds.isEmpty()) {
predicates.add(root.get("educationForm").get("id").in(educationFormIds));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("departmentId"), departmentId));
}
if (yearStartStudy != null) {
predicates.add(builder.equal(root.get("yearStartStudy"), yearStartStudy));
}
String normalizedQuery = normalizeQuery(query);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("name")), pattern),
builder.like(builder.lower(root.get("speciality").get("specialityCode")), pattern),
builder.like(builder.lower(root.get("speciality").get("specialityName")), pattern),
builder.like(builder.lower(root.get("specialtyProfile").get("name")), pattern),
builder.like(builder.lower(root.get("educationForm").get("name")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort groupSort(String field, String direction) {
String property = switch (field) {
case "yearStartStudy" -> "yearStartStudy";
case "groupSize" -> "groupSize";
case "status" -> "status";
default -> "name";
};
return "desc".equalsIgnoreCase(direction)
? Sort.by(property).descending()
: Sort.by(property).ascending();
}
private int normalizePage(int page) {
return Math.max(page, 0);
}
private int normalizeSize(int size) {
return Math.max(1, Math.min(size, 100));
}
private String normalizeQuery(String query) {
return query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
}
@GetMapping("/{departmentId}")
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);

View File

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

View File

@@ -12,10 +12,13 @@ import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController {
@@ -36,8 +39,16 @@ public class SubgroupController {
}
@GetMapping("/api/subgroups")
public List<SubgroupDto> getAll() {
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
public List<SubgroupDto> getAll(@RequestParam(required = false) List<Long> groupId) {
if (groupId != null && groupId.size() > 100) {
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп");
}
List<Subgroup> selected = groupId == null
? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()
: groupId.isEmpty()
? List.of()
: subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList());
return selected.stream()
.map(this::toDto)
.toList();
}

View File

@@ -1,8 +1,10 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
@@ -11,9 +13,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@RestController
@@ -53,6 +60,74 @@ public class SubjectController {
}
}
@GetMapping("/page")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public PageResponse<SubjectResponse> getSubjectsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long departmentId,
@RequestParam(defaultValue = "false") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "name") String sort,
@RequestParam(defaultValue = "asc") String direction) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
var subjects = subjectRepository.findAll(
subjectSpecification(query, effectiveDepartmentId, includeArchived),
PageRequest.of(
Math.max(page, 0),
Math.max(1, Math.min(size, 100)),
subjectSort(sort, direction)
)
);
return PageResponse.from(subjects.map(this::toResponse));
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser != null && currentUser.role() == Role.DEPARTMENT
? currentUser.departmentId()
: requestedDepartmentId;
}
private Specification<Subject> subjectSpecification(String query,
Long departmentId,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (!includeArchived) {
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("departmentId"), departmentId));
}
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("name")), pattern),
builder.like(builder.lower(root.get("code")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort subjectSort(String field, String direction) {
String property = switch (field) {
case "code" -> "code";
case "departmentId" -> "departmentId";
case "status" -> "status";
default -> "name";
};
return "desc".equalsIgnoreCase(direction)
? Sort.by(property).descending()
: Sort.by(property).ascending();
}
private SubjectResponse toResponse(Subject subject) {
return new SubjectResponse(subject.getId(), subject.getName(), subject.getCode(), subject.getDepartmentId());
}
@GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {

View File

@@ -5,6 +5,7 @@ import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
@@ -12,11 +13,16 @@ import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@RestController
@@ -55,6 +61,63 @@ public class TeacherCreationRequestController {
.toList();
}
@GetMapping("/page")
public PageResponse<TeacherCreationRequestResponse> getRequestsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) TeacherCreationRequestStatus status,
@RequestParam(required = false) Long departmentId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "createdAt") String sort,
@RequestParam(defaultValue = "desc") String direction) {
var requests = teacherCreationRequestRepository.findAll(
requestSpecification(query, status, departmentId),
PageRequest.of(
Math.max(page, 0),
Math.max(1, Math.min(size, 100)),
requestSort(sort, direction)
)
);
return PageResponse.from(requests.map(this::toResponse));
}
private Specification<TeacherCreationRequest> requestSpecification(String query,
TeacherCreationRequestStatus status,
Long departmentId) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (status != null) {
predicates.add(builder.equal(root.get("status"), status));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("department").get("id"), departmentId));
}
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("username")), pattern),
builder.like(builder.lower(root.get("fullName")), pattern),
builder.like(builder.lower(root.get("jobTitle")), pattern),
builder.like(builder.lower(root.get("department").get("departmentName")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort requestSort(String field, String direction) {
String property = switch (field) {
case "fullName" -> "fullName";
case "username" -> "username";
case "status" -> "status";
default -> "createdAt";
};
return "asc".equalsIgnoreCase(direction)
? Sort.by(property).ascending()
: Sort.by(property).descending();
}
@PostMapping("/{id}/approve")
@Transactional
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {

View File

@@ -16,10 +16,13 @@ import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController
@RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
@@ -53,15 +56,18 @@ public class TeacherSubjectController {
}
@GetMapping
public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
public List<TeacherSubjectResponse> getAll(@RequestParam(required = false) List<Long> subjectId) {
if (subjectId != null && subjectId.size() > 100) {
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин");
}
List<TeacherSubject> assignments = subjectId == null
? teacherSubjectRepository.findAll()
: subjectId.isEmpty()
? List.of()
: teacherSubjectRepository.findBySubjectIdInWithDetails(subjectId.stream().distinct().toList());
return assignments.stream()
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
.map(ts -> new TeacherSubjectResponse(
ts.getUserId(),
ts.getUser().getUsername(),
ts.getSubjectId(),
ts.getSubject().getName()
))
.map(this::toResponse)
.toList();
}
@@ -136,4 +142,14 @@ public class TeacherSubjectController {
)
&& departmentId.equals(subject.getDepartmentId());
}
private TeacherSubjectResponse toResponse(TeacherSubject teacherSubject) {
return new TeacherSubjectResponse(
teacherSubject.getUserId(),
teacherSubject.getUser().getUsername(),
teacherSubject.getUser().getFullName(),
teacherSubject.getSubjectId(),
teacherSubject.getSubject().getName()
);
}
}

View File

@@ -6,6 +6,7 @@ import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
@@ -22,11 +23,16 @@ import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@RestController
@@ -80,6 +86,23 @@ public class UserController {
return response;
}
@GetMapping("/page")
public PageResponse<UserResponse> getUsersPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Role role,
@RequestParam(required = false) Long departmentId,
@RequestParam(defaultValue = "true") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "fullName") String sort,
@RequestParam(defaultValue = "asc") String direction) {
var users = userRepository.findAll(
userSpecification(query, role, departmentId, includeArchived),
PageRequest.of(normalizePage(page), normalizeSize(size), userSort(sort, direction))
);
return PageResponse.from(users.map(this::toUserResponse));
}
@GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() {
@@ -91,6 +114,92 @@ public class UserController {
return response;
}
@GetMapping("/teachers/options")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> searchTeacherOptions(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Long selectedId,
@RequestParam(defaultValue = "20") int limit) {
var currentUser = AuthContext.getCurrentUser();
Long effectiveDepartmentId = currentUser != null && currentUser.role() == Role.DEPARTMENT
? currentUser.departmentId()
: departmentId;
LocalDate today = businessTime.today();
List<UserResponse> options = new ArrayList<>(userRepository.searchTeacherOptions(
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
Role.TEACHER,
LifecycleEntity.STATUS_ARCHIVED,
effectiveDepartmentId,
today,
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("fullName").ascending())
).stream()
.map(user -> effectiveDepartmentId == null
? toUserResponse(user)
: toUserResponse(user, effectiveDepartmentId))
.toList());
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
userRepository.findById(selectedId)
.filter(user -> user.getRole() == Role.TEACHER)
.filter(user -> !user.isArchivedRecord())
.filter(user -> effectiveDepartmentId == null
|| teacherDepartmentService.hasAssignmentAtDate(user.getId(), effectiveDepartmentId, today))
.map(user -> effectiveDepartmentId == null
? toUserResponse(user)
: toUserResponse(user, effectiveDepartmentId))
.ifPresent(options::add);
}
return options;
}
private Specification<User> userSpecification(String query,
Role role,
Long departmentId,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (!includeArchived) {
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
}
if (role != null) {
predicates.add(builder.equal(root.get("role"), role));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("departmentId"), departmentId));
}
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("username")), pattern),
builder.like(builder.lower(root.get("fullName")), pattern),
builder.like(builder.lower(root.get("jobTitle")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort userSort(String field, String direction) {
String property = switch (field) {
case "username" -> "username";
case "role" -> "role";
case "status" -> "status";
default -> "fullName";
};
return "desc".equalsIgnoreCase(direction)
? Sort.by(property).descending()
: Sort.by(property).ascending();
}
private int normalizePage(int page) {
return Math.max(page, 0);
}
private int normalizeSize(int size) {
return Math.max(1, Math.min(size, 100));
}
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){

View File

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

View File

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

View File

@@ -4,12 +4,14 @@ public class TeacherSubjectResponse {
private Long userId;
private String username;
private String fullName;
private Long subjectId;
private String subjectName;
public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
public TeacherSubjectResponse(Long userId, String username, String fullName, Long subjectId, String subjectName) {
this.userId = userId;
this.username = username;
this.fullName = fullName;
this.subjectId = subjectId;
this.subjectName = subjectName;
}
@@ -22,6 +24,10 @@ public class TeacherSubjectResponse {
return username;
}
public String getFullName() {
return fullName;
}
public Long getSubjectId() {
return subjectId;
}

View File

@@ -3,14 +3,17 @@ package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendar;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long> {
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long>, JpaSpecificationExecutor<AcademicCalendar> {
long countByStudyFormId(Long studyFormId);
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
where calendar.id = :id
""")
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
@Query(value = """
select calendar
from AcademicCalendar calendar
join fetch calendar.academicYear academicYear
join fetch calendar.speciality speciality
join fetch calendar.specialtyProfile specialtyProfile
join fetch calendar.studyForm studyForm
where (:query = ''
or lower(calendar.title) like concat('%', :query, '%')
or lower(academicYear.title) like concat('%', :query, '%')
or lower(speciality.specialityCode) like concat('%', :query, '%')
or lower(speciality.specialityName) like concat('%', :query, '%')
or lower(specialtyProfile.name) like concat('%', :query, '%')
or lower(studyForm.name) like concat('%', :query, '%'))
and (:academicYearId is null or academicYear.id = :academicYearId)
and (:specialtyId is null or speciality.id = :specialtyId)
and (:profileId is null or specialtyProfile.id = :profileId)
and (:studyFormId is null or studyForm.id = :studyFormId)
""",
countQuery = """
select count(calendar)
from AcademicCalendar calendar
join calendar.academicYear academicYear
join calendar.speciality speciality
join calendar.specialtyProfile specialtyProfile
join calendar.studyForm studyForm
where (:query = ''
or lower(calendar.title) like concat('%', :query, '%')
or lower(academicYear.title) like concat('%', :query, '%')
or lower(speciality.specialityCode) like concat('%', :query, '%')
or lower(speciality.specialityName) like concat('%', :query, '%')
or lower(specialtyProfile.name) like concat('%', :query, '%')
or lower(studyForm.name) like concat('%', :query, '%'))
and (:academicYearId is null or academicYear.id = :academicYearId)
and (:specialtyId is null or speciality.id = :specialtyId)
and (:profileId is null or specialtyProfile.id = :profileId)
and (:studyFormId is null or studyForm.id = :studyFormId)
""")
Page<AcademicCalendar> searchOptions(@Param("query") String query,
@Param("academicYearId") Long academicYearId,
@Param("specialtyId") Long specialtyId,
@Param("profileId") Long profileId,
@Param("studyFormId") Long studyFormId,
Pageable pageable);
}

View File

@@ -2,12 +2,29 @@ package com.magistr.app.repository;
import com.magistr.app.model.Classroom;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
import java.util.List;
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
public interface ClassroomRepository extends JpaRepository<Classroom, Long>, JpaSpecificationExecutor<Classroom> {
Optional<Classroom> findByName(String name);
List<Classroom> findByStatusNot(String status);
@Query("""
select classroom
from Classroom classroom
where classroom.status <> :archivedStatus
and (:query = ''
or lower(classroom.name) like concat('%', :query, '%')
or lower(coalesce(classroom.building, '')) like concat('%', :query, '%'))
""")
Page<Classroom> searchActiveOptions(@Param("query") String query,
@Param("archivedStatus") String archivedStatus,
Pageable pageable);
}

View File

@@ -3,6 +3,7 @@ package com.magistr.app.repository;
import com.magistr.app.model.StudentGroup;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -10,7 +11,7 @@ import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
public interface GroupRepository extends JpaRepository<StudentGroup, Long>, JpaSpecificationExecutor<StudentGroup> {
List<StudentGroup> findByEducationFormId(Long educationFormId);

View File

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

View File

@@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
""")
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
@Query("""
select subgroup
from Subgroup subgroup
join fetch subgroup.studentGroup studentGroup
where studentGroup.id in :groupIds
and subgroup.status <> 'ARCHIVED'
order by studentGroup.name, subgroup.name
""")
List<Subgroup> findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List<Long> groupIds);
@Query("""
select subgroup
from Subgroup subgroup

View File

@@ -2,11 +2,12 @@ package com.magistr.app.repository;
import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> {
public interface SubjectRepository extends JpaRepository<Subject, Long>, JpaSpecificationExecutor<Subject> {
Optional<Subject> findByNameIgnoreCase(String name);
List<Subject> findByDepartmentId(Long departmentId);

View File

@@ -3,10 +3,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.TeacherCreationRequest;
import com.magistr.app.model.TeacherCreationRequestStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long>, JpaSpecificationExecutor<TeacherCreationRequest> {
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);

View File

@@ -12,6 +12,16 @@ public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject,
List<TeacherSubject> findByUserId(Long userId);
List<TeacherSubject> findBySubjectId(Long subjectId);
@Query("""
select teacherSubject
from TeacherSubject teacherSubject
join fetch teacherSubject.user
join fetch teacherSubject.subject
where teacherSubject.subjectId in :subjectIds
order by teacherSubject.subject.name, teacherSubject.user.fullName
""")
List<TeacherSubject> findBySubjectIdInWithDetails(@Param("subjectIds") List<Long> subjectIds);
@Query(value = """
select subject_link.user_id
from teacher_subjects subject_link

View File

@@ -4,14 +4,18 @@ import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
Optional<User> findByUsername(String username);
@@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
@Query(value = """
select user
from User user
where user.role = :role
and user.status <> :archivedStatus
and (:query = ''
or lower(user.username) like concat('%', :query, '%')
or lower(user.fullName) like concat('%', :query, '%')
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
and (:departmentId is null or exists (
select assignment.id
from TeacherDepartmentAssignment assignment
where assignment.teacher.id = user.id
and assignment.department.id = :departmentId
and assignment.validFrom <= :date
and (assignment.validTo is null or assignment.validTo >= :date)
))
""",
countQuery = """
select count(user)
from User user
where user.role = :role
and user.status <> :archivedStatus
and (:query = ''
or lower(user.username) like concat('%', :query, '%')
or lower(user.fullName) like concat('%', :query, '%')
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
and (:departmentId is null or exists (
select assignment.id
from TeacherDepartmentAssignment assignment
where assignment.teacher.id = user.id
and assignment.department.id = :departmentId
and assignment.validFrom <= :date
and (assignment.validTo is null or assignment.validTo >= :date)
))
""")
Page<User> searchTeacherOptions(@Param("query") String query,
@Param("role") Role role,
@Param("archivedStatus") String archivedStatus,
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date,
Pageable pageable);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from User user where user.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id);

View File

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

View File

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

View File

@@ -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 дней");
}
}

View File

@@ -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 дней");
}
}

View File

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

View File

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

View File

@@ -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,

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,6 @@ import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.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),

View File

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

View File

@@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -74,6 +75,52 @@ class TeacherSubjectControllerTest {
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
}
@Test
void returnsAssignmentsOnlyForRequestedSubjects() {
TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class);
UserRepository userRepository = mock(UserRepository.class);
SubjectRepository subjectRepository = mock(SubjectRepository.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
User teacher = teacher();
Subject subject = new Subject();
subject.setId(20L);
subject.setName("Высшая математика");
TeacherSubject assignment = new TeacherSubject(teacher.getId(), subject.getId());
setTeacherSubjectDetails(assignment, teacher, subject);
when(teacherSubjectRepository.findBySubjectIdInWithDetails(List.of(subject.getId())))
.thenReturn(List.of(assignment));
TeacherSubjectController controller = new TeacherSubjectController(
teacherSubjectRepository,
userRepository,
subjectRepository,
teacherDepartmentService,
BUSINESS_TIME
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
var result = controller.getAll(List.of(subject.getId()));
assertThat(result).singleElement().satisfies(response -> {
assertThat(response.getSubjectId()).isEqualTo(subject.getId());
assertThat(response.getUserId()).isEqualTo(teacher.getId());
assertThat(response.getFullName()).isEqualTo(teacher.getFullName());
});
verify(teacherSubjectRepository).findBySubjectIdInWithDetails(List.of(subject.getId()));
}
private void setTeacherSubjectDetails(TeacherSubject assignment, User teacher, Subject subject) {
try {
var userField = TeacherSubject.class.getDeclaredField("user");
userField.setAccessible(true);
userField.set(assignment, teacher);
var subjectField = TeacherSubject.class.getDeclaredField("subject");
subjectField.setAccessible(true);
subjectField.set(assignment, subject);
} catch (ReflectiveOperationException exception) {
throw new AssertionError("Не удалось подготовить тестовую привязку", exception);
}
}
private User teacher() {
User teacher = new User();
teacher.setId(10L);

View File

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

View File

@@ -0,0 +1,238 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@DisplayName("Миграция тестовых данных и начальных периодов календарного графика")
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V2 заполняет кодом Т только полностью пустые графики")
void migrationBackfillsOnlyEmptyCalendars() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
long emptyCalendarId;
long configuredCalendarId;
try (Connection connection = POSTGRES.createConnection("")) {
prepareCustomCalendarPrerequisites(connection);
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
insertConfiguredPeriod(connection, configuredCalendarId);
}
Flyway allMigrations = flyway(null);
allMigrations.migrate();
assertThat(allMigrations.info().current()).isNotNull();
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(readSummary(connection, emptyCalendarId))
.isEqualTo(new PeriodSummary(3, 1, 3, true));
assertThat(readSummary(connection, configuredCalendarId))
.isEqualTo(new PeriodSummary(1, 1, 1, false));
}
}
@Test
@DisplayName("V1 создаёт только схему, а V2 загружает полный тестовый набор")
void migrationSeparatesSchemaAndTestData() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """
SELECT
(SELECT COUNT(*) FROM departments)
+ (SELECT COUNT(*) FROM users)
+ (SELECT COUNT(*) FROM student_groups)
+ (SELECT COUNT(*) FROM subjects)
+ (SELECT COUNT(*) FROM academic_years)
+ (SELECT COUNT(*) FROM schedule_rules)
"""))
.as("После V1 доменные таблицы должны быть пустыми")
.isZero();
}
Flyway allMigrations = flyway(null);
allMigrations.migrate();
assertThat(allMigrations.info().current()).isNotNull();
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, "SELECT COUNT(*) FROM departments")).isEqualTo(41);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM specialties")).isEqualTo(42);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM users WHERE role = 'TEACHER'"))
.isEqualTo(151);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_groups")).isEqualTo(202);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subgroups")).isEqualTo(404);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subjects")).isEqualTo(229);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM classrooms")).isEqualTo(80);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendars")).isEqualTo(132);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendar_periods")).isEqualTo(576);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_group_calendar_assignments"))
.isEqualTo(202);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rules")).isEqualTo(610);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rule_slots")).isEqualTo(610);
}
}
private Flyway flyway(String targetVersion) {
var configuration = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.cleanDisabled(false);
if (targetVersion != null) {
configuration.target(MigrationVersion.fromVersion(targetVersion));
}
return configuration.load();
}
private long insertCalendarFromSeed(Connection connection,
String title,
int courseCount) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendars (
title,
academic_year_id,
specialty_id,
specialty_profile_id,
study_form_id,
course_count
)
SELECT ?, academic_year.id, specialty.id, profile.id, study_form.id, ?
FROM academic_years academic_year
JOIN specialties specialty ON specialty.specialty_code = '99.99.99'
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
JOIN education_forms study_form ON study_form.name = 'Тестовая форма'
WHERE academic_year.title = '2030-2031'
RETURNING id
""")) {
statement.setString(1, title);
statement.setInt(2, courseCount);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void prepareCustomCalendarPrerequisites(Connection connection) throws SQLException {
try (var statement = connection.createStatement()) {
statement.executeUpdate("""
INSERT INTO departments (name, code)
VALUES ('Тестовая кафедра для V2', 9999)
""");
statement.executeUpdate("""
INSERT INTO specialties (name, specialty_code)
VALUES ('Тестовое направление для V2', '99.99.99')
""");
statement.executeUpdate("""
INSERT INTO specialty_profiles (specialty_id, name)
SELECT id, 'Тестовый профиль для V2'
FROM specialties
WHERE specialty_code = '99.99.99'
""");
statement.executeUpdate("""
INSERT INTO education_forms (name)
VALUES ('Тестовая форма')
""");
statement.executeUpdate("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES ('2030-2031', DATE '2030-09-01', DATE '2031-06-30')
""");
statement.executeUpdate("""
INSERT INTO academic_calendar_activity_types (
code, name, allow_schedule, color_code, display_order
) VALUES
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10),
('К', 'Каникулы', FALSE, '#38bdf8', 30)
""");
}
}
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT calendar.id, 1, academic_year.start_date, academic_year.start_date, activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'К'
WHERE calendar.id = ?
""")) {
statement.setLong(1, calendarId);
assertThat(statement.executeUpdate()).isEqualTo(1);
}
}
private PeriodSummary readSummary(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT
COUNT(*),
MIN(period.course_number),
MAX(period.course_number),
BOOL_AND(
activity.code = 'Т'
AND period.start_date = academic_year.start_date
AND period.end_date = academic_year.end_date
)
FROM academic_calendar_periods period
JOIN academic_calendars calendar ON calendar.id = period.calendar_id
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.id = period.activity_type_id
WHERE period.calendar_id = ?
""")) {
statement.setLong(1, calendarId);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return new PeriodSummary(
resultSet.getInt(1),
resultSet.getInt(2),
resultSet.getInt(3),
resultSet.getBoolean(4)
);
}
}
}
private long queryLong(Connection connection, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
private record PeriodSummary(int count,
int minCourse,
int maxCourse,
boolean fullYearTheory) {
}
}

View File

@@ -1,6 +1,7 @@
package com.magistr.app.migration;
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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -230,10 +230,39 @@ Refresh-токен ротируется при каждом успешном о
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
### `GET /api/users/page`
Пагинированный реестр пользователей для администратора. Параметры: `query`, `role`,
`departmentId`, `includeArchived`, `page`, `size`, `sort`, `direction`. Размер страницы
ограничен диапазоном `1..100`; поддерживаемые поля сортировки — `fullName`, `username`,
`role`, `status`.
Пагинированные endpoints используют общий ответ:
```json
{
"items": [],
"page": 0,
"size": 25,
"totalItems": 0,
"totalPages": 0,
"first": true,
"last": true
}
```
### `GET /api/users/teachers`
Список только преподавателей (роль `TEACHER`).
### `GET /api/users/teachers/options`
Серверный поиск преподавателей для асинхронных списков. Параметры: `query`,
`departmentId`, `selectedId`, `limit` (`1..50`). Поиск выполняется по логину, ФИО и
должности; архивные записи исключаются. Для роли `DEPARTMENT` backend принудительно
использует кафедру текущего пользователя и учитывает действующие основные и дополнительные
назначения из `teacher_department_assignments`. `selectedId` не обходит это ограничение.
### `GET /api/users/teachers/{departmentId}`
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
@@ -309,6 +338,7 @@ Refresh-токен ротируется при каждом успешном о
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
| `GET` | `/api/teacher-requests/page?query=&status=PENDING&page=0&size=25` | Пагинированный поиск заявок по логину, ФИО, должности и кафедре |
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
@@ -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",
"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": "Высшая математика"
}

View File

@@ -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`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.

View File

@@ -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-аккаунты.
### Полный сброс БД (локально)

View File

@@ -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
```

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -95,10 +95,11 @@ Backend собирается через multi-stage сборку Maven:
```dockerfile
FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets
RUN npm ci
RUN npm run build:vendor
RUN npm run build
FROM httpd:alpine3.23@sha256:4a15e9c...
RUN apk upgrade --no-cache
COPY --from=frontend-assets /build/dist/react/ /usr/local/apache2/htdocs/react/
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
@@ -106,8 +107,10 @@ COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
tag и manifest digest. Первый этап frontend собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
OpenTelemetry bundle и production-bundle React-страницы входа; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. В корень образа также копируются
`ui-foundation.css`, `schedule-overview.css` и `schedule-overview.js`: они необходимы
для общего шрифта, компонентов и обзора занятий в личных кабинетах. Apache подключает `mod_headers`,
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
маршрутизировался в tenant `default`. Перед копированием runtime-файлов устанавливаются
доступные исправления Alpine через `apk upgrade --no-cache`. Сервер

675
docs/REACT_MIGRATION.md Normal file
View File

@@ -0,0 +1,675 @@
# План перехода frontend на React
## Зачем выполняется миграция
Цель перехода — снизить сложность развития интерфейса, сделать состояние страниц явным,
переиспользовать компоненты и тестировать пользовательские сценарии изолированно. Сам React
не гарантирует меньший размер страницы: вес, время загрузки и объём запросов контролируются
отдельными бюджетами.
Миграция выполняется постепенно по модели «strangler»: React и существующие ES-модули
временно работают рядом, а переключение происходит по целым страницам или независимым
разделам. Большой одномоментный rewrite не планируется.
## Обязательные ограничения
- Сохраняются текущие URL, REST API и серверная ролевая модель.
- Access JWT остаётся только в памяти, refresh-токен — в `HttpOnly` cookie.
- Нельзя ослаблять CSP: удалённые и inline-скрипты по-прежнему запрещены.
- UI, ошибки и пользовательские сообщения остаются на русском языке.
- Общие CSS-токены и светлая/тёмная темы сохраняются до отдельного решения о редизайне.
- Каждый перенесённый срез должен иметь тесты, production-сборку и измеримый бюджет bundle.
- Vanilla-код удаляется только после переключения соответствующего маршрута на React.
## Целевая схема
```text
frontend/
├── react/
│ ├── shared/ # Общие React-компоненты, auth/api и состояния загрузки
│ ├── login/ # Страница входа
│ ├── student/ # Кабинет студента
│ ├── teacher/ # Кабинет преподавателя
│ └── admin/ # Shell, маршруты и feature-модули админ-панели
├── dist/react/ # Скомпилированные production-bundle, не хранятся в Git
├── admin/, student/, ... # Legacy-срезы до завершения их миграции
└── scripts/build-react.mjs # Сборка React через esbuild
```
На переходном этапе Apache продолжает раздавать статические файлы и проксировать `/api`.
React-bundle собираются в существующем Node-этапе Docker-образа. После появления нескольких
React entrypoint сборка должна выделить общий runtime в кэшируемый chunk, а функциональные
разделы — загружать лениво.
## Этапы
| Этап | Содержание | Критерий завершения | Статус |
|------|------------|---------------------|--------|
| 0. Инвентаризация | Карта entrypoint, маршрутов, общих модулей и рисков | Выбран независимый первый срез; зафиксированы ограничения | Завершён |
| 1. Основа + вход | React/React DOM, esbuild entrypoint, Docker, перенос `/`, тест API-контракта и бюджет bundle | `/` работает на React без изменения auth-контракта; `npm run check` проходит | Завершён |
| 2. Общий React-слой | `react/shared`: auth/api adapters, состояния loading/empty/error, Error Boundary; общий runtime chunk; DOM-тесты | Следующие страницы не дублируют инфраструктурный код | Завершён |
| 3. Автономные кабинеты | Сначала `/student/`, затем `/teacher/`; компоненты расписания и навигации | Оба URL полностью работают на React, legacy `app.js` удалены | Завершён: `/student/` и `/teacher/` перенесены |
| 4. Настройки | React-shell `/admin/settings/`, затем вкладки `general`, `time-slots`, `edu-forms`, `database` | Роли и hash-навигация совпадают с текущим SPA | Завершён: `/admin/settings/` перенесён |
| 5. Основная админ-панель | Shell и hash-router, затем перенос feature-модулей волнами | Все роли и вкладки работают на React, HTML-фрагменты больше не загружаются через `fetch` | Завершён: shell, hash-router и все 15 вкладок (волны 1–6) перенесены, переходный `LegacyTabView`/`legacy-views.js` удалён |
| 6. Очистка и оптимизация | Удаление legacy-loader, дедупликация CSS, lazy loading, cache/compression, финальные performance budgets | Нет исполняемого legacy UI-кода; контрольные метрики не хуже baseline | Не начат |
### Очерёдность админских feature-модулей
1. Простые справочники: `classrooms`, `subjects`, `dashboard`.
2. Реестры и формы: `users`, `groups`, `university-structure`.
3. Ролевые рабочие места: `teacher-requests`, `department-workspace`.
4. Календарь и нагрузка: `academic-calendar`, `auditorium-workload`.
5. Контур публикации: `schedule-view`, `schedule-versions`, `schedule-quality`,
`teacher-absences`.
6. Последними — `schedule` и `schedule-override-panel`, потому что у них больше всего
связей, локального состояния и бизнес-ограничений.
## Стратегия проверки
- На каждом этапе: `npm run build`, `npm run check` и smoke-проверка Docker-образа.
- Для чистой логики сохраняются быстрые тесты на `node:test`.
- На этапе 2 добавляются DOM/component-тесты для форм, ошибок, клавиатурной навигации и ролей.
- Перед удалением legacy-среза сравниваются URL, payload запросов, редиректы и доступность.
- Для React entrypoint действует gzip-бюджет. Первый срез `/` ограничен 75 000 байт gzip;
следующие feature-chunk получают отдельные лимиты после выделения общего runtime.
- Для тяжёлых разделов измеряются число запросов, transferred bytes и время до готовности
интерфейса; миграция не считается оптимизацией без улучшения или сохранения этих метрик.
## Риски и меры
| Риск | Мера |
|------|------|
| Рост bundle из-за React runtime | Общий кэшируемый chunk, lazy loading, лимит сборки |
| Дублирование auth/api логики | Завершить этап 2 до переноса следующей страницы |
| Рассинхрон ролей admin/settings | Использовать текущую `role-capabilities.js` как единый контракт до её переноса |
| Поломка CSP | Проверять `.jsx` теми же security-тестами, что `.js` и `.html` |
| Два источника истины в одном экране | Переключать целый маршрут/вкладку, не смешивать управление одним DOM-деревом |
| Незаметная деградация производительности | Фиксировать raw/gzip размер и браузерные метрики на каждом этапе |
## Точка остановки
**Дата:** 28 сентября 2026 года
**Остановились после этапа 5, волны 1 — вкладки `dashboard`, `classrooms`, `subjects`
основной админ-панели переведены на React-компоненты с lazy-загрузкой.**
Выполнено на этапе 2 (общий слой):
- `react/shared`: `apiClient`/`requestJson`, `auth-client` + `session-policy`,
`useAuthorizedSession`, `AppErrorBoundary`, `AsyncState`, `FormAlert`;
- общий кэшируемый runtime-chunk `dist/react/shared/chunks/` (esbuild `splitting`),
Apache отдаёт chunk с `immutable`, entrypoint — `no-cache`;
- DOM-проверки через `renderToStaticMarkup`, бюджеты сборки.
Выполнено на этапе 3 (кабинет студента):
- `react/student/`: `StudentApp` (сессия через `useAuthorizedSession(['STUDENT'])`,
выбор группы, навигация по неделям, состояния loading/empty/error с повтором),
`WeekGrid`, `ScheduleOverview`, `student-service` (дата-математика, чипы, контракты API);
- React-версия поискового списка `AsyncCombobox` вынесена в `react/shared/ui/` с той же
семантикой ARIA, debounce 320 мс и клавиатурной навигацией, что и vanilla-версия;
- модель обзора «Сегодня / Следующая пара» переиспользует чистые функции
`schedule-overview.js` (нужные помощники экспортированы аддитивно);
- контракты сохранены: `GET /api/groups/options`, `GET /api/schedule`,
`localStorage.studentGroupId`, query-параметры `studentGroup`/`studentDate`,
telemetry-события `student_*`, logout через общий `logoutAndRedirect`;
- `student/index.html` монтирует `/react/student.js`, legacy `student/app.js` удалён;
- бюджеты: entrypoint студента 5 819 байт gzip (лимит 20 000), холодная загрузка
78 309 байт gzip (лимит 90 000), общие chunk 72 490 байт gzip (лимит 75 000);
- `npm run check`: 124 теста пройдено (13 новых для кабинета студента).
Выполнено на этапе 3 (кабинет преподавателя):
- `react/teacher/`: `TeacherApp` (сессия через `useAuthorizedSession(['TEACHER'])`,
вкладки расписания/пожеланий/заявок, навигация по неделям), `TeacherWeekGrid`
(матрица слотов на десктопе и дневной список на мобильных через `matchMedia`),
`AbsencePanel`, `PreferencesTab`, `RequestsTab`, `RequestDialog`,
`teacher-service` (слоты, матрица недели, метки статусов, payload заявок,
контракты API);
- дата-математика выделена в `react/shared/dates.js` (единый источник для
студента и преподавателя, `student-service` реэкспортирует),
`ScheduleOverview` перенесён в `react/shared/ui/` и переиспользуется обоими
кабинетами с ролью `student`/`teacher`;
- контракты сохранены: `GET /api/schedule?teacherId=`, `GET|POST|DELETE
/api/teacher-preferences`, `GET|POST|DELETE /api/teacher-change-requests`,
`GET /api/teacher-change-requests/candidates`, `GET|POST|DELETE
/api/teacher-absences`, query-параметры `teacherTab`/`teacherDate`,
telemetry-события `teacher_*`;
- `teacher/index.html` монтирует `/react/teacher.js`, legacy `teacher/app.js` удалён;
- бюджеты: entrypoint преподавателя 10 095 байт gzip (лимит 20 000), холодная
загрузка 84 590 байт gzip (лимит 90 000), общие chunk 74 495 байт gzip
(лимит 75 000);
- `npm run check`: 146 тестов пройдено (22 новых для кабинета преподавателя).
Выполнено на этапе 4 (страница настроек):
- `react/settings/`: `SettingsApp` (сессия через `useAuthorizedSession(SETTINGS_ROLES)`,
боковая панель с фильтрацией вкладок по `role-capabilities.js`, сворачивание панели
с сохранением в `localStorage.sidebar-collapsed`, тема в React вместо
`theme-toggle.js`), вкладки `GeneralTab`, `TimeSlotsTab`, `EduFormsTab`, `DatabaseTab`,
`settings-service` (метки сеток времени, валидация слота/тенанта, контракты API);
- общий `ConfirmDialog` в `react/shared/ui/` переиспользует классы `.project-dialog*`
и закрывается по Escape/клику по фону;
- роли и навигация совпадают с legacy-SPA: у настроек не было hash-навигации,
активная вкладка живёт в состоянии React, как раньше — в памяти страницы;
ADMIN видит все четыре вкладки (по умолчанию `general`), EDUCATION_OFFICE —
`time-slots` и `edu-forms` (по умолчанию `time-slots`);
- inline-стили legacy (`database.html`, `database.js`) заменены классами
`.db-status-grid`, `.form-group-wide`, `.form-row-actions` в `admin/settings/css/main.css`,
CSP-хэши `style-src-attr` в `security.conf` пересобраны по фактическим атрибутам;
- контракты сохранены: `GET|POST|DELETE /api/admin/time-slots/scopes`,
`GET|POST|PUT|DELETE /api/admin/time-slots`, `GET|POST|DELETE /api/education-forms`,
`GET /api/database/status`, `GET|POST|DELETE /api/database/tenants`,
`POST /api/database/test`;
- `admin/settings/index.html` монтирует `/react/settings.js`, legacy
`admin/settings/js/` и `admin/settings/views/` удалены;
- бюджеты: entrypoint настроек 9 172 байта gzip (лимит 20 000), холодная загрузка
83 667 байт gzip (лимит 90 000), общие chunk 74 495 байт gzip (лимит 75 000);
- `npm run check`: 159 тестов пройдено (13 новых для страницы настроек).
Выполнено на этапе 5 (срез 1 — React-shell основной админ-панели):
- `react/admin/`: `AdminApp` (сессия через `useAuthorizedSession(ADMIN_APP_ROLES)`) и
`AdminWorkspace` (sidebar с секциями и фильтрацией по `role-capabilities.js`,
topbar с заголовком вкладки и счётчиком заявок, тема в React вместо
`theme-toggle.js`, сворачивание панели `localStorage.sidebar-collapsed`,
настройки с выходом);
- hash-router в React: чтение `location.hash` при старте, слушатель `hashchange`,
переключение через `requestTabSwitch` с guard'ом `confirmAbandonDirtyChanges`
(единый модуль `admin/js/dirty-state.js` для React и legacy-вкладок);
- счётчик ожидающих заявок (только ADMIN): `GET /api/teacher-requests/page?status=PENDING&page=0&size=1`,
обновление по событию `teacherRequestsChanged`, склонение
«заявка/заявки/заявок», бейдж в навигации и заголовке;
- переходный слой `LegacyTabView` + `legacy-views.js` (удалён после волны 6):
ещё не перенесённые вкладки загружались как lazy-chunkи (fetch HTML-фрагмента
+ `init*()` legacy-модуля), каждая вьюха целиком владела своим DOM-поддеревом —
React и vanilla не смешивались в одном дереве; после переноса вкладки её
загрузчик и legacy-файл удалялись;
- глобальные утилиты legacy (`applyRippleEffect`, `closeAllDropdownsOnOutsideClick`,
`initAllCustomDropdowns`, `startDropdownAutoObserver`) вызываются оболочкой один раз;
- `admin/index.html` монтирует `/react/admin.js`, legacy `admin/js/main.js` удалён;
- бюджеты: entrypoint админ-панели 5 399 байт gzip (лимит 20 000), холодная загрузка
79 977 байт gzip (лимит 90 000), общие chunk 74 578 байт gzip (лимит 75 000),
переходные lazy-вкладки 109 512 байт gzip (лимит 150 000, загружаются по одной);
- `npm run check`: 170 тестов пройдено (10 новых для оболочки админки).
Выполнено на этапе 5 (волна 1 — `dashboard`, `classrooms`, `subjects`):
- `react/admin/react-tabs.js` — реестр перенесённых вкладок: каждая вкладка
загружается отдельным lazy-chunk через `React.lazy` + `Suspense`
(fallback — `tab-loading`); вкладки без записи в реестре едут через
`LegacyTabView`;
- `react/admin/tabs/DashboardTab.jsx`: метрики, мониторинг текущей пары
(обновление каждые 30 с), свободные аудитории, быстрый переход, Red Zone;
чистая логика выявления конфликтов (накладки преподавателей/аудиторий,
превышение вместимости) перенесена в `dashboard-service.js`
(`detectScheduleConflicts` возвращает сегменты с выделением вместо HTML-строк),
презентации состояния — из общего `dashboard-conflicts.js`;
- `react/admin/tabs/ClassroomsTab.jsx`: форма создания с React-мультиселектом
оборудования (замена `initMultiSelect`), таблица с переключением статуса,
модалки редактирования аудитории и справочника оборудования;
- `react/admin/tabs/SubjectsTab.jsx`: реестр с поиском (debounce 320 мс),
фильтром кафедры, сортировкой, пагинацией (общий `react/shared/ui/Pagination.jsx`),
сохранением состояния в URL (`subjectQuery`/`subjectDepartment`/`subjectPage`/
`subjectSize`/`subjectSort`), модалка управления преподавателями с
`AsyncCombobox`; контракты `GET /api/subjects/page`, `GET|POST|DELETE
/api/teacher-subjects` сохранены;
- legacy-файлы вкладок удалены (`admin/js/views/{dashboard,classrooms,subjects}.js`,
`admin/views/{dashboard,classrooms,subjects}.html`), загрузчики убраны из
`legacy-views.js`, у маршрутов перенесённых вкладок в `admin-service.js`
больше нет `viewFile`;
- inline-стили вкладок заменены классами в `admin/css/components.css`
(дашборд: метрики/Red Zone/свободные аудитории; общие: `card-toolbar`,
`form-row--*`, `modal-content--medium`, `th-actions`, `badge-list` и др.);
CSP-хэши `style-src-attr` в `security.conf` пересчитаны (50 → 22);
- классификация chunk'ов в `build-react.mjs` переведена на достижимость от
entrypoint'ов: chunk, достижимый только из `dist/react/admin.js`, — lazy-вкладка
(в общий runtime-бюджет не входит), даже если внутри есть модуль `react/shared`
(например, `Pagination` внутри chunk'а `SubjectsTab`);
- бюджеты: entrypoint админ-панели 5 530 байт gzip (лимит 20 000), холодная
загрузка 83 877 байт gzip (лимит 90 000), общие chunk 78 347 байт gzip
(лимит поднят 75 000 → 85 000: `ConfirmDialog` и `AsyncCombobox` стали общими
chunk'ами — их теперь используют и React-вкладки админки; лимит холодной
загрузки входа поднят 80 000 → 85 000 по той же причине), lazy-вкладки
112 753 байта gzip в 26 chunk'ах (лимит 150 000, загружаются по одной);
- `npm run check`: 182 теста пройдено (12 новых: `react-admin-tabs.test.mjs`).
Выполнено на этапе 5 (волна 2 — `users`, `groups`, `university-structure`):
- `react/admin/tabs/UsersTab.jsx`: форма создания пользователя (пароль —
`type="password"` + `autoComplete="new-password"`), реестр с поиском (debounce
320 мс), фильтром роли, сортировкой и пагинацией, состояние в URL
(`userQuery`/`userRole`/`userPage`/`userSize`/`userSort`), архивирование через
`ConfirmDialog` и восстановление; контракты `GET /api/users/page`,
`POST /api/users`, `DELETE /api/users/{id}`, `POST /api/users/{id}/restore`
сохранены; чистая логика — `users-service.js`;
- `react/admin/tabs/GroupsTab.jsx`: форма создания с каскадом
специальность → профиль, реестр с мультиселектом форм обучения
(React-фильтр с чекбоксами «Все» + формы, как `EquipmentMultiSelect`),
поиском, сортировкой, сбросом фильтров и пагинацией, состояние в URL
(включая массив `groupEducationForm`); модалки редактирования группы,
подгрупп (режимы 0/2/3, подсказанные вместимости, валидация суммы,
legacy-порядок синхронизации: лишние удаляются, затем обновления —
сначала уменьшающие вместимость, затем создания) и календарных графиков
(учебный год + `AsyncCombobox` совместимых графиков, список назначений с
дисциплинами по семестрам); контракты `GET /api/groups/page`,
`POST|PUT|DELETE /api/groups...`, `/subgroups`, `/calendar-assignments`,
`GET /api/admin/academic-calendars/options` сохранены; чистая логика —
`groups-service.js` (заголовки графиков — общий
`admin/js/views/academic-calendar-title.js`);
- `react/admin/tabs/UniversityStructureTab.jsx`: внутренние разделы
кафедр/специальностей/профилей, формы создания и редактирования
(модалки кафедр и специальностей, форма профиля на вкладке и в модалке
специальности), клиентская фильтрация и пагинация списков как в legacy
`listPage` (поиск с debounce 250 мс, `toLocaleLowerCase('ru')`); контракты
`GET|POST|PUT|DELETE /api/departments`, `/api/specialties...`,
`/api/specialties/profiles` сохранены; чистая логика —
`university-structure-service.js`;
- legacy-файлы вкладок удалены (`admin/js/views/{users,groups,
university-structure}.js`, `admin/views/{users,groups,
university-structure}.html`), загрузчики убраны из `legacy-views.js`,
у маршрутов в `admin-service.js` больше нет `viewFile`;
- inline-стили заменены классами в `admin/css/components.css`
(`modal-content--wide/--xwide/--full`, `profile-form-*`,
`form-actions-row`); CSP-хэши `style-src-attr` пересчитаны (22 → 6);
- бюджеты: entrypoint админ-панели 5 508 байт gzip (лимит 20 000), холодная
загрузка 83 855 байт gzip (лимит 90 000), общие chunk 78 347 байт gzip
(лимит 85 000), lazy-вкладки 118 009 байт gzip в 26 chunk'ах (лимит
150 000, загружаются по одной) — лимиты не поднимались;
- `npm run check`: 195 тестов пройдено (13 новых: контракты, валидация
подгрупп, фильтрация списков, DOM-рендеры вкладок волны 2).
Выполнено на этапе 5 (волна 3 — `teacher-requests`, `department-workspace`):
- `react/admin/tabs/TeacherRequestsTab.jsx`: реестр заявок с поиском (debounce
320 мс), фильтром статуса (по умолчанию `PENDING`), пагинацией и состоянием
в URL (`requestQuery`/`requestStatus`/`requestPage`/`requestSize`);
редактируемые строки (кафедра, логин, ФИО, должность, пароль —
`type="password"` + `autoComplete="new-password"`), одобрение с валидацией
legacy (все поля + пароль от 8 символов), отклонение через новый общий
`PromptDialog` (реплика `promptAction`: обязательная причина, ошибка внутри
диалога); событие `teacherRequestsChanged` для счётчика sidebar
диспетчируется как в legacy (count актуален только для нефильтрованного
списка `PENDING`); контракты `GET /api/teacher-requests/page`,
`POST /api/teacher-requests/{id}/approve|reject` сохранены; чистая логика —
`teacher-requests-service.js`;
- `react/shared/ui/ConfirmDialog.jsx`: добавлен `PromptDialog` — реплика
legacy `promptAction` из `admin/js/dialog.js` (разметка `project-dialog*`,
обязательное поле с `requiredMessage`, Escape/клик по фону закрывают);
- `react/admin/tabs/DepartmentWorkspaceTab.jsx`: карточка управления
(кафедра — скрыта для роли `DEPARTMENT`, период, обновление), загрузка
дисциплин строками (добавить/удалить, валидация legacy), привязка
преподавателя (доступные = все минус привязанные), заявка на преподавателя,
сводка «Преподаватели и нагрузка» (сопоставление по `id:` и нормализованному
имени, непривязанная нагрузка — «Есть занятия в расписании кафедры», как в
legacy), таблицы заявок и дисциплин кафедры, комментарии к дисциплинам
(карточка открывается по кнопке «Открыть»); период по умолчанию — из
справочника семестров (`defaultSchedulePeriod`) с fallback «сегодня + 7
дней»; сессия — `getSession()` из `admin/js/api.js`, как в legacy; контракты
`/api/department/*`, `/api/workload/teachers`, `/api/schedule/semesters`,
`/api/users/teachers` сохранены; чистая логика —
`department-workspace-service.js`;
- legacy-файлы вкладок удалены (`admin/js/views/{teacher-requests,
department-workspace}.js`, `admin/views/{teacher-requests,
department-workspace}.html`), загрузчики убраны из `legacy-views.js`,
у маршрутов в `admin-service.js` больше нет `viewFile`;
- inline-стиль `style="margin:0"` из legacy HTML заменён классом
`schedule-section-label--flush` в `admin/css/components.css`; CSP-хэши
`style-src-attr` пересчитаны (6 → 5);
- бюджеты: entrypoint админ-панели 4 371 байт gzip (лимит 20 000), холодная
загрузка 83 268 байт gzip (лимит 90 000), общие chunk 78 897 байт gzip
(лимит 85 000; `PromptDialog` добавил ~550 байт gzip), lazy-вкладки
121 368 байт gzip в 27 chunk'ах (лимит 150 000, загружаются по одной) —
лимиты не поднимались;
- `npm run check`: 205 тестов пройдено (10 новых: состояние и контракты
заявок, payload-валидация кабинета кафедры, сопоставление нагрузки,
DOM-рендеры вкладок волны 3).
Выполнено на этапе 5 (волна 4, срез 1 — `auditorium-workload`):
- `react/admin/tabs/AuditoriumWorkloadTab.jsx`: переключатель «Что смотреть»
(аудитории/преподаватели/кафедры) и селект «Отображение» (все/конкретная
сущность), сводная таблица занятости на дату (ось — сущности, колонки —
эффективные слоты, карточки занятий, «Свободно»), фильтры аудиторий
(корпус/мин. вместимость/оборудование — React-мультиселекты, применяются
к уже загруженным данным без перезапроса), двухнедельная сводка по
выбранной сущности (чёт/нечёт недели в ячейках одного дня, сводка
«Занято: N из M», предупреждения об ошибках загрузки и датах вне
семестра); сводный текст «Дата: …, Занятых слотов: …» воспроизводит
legacy `renderSummary` (включая «выбран один объект» и счётчик ошибок
групп); при сбое части групп/сеток показываются доступные данные с
предупреждением (legacy `allSettled`-стратегия);
- `react/admin/auditorium-workload-service.js`: контракты
`GET /api/classrooms`, `/api/equipments`, `/api/groups`,
`/api/admin/calendar/years` (сбой молча даёт `[]`), `/api/users/teachers`,
`/api/departments`, `/api/admin/time-slots/effective?date=`,
`/api/schedule?groupId=&startDate=&endDate=`; чистая логика: дедупликация
занятий (`uniqueLessons` с мерджем групп/подгрупп), группировка по
сущность×слот и дата×слот, сопоставление преподаватель→кафедра
(`departmentId`, fallback по названию), двухнедельный период с
понедельника, номера учебных недель и чётность (паритет занятия
перекрывает расчёт по семестру, вне семестра — fallback по индексу
даты), строки слотов сводной недели, состояния ячеек
(busy/free/missing/empty) и сравнение наборов занятий подписью;
- legacy-файлы вкладки удалены (`admin/js/views/auditorium-workload.js`,
`admin/views/auditorium-workload.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стили legacy HTML (отступы заголовка/фильтров, ширина поля
вместимости, дата) заменены классами `workload-card-header`,
`workload-filter-row` (дополнен), `workload-min-capacity`,
`workload-date-field` в `admin/css/auditorium-workload.css`;
CSP-хэши `style-src-attr` пересчитаны (5 → 1: в корпусе остался один
inline-стиль в `schedule-view.js`);
- бюджеты: entrypoint админ-панели 4 372 байта gzip (лимит 20 000),
холодная загрузка 83 266 байт gzip (лимит 90 000), общие chunk
78 894 байта gzip (лимит 85 000), lazy-вкладки 122 252 байта gzip
в 27 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 212 тестов пройдено (7 новых: период и чётность недель,
дедупликация/группировка занятий, фильтры и опции, контракты API,
сводки и счётчики, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5 — контур публикации
(`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences`)
по той же схеме: выделить чистую логику, переиспользовать `react/shared`,
заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить
legacy-файл вкладки. Последними (волна 6) — `schedule` +
`schedule-override-panel`.
Выполнено на этапе 5 (волна 4, срез 2 — `academic-calendar`):
- `react/admin/tabs/AcademicCalendarTab.jsx`: три внутренних раздела
(Графики/Сетки/Дисциплины) с подтверждением ухода при несохранённых
изменениях; раздел «Графики»: таблица учебных годов (редактирование года и
отдельного семестра, удаление через `ConfirmDialog`), форма календарного
графика (год, специальность → профиль, форма обучения, число курсов),
реестр графиков (поиск с debounce 300 мс, фильтр по году, пагинация,
кнопки «Изменить»/«Сетка»/«Дисциплины»/«Удалить»), конструктор учебного
года (ввод года начала автоматически считает год окончания и даты обоих
семестров, 4 поля дат с маской); раздел «Сетки»: выбор графика через
`AsyncCombobox`, загрузка/сохранение сетки, заполнение диапазона
(курс/даты/код активности), чипы итогов с маркером выделения, блоки
курсов с таблицами семестров, диалог ячейки (одно/множественное
выделение, код активности на всё выделение, информационная временная
сетка, «Убрать ручную сетку»), drag-выделение ячеек и подсказка через
портал в `document.body` с позиционированием `el.animate` (реплика
legacy, без inline-стилей); раздел «Дисциплины»: выбор графика,
семестр/дисциплина, привязки по семестрам чипами с удалением,
добавление/сохранение с dirty-индикатором «•»;
- `react/admin/academic-calendar-service.js`: контракты
`GET /api/admin/academic-calendars/page|options`, `GET|PUT
/api/admin/academic-calendars/{id}/grid|subjects`, `POST|PUT|DELETE
/api/admin/calendar/years`, `DELETE /api/admin/academic-calendars/{id}`,
`/api/specialties/{id}/profiles`, `/api/admin/time-slots/*`; чистая
логика: расчёт семестров учебного года и валидация пересечений,
построение сетки по датам учебного года, блоки семестров, итоги по кодам
активностей, тексты диалога/подсказки, расчёт позиции подсказки;
заголовки графиков — общий `admin/js/views/academic-calendar-title.js`
(как в `groups-service`);
- `MaskedDateInput` внутри вкладки — React-реплика `bindDateInputMask`
из `admin/js/date-input.js` (форматирование при вводе, нормализация и
`aria-invalid` при потере фокуса), переиспользует чистые функции
`formatDateInputValue`/`dateInputValueToIso`/`isoDateToInputValue`;
- мёртвый код legacy не переносился: «Применить» в диалоге ячейки только
закрывает окно, выбор временной сетки информационный (задокументировано
в FRONTEND.md), «Убрать ручную сетку» реально удаляет назначение;
- legacy-файлы вкладки удалены (`admin/js/views/academic-calendar.js`,
`admin/views/academic-calendar.html`; общие хелперы
`academic-calendar-title.js`/`academic-calendar-grid.js` сохранены —
используются React-сервисом), загрузчик убран из `legacy-views.js`,
у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стилей в legacy HTML вкладки не было — CSP-хэши `style-src-attr`
не изменились (1 хэш, inline-стиль в `schedule-view.js`);
- бюджеты: entrypoint админ-панели 4 375 байт gzip (лимит 20 000),
холодная загрузка 83 848 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 124 206 байт gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 217 тестов пройдено (5 новых: контракты API и эндпоинты,
ввод года и чтение дат, подписи и сортировки, построение сетки и тексты
диалога/подсказки, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5 — контур публикации
(`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences`)
по той же схеме: выделить чистую логику, переиспользовать `react/shared`,
заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить
legacy-файл вкладки. Последними (волна 6) — `schedule` +
`schedule-override-panel`.
Выполнено на этапе 5 (волна 5, срез 1 — `schedule-view`):
- `react/admin/tabs/ScheduleViewTab.jsx`: карточка параметров (цель просмотра —
для `DEPARTMENT` заблокирована на «Кафедра», `AsyncCombobox` группы/
преподавателя/аудитории, обычный select кафедры, скрытое поле даты,
дополнительные фильтры с каскадом «учебный год → семестр», дисциплина, тип
занятия, чётность), карточка результатов (счётчик, чипы разделов, кнопки
предыдущего/следующего расписания, кнопка реестра правок) и одна активная
таблица (семестровая матрица, совмещённая двухнедельная таблица выбранной
сущности, мобильный блок одного дня с переключателем дней); состояние таблиц
выводится производно из `sections/activeSectionIndex/range/isMobile`,
пустое состояние с подсказкой содержит собственную кнопку «Показать
расписание»;
- `schedule-override-panel.js` (634 строки) сохранён как общий хелпер волны 6:
React-вкладка рендерит drawer как статичный JSX (все `schedule-override-*`
id сохранены) и вызывает `createScheduleOverridePanel` в эффекте после
загрузки справочников; колбэки панели (`loadData`, `getRange`) читают только
`latestRef`/`rangeRef`, поэтому захваченные панелью замыкания не протухают;
- `react/admin/schedule-view-service.js`: контракты `GET /api/departments`,
`/api/subjects`, `/api/lesson-types`, `/api/schedule/semesters`,
`/api/schedule/search`, `/api/edu-office/schedule/overrides`,
`/api/groups/options`, `/api/users/teachers/options`,
`/api/classrooms/options`; чистая логика: разделы по цели просмотра
(выбранная группа собирает все занятия в один раздел — фильтрует сервер),
семестровая матрица с чётностью и границами недель, совмещённые двухнедельные
блоки, подписи недель из академических часов, URL-состояние фильтров
(кафедра не сохраняет цель — она зафиксирована ролью);
- прелоад трёх комбобоксов на монтировании воспроизводит legacy
`combo.preload('')`: справочники сущностей наполняются заранее, подпись
восстановленного из URL значения обновляется после загрузки опций;
- legacy-файлы вкладки удалены (`admin/js/views/schedule-view.js`,
`admin/views/schedule-view.html`), загрузчик убран из `legacy-views.js`,
у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стиль legacy (flex в заголовке карточки занятия) оказался избыточным —
правило уже есть в классе `.schedule-view-lesson-title`
(`auditorium-workload.css`); CSP-хэши `style-src-attr` стали пустыми
(1 → 0), директива `unsafe-hashes` сохранена;
- мёртвый код legacy не переносился: кнопок периода `prev-period`/`today`/
`next-period` в разметке не было (в legacy они были null);
- бюджеты: entrypoint админ-панели 4 383 байта gzip (лимит 20 000),
холодная загрузка 83 856 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 126 170 байт gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 224 теста пройдено (7 новых: контракты API просмотра,
разделы по цели, двухнедельная сетка и матрица семестра, подписи недель,
URL-состояние, пополнение справочников, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5, срезы 2–4 —
`schedule-versions`, `schedule-quality`, `teacher-absences` по той же схеме:
выделить чистую логику, переиспользовать `react/shared`, заменить загрузчик
в `legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки.
Последними (волна 6) — `schedule` + `schedule-override-panel`.
Выполнено на этапе 5 (волна 5, срез 2 — `schedule-versions`):
- `react/admin/tabs/ScheduleVersionsTab.jsx`: hero с каскадом «учебный год →
семестр» (восстановление из `localStorage`-ключей `magistr.schedule.*`),
статусная строка с `data-tone` (`loading`/`success`/`error`), карточка
опубликованной версии (LIVE/ПУСТО), панель черновиков со счётчиком, панель
сравнения (Δ / загрузка / ошибка / сводка «Что изменится после публикации»
с карточками правил), архив публикаций, журнал действий и модальное окно
операций (создание черновика, публикация со сводкой валидации, восстановление)
с закрытием по Escape/фону и блокировкой отправки при непройденной проверке;
- переходы «Редактировать»/«Качество» передают контекст через `localStorage`
(`magistr.schedule.openVersionId`, `magistr.quality.*`) и переключают вкладку
через `location.hash` — hash-router `AdminApp` проводит переход через
guard несохранённых изменений, как клик по пункту меню в legacy;
- `react/admin/schedule-versions-service.js`: контракты `GET
/api/schedule/semesters`, `/api/edu-office/schedule/versions?semesterId=`,
`.../history?semesterId=`, `.../{id}/diff`, `.../{id}/validate`, `POST
/api/edu-office/schedule/versions` (черновик), `POST .../{id}/publish|restore`
(причина); чистая логика: группировка версий (published/drafts/archive),
подписи действий журнала и типов изменений, основа черновика, сводка
валидации перед публикацией, формат даты-времени; localStorage-ключи
экспортированы константами для волны 6;
- до первой загрузки панели пусты, как в legacy HTML (пустые блоки «Нет
активных черновиков» и т. п. появляются только после успешной загрузки);
мёртвый CSS `version-modal-summary[data-tone]` не поддержан (в legacy
атрибут никогда не ставился);
- legacy-файлы вкладки удалены (`admin/js/views/schedule-versions.js`,
`admin/views/schedule-versions.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
- inline-стилей в legacy HTML вкладки не было — CSP-хэши `style-src-attr`
остались пустыми;
- бюджеты: entrypoint админ-панели 4 378 байт gzip (лимит 20 000),
холодная загрузка 83 851 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 127 193 байта gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 227 тестов пройдено (3 новых: контракты API контура
публикаций, группировка версий и подписи, DOM-рендер вкладки).
**Следующее действие при продолжении:** этап 5, волна 5, срезы 3–4 —
`schedule-quality`, `teacher-absences` по той же схеме: выделить чистую
логику, переиспользовать `react/shared`, заменить загрузчик в
`legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки.
Последними (волна 6) — `schedule` + `schedule-override-panel`.
Выполнено на этапе 5 (волна 5, срез 3 — `schedule-quality`):
- `react/admin/tabs/ScheduleQualityTab.jsx`: каскад «учебный год → семестр →
версия» (восстановление из `localStorage`-ключей `magistr.quality.*`,
версия по умолчанию — сохранённая, иначе опубликованная, иначе первая),
статусная строка с `data-tone` (включая `warning`), кнопка анализа с
состоянием «Анализируем…», скорборд с SVG-кольцом (`stroke-dasharray`
по клампу счёта, `data-band`), метрики, карта проблем с фильтрами
важности/категории и сортировкой по штрафу, панель выбранного правила
с переходом в конструктор;
- блок результатов скрывается на время анализа и остаётся скрытым при
ошибке (как `hidden` в legacy), пустые блоки фильтра — только после
успешного анализа;
- переход «Открыть правило в конструкторе» передаёт контекст через
`localStorage` (`magistr.schedule.semesterId`, `openVersionId`,
`openRuleId`) и переключает вкладку через `location.hash`;
- `react/admin/schedule-quality-service.js`: контракт
`GET /api/edu-office/schedule/quality?semesterId=&versionId=`; чистая
логика: диапазоны оценки, фильтрация и сортировка проблем по категориям
(окна/нагрузка/аудитории/пожелания), подписи статусов версий и
важности, формат метрик и дат, склонение «занятие/занятия/занятий»,
сводка «Показаны первые N из M правил»; семестры и версии переиспользуют
контракты `schedule-versions-service.js`;
- legacy-файлы вкладки удалены (`admin/js/views/schedule-quality.js`,
`admin/views/schedule-quality.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
- бюджеты: entrypoint админ-панели 4 357 байт gzip (лимит 20 000),
холодная загрузка 83 830 байт gzip (лимит 90 000), общие chunk
79 473 байта gzip (лимит 85 000), lazy-вкладки 130 276 байт gzip
в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты
не поднимались;
- `npm run check`: 233 теста пройдено (3 новых: контракт API анализа,
подписи и диапазоны, DOM-рендер вкладки).
Выполнено на этапе 5 (волна 5, срез 4 — `teacher-absences`):
- `react/admin/tabs/TeacherAbsencesTab.jsx`: сводка статистики, форма
регистрации отсутствия (React-`AsyncCombobox` поиска преподавателя —
для роли DEPARTMENT подставляет `departmentId` из сессии, даты по
умолчанию сегодня), реестр инцидентов с фильтром статуса и действиями
(подтвердить/отклонить/отменить/мастер), мастер замены (overlay с
блокировкой прокрутки `absence-wizard-open` на body, решения по
каждому занятию, журнал решений, «Применить выбранные» с проверкой
«Выберите хотя бы одно решение»), согласование пожеланий на семестр
(фильтры семестра/статуса, счётчик ожидающих, комментарий обязателен
при отклонении) и заявки на изменение (применение доступно только
ADMIN/EDUCATION_OFFICE, история решения);
- модальные сообщения об ошибках действий воспроизведены локальным
диалогом с классами `project-dialog` (разметка и поведение `alertAction`
из `dialog.js`: закрытие по Escape/фону, кнопка «Понятно»);
- до инициализации списки согласования пусты, реестр показывает
«Загрузка...» — как в legacy HTML; мастер замены рендерится только
при открытии;
- `react/admin/teacher-absences-service.js`: контракты
`GET/POST/DELETE /api/teacher-absences` (+ `/{id}/review`, `/{id}/wizard`,
`/{id}/resolve`), `GET /api/teacher-preferences/meta`, `GET/POST
/api/teacher-preferences`, `GET/POST /api/teacher-change-requests`,
`GET /api/users/teachers/options`; чистая логика: варианты решений
мастера и разбор `REPLACE_TEACHER|id` / `MOVE_TIME|date|slot` /
`CHANGE_CLASSROOM|id`, подписи статусов и решений, склонение
«решение/решения/решений», сводка статистики, цель заявки на изменение;
- legacy-файлы вкладки удалены (`admin/js/views/teacher-absences.js`,
`admin/views/teacher-absences.html`), загрузчик убран из
`legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`;
в `legacy-views.js` остался единственный загрузчик — `schedule` (волна 6);
- бюджеты: lazy-вкладки 130 276 байт gzip в 26 chunk'ах (лимит 150 000),
остальные без изменений — лимиты не поднимались;
- `npm run check`: 233 теста пройдено (3 новых: контракты API отсутствий,
подписи и сводки, DOM-рендер вкладки).
Выполнено на этапе 5 (волна 6, финальная — `schedule` + `schedule-override-panel`):
- `react/admin/tabs/ScheduleTab.jsx`: конструктор правил — каскад
учебный год → семестр → версия (контекст-бар с eyebrow/заголовком/заметкой,
выбор версии по localStorage-ключам `magistr.schedule.openVersionId`/
`openRuleId` из `schedule-versions-service.js`, модалка создания черновика
с копией правил), форма правила (дисциплина, семестр синхронизирован с
версией, часы/недели начала по типам занятий, мультиселект групп с поиском
и кэшем выбранных, слоты с подгруппами только для лабораторных, подсказки
согласованных пожеланий преподавателя), реестр правил с поиском, пагинацией
и удалением, модалка конфликта (причины, редактируемые слоты конфликтующего
правила, повтор с сохранением payload), визуальная матрица (drawer с
фильтром года/семестра/групп, ячейки по чётности, контекстное меню пары —
позиционирование через Web Animations API, действия «открыть правило»/
«изменить день и пару»/«удалить»), dirty-guard формы через общий
`dirty-state.js`;
- `react/admin/schedule-service.js`: чистая логика и контракты API —
слоты правил и payload-валидация (часы по типам, подгруппы только
лабораторных и по одной на группу), payload конфликтующего правила,
подписи правил/черновиков, группировка опций версий, визуальная матрица
(ячейки по чётности, активные недели, дедупликация занятий), позиционирование
контекстного меню, `GET /api/admin/schedule-rules/page|/{id}`,
`POST|PUT|DELETE /api/admin/schedule-rules`, `GET /api/groups/options`,
`GET /api/groups/subgroups`, `GET /api/teacher-preferences?status=APPROVED`;
- боковая панель разовых правок `schedule-override-panel.js` абсорбирована
в `ScheduleViewTab.jsx`: drawer переписан на React-состояние
(`forwardRef` + `useImperativeHandle`: `openLesson`/`openRegistry`),
реестром правок за период владеет вкладка (кнопка «Изменения за период»
со счётчиком), вкладка «Редактирование» — сравнение «было/станет»,
недельный выбор дат, календарь семестра, время по эффективным слотам,
преподаватель/аудитория через React-`AsyncCombobox` с пополнением
справочников, отмена занятия и возврат по правилу; чистая логика —
`react/admin/schedule-override-service.js` (контракты
`GET /api/edu-office/schedule/overrides/availability`,
`GET /api/admin/time-slots/effective`, `POST|PUT|DELETE
/api/edu-office/schedule/overrides`);
- переходный слой удалён: `react/admin/LegacyTabView.jsx`,
`react/admin/legacy-views.js`, `admin/views/schedule.html`,
`admin/js/views/schedule.js`, `admin/js/views/schedule-override-panel.js`,
а также оставшиеся без пользователей `admin/js/async-combobox.js`
и `admin/js/pagination.js` (React-аналоги — `react/shared/ui/AsyncCombobox.jsx`
и `Pagination.jsx`); все 15 вкладок админ-панели — React, HTML-фрагменты
больше не загружаются через `fetch`;
- бюджеты: entrypoint админ-панели 5 640 байт gzip (лимит 20 000), холодная
загрузка 84 857 байт gzip (лимит 90 000), общие chunk 79 217 байт gzip
(лимит 85 000), lazy-вкладки 128 571 байт gzip в 25 chunk'ах (лимит 150 000,
загружаются по одной) — лимиты не поднимались;
- `npm run check`: 237 тестов пройдено (обновлены импорты тестов с legacy-модулей
на React-сервисы, добавлены payload-валидация, DOM-рендер конструктора
и контроль разрешения lazy-вкладок; проверки `LegacyTabView`/`legacy-views`
заменены на контроль отсутствия legacy-модулей).
Починено при первом браузерном smoke-тесте после волны 6: реестр `react-tabs.js`
использовал голый `import()` внутри `React.lazy`, а компоненты вкладок экспортируются
именованными экспортами — без `default` React падал при открытии любой вкладки
(React error #306, «Интерфейс временно недоступен» после авторизации). Исправлено
явным отображением `.then(m => ({ default: m.XTab }))`; добавлен регрессионный тест,
проверяющий разрешение всех 15 lazy-вкладок.
Этап 5 закрыт: основная админ-панель полностью работает на React.
Команды для возобновления:
```bash
cd frontend
npm ci
npm run build
npm run check
```

View File

@@ -0,0 +1,101 @@
# Итоги миграции frontend на React
Дата завершения: 28.09.2026. Полный журнал процесса — в [`REACT_MIGRATION.md`](REACT_MIGRATION.md),
архитектура итогового решения — в [`FRONTEND.md`](FRONTEND.md).
---
## Что сделано
По модели strangler (этапы 1–5, все изменения не закоммичены — коммит по решению владельца):
| Этап | Содержимое | Статус |
|------|-----------|--------|
| 1. Точка входа и вход в систему | `index.html`, страница логина | Завершён |
| 2. Интерфейс студента | SPA `student/` | Завершён |
| 3. Интерфейс преподавателя | SPA `teacher/` (пожелания, заявки) | Завершён |
| 4. Страница настроек | Отдельный SPA `admin/settings/` (4 вкладки) | Завершён |
| 5. Основная админ-панель | Shell + hash-router + **15 вкладок** в 6 волнах | Завершён |
Волны этапа 5: `dashboard`, `classrooms`, `subjects` → `users`, `groups`, `university-structure` →
`teacher-requests`, `department-workspace` → `auditorium-workload`, `academic-calendar` →
`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences` → `schedule`
(конструктор правил + абсорбция панели разовых правок).
**Удалено 45 legacy-файлов** (~19 000 строк vanilla-кода): все `admin/js/views/*.js`
и `admin/views/*.html`, `admin/js/main.js`, переходный слой `LegacyTabView.jsx` + `legacy-views.js`,
дублирующие утилиты `async-combobox.js` и `pagination.js` (заменены общими React-компонентами
`AsyncCombobox.jsx` и `Pagination.jsx`).
**Добавлено 75 React-файлов** (~23 200 строк): оболочки четырёх SPA, 15 вкладок админки,
19 сервисов с чистой логикой, общая дизайн-система (`react/shared/ui/`), render-хелперы для тестов.
---
## Насколько стало лучше
### Скорость загрузки и переключения вкладок
| Метрика (gzip) | До (legacy) | После (React) |
|----------------|-------------|---------------|
| Переключение вкладки админки | 2 запроса: `fetch` HTML-фрагмента + lazy JS | **1 запрос**: один lazy-chunk |
| Средний размер вкладки | HTML + JS отдельно | ~5,1 КБ (128 571 байт / 25 chunk'ов) |
| Общие chunk'и (кэшируются между страницами) | — | 79 217 байт (лимит 85 000) |
| Холодная загрузка админки | — | 84 857 байт (лимит 90 000) |
- HTML-фрагменты больше **не загружаются через `fetch`** — вкладка рендерится сразу из
одного кэшируемого JS-chunk'а, исчезли двойные сетевые задержки и парсинг HTML на каждое
переключение.
- Runtime, дизайн-система и утилиты вынесены в общие chunk'и: повторные визиты и переходы
между страницами (логин → студент → админка) переиспользуют кэш браузера.
- Каждая вкладка загружается отдельно (`React.lazy` + `Suspense`) — пользователь качает
только то, что открывает.
### Качество и надёжность
- **237 тестов** проходят (`npm run check`), против ~170 на момент начала этапа 5:
контракты API, payload-валидация, DOM-рендер каждой вкладки, бюджеты bundle, CSP,
разрешение всех lazy-вкладок (регрессия на React error #306 поймана и закрыта тестом).
- Логика вынесена в **чистые сервисы** (`*-service.js`) — тестируются в Node без браузера
и DOM-заглушек.
- Единая дизайн-система вместо копипасты виджетов: кастомные селекты, `AsyncCombobox`,
`ConfirmDialog`, `Pagination`, `MaskedDateInput` — одинаковое поведение и a11y везде.
- Устранён класс ошибок ручного DOM: React-state вместо `innerHTML`/`classList`,
модалки — условный рендер вместо `hidden`-флагов, позиционирование меню — Web Animations API.
### Безопасность (не ослаблена)
- CSP-хэши: `[]` — ни одного элемента вне политики.
- JWT по-прежнему только в памяти, без inline-скриптов и inline-стилей
(проверяется тестами `security-policy.test.mjs`).
- Сообщения об ошибках не вставляют текст исключений через `innerHTML`.
### Сопровождаемость
- Один стек вместо двух: не нужно поддерживать параллельно vanilla-модули и React.
- −19 000 строк legacy-кода, +4 965 строк тестов.
- URL/REST-контракты и русский UI сохранены — для пользователей миграция незаметна.
---
## Бюджеты bundle (итог, лимиты не поднимались с волны 1)
| Entry point | Размер gzip | Холодная загрузка | Лимиты |
|-------------|-------------|-------------------|--------|
| Логин | 2 493 | 81 710 | 10 000 / 85 000 |
| Студент | 3 431 | 82 648 | 20 000 / 90 000 |
| Преподаватель | 10 145 | 89 362 | 20 000 / 90 000 |
| Настройки | 8 466 | 87 683 | 20 000 / 90 000 |
| Админ-панель | 5 640 | 84 857 | 20 000 / 90 000 |
| Lazy-вкладки админки | 128 571 (25 chunk'ов, по одной за раз) | — | 150 000 |
---
## Проверка
```bash
cd frontend
npm ci
npm run build
npm run check # 237/237 тестов, бюджеты, CSP
```

View File

@@ -14,7 +14,7 @@
| Компонент | Технология |
|-----------|-----------|
| **Backend** | Java 17, Spring Boot 3.5.16 |
| **Frontend** | Vanilla JavaScript (ES6 Modules) + HTML/CSS |
| **Frontend** | React 19 + переходные Vanilla JavaScript ES-модули + HTML/CSS |
| **База данных** | PostgreSQL (через Flyway миграции) |
| **Контейнеризация** | Docker, Docker Compose |
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |
@@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик
| `admin` | `admin` | Администратор |
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
Эти и остальные прежние демонстрационные записи загружаются миграцией
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
### Полезные команды
```bash

View File

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

View File

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

View File

@@ -3,9 +3,12 @@ FROM node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY auth-session.js telemetry.js schedule-overview.js ./
COPY admin/js/ ./admin/js/
COPY react/ ./react/
COPY telemetry/ ./telemetry/
COPY scripts/build-vendor.mjs ./scripts/build-vendor.mjs
RUN npm run build:vendor
COPY scripts/build-react.mjs scripts/build-vendor.mjs ./scripts/
RUN npm run build
FROM httpd:alpine3.23@sha256:4a15e9c73f25334bc03cfb3c692c9adfc103bb46ca89cee1f0b9a5fcbc7b21f6
@@ -17,14 +20,17 @@ COPY department/ /usr/local/apache2/htdocs/department/
COPY edu-office/ /usr/local/apache2/htdocs/edu-office/
COPY student/ /usr/local/apache2/htdocs/student/
COPY teacher/ /usr/local/apache2/htdocs/teacher/
COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
COPY index.html style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
COPY ui-foundation.css schedule-overview.css schedule-overview.js /usr/local/apache2/htdocs/
COPY --from=frontend-assets /build/dist/react/ /usr/local/apache2/htdocs/react/
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY react-cache.conf /usr/local/apache2/conf/extra/magistr-react-cache.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
# Сервер раздаёт same-origin ресурсы и проксирует API во внутреннюю сеть Compose.
RUN sed -i 's/^#LoadModule headers_module/LoadModule headers_module/' /usr/local/apache2/conf/httpd.conf \
&& sed -i 's/^#LoadModule proxy_module/LoadModule proxy_module/' /usr/local/apache2/conf/httpd.conf \
&& sed -i 's/^#LoadModule proxy_http_module/LoadModule proxy_http_module/' /usr/local/apache2/conf/httpd.conf \
&& printf '\nInclude conf/extra/magistr-security.conf\nInclude conf/extra/magistr-proxy.conf\n' >> /usr/local/apache2/conf/httpd.conf \
&& printf '\nInclude conf/extra/magistr-security.conf\nInclude conf/extra/magistr-react-cache.conf\nInclude conf/extra/magistr-proxy.conf\n' >> /usr/local/apache2/conf/httpd.conf \
&& chown -R www-data:www-data /usr/local/apache2/htdocs/

View File

@@ -11,6 +11,22 @@
.workload-filter-row {
flex-wrap: wrap;
align-items: flex-end;
gap: 1.5rem;
margin-bottom: 2rem;
}
/* Заголовок карточки загруженности (вместо inline-стиля legacy-вьюхи) */
.workload-card-header {
margin-bottom: 1.5rem;
}
.workload-min-capacity {
width: 100px;
}
.workload-date-field {
max-width: 200px;
}
.workload-display-control {
@@ -630,7 +646,7 @@
}
.schedule-view-extra-grid {
grid-template-columns: repeat(4, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
margin-top: 0.9rem;
}

View File

@@ -219,6 +219,13 @@
align-content: flex-start;
gap: 0.5rem;
margin-top: 0.25rem;
height: 180px;
min-height: 180px;
max-height: 180px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-right: 0.25rem;
}
.dashboard-actions {
@@ -689,6 +696,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 +762,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 +1090,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 +1401,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 +2370,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 +2547,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 +3996,441 @@ 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; }
/* ===== Êëàññû ýòàïà 5 (âîëíà 1): çàìåíà inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
/* Çàãîëîâîê êàðòî÷êè ñ äåéñòâèåì ñïðàâà (àóäèòîðèè) */
.card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.card-toolbar h2 {
margin: 0;
}
/* Ñòðîêè è ãðóïïû ôîðì */
.form-row--spacing {
margin-top: 1rem;
}
.form-row--bottom {
align-items: flex-end;
}
.form-group--wide {
flex: 2;
}
.form-group--flush {
margin-bottom: 0;
}
.form-group--submit {
display: flex;
align-items: flex-end;
}
.btn-block {
width: 100%;
}
.btn-inline {
height: fit-content;
margin-bottom: 0;
}
/* ß÷åéêè òàáëèö */
.th-actions {
text-align: right;
}
.table-cell-actions {
text-align: right;
white-space: nowrap;
}
.badge-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.badge--tight {
margin: 0.1rem;
}
.badge--lesson-type {
font-size: 0.75rem;
}
/* Ìîäàëüíûå îêíà ïåðåíåñ¸ííûõ âêëàäîê */
.modal-content--medium {
max-width: 600px;
width: 90%;
}
.modal-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.modal-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.modal-subcard {
margin-bottom: 1.5rem;
background: var(--bg-secondary);
}
.modal-subcard h3 {
margin-top: 0;
}
/* Ñîñòîÿíèå çàãðóçêè lazy-âêëàäêè */
.tab-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-secondary);
}
/* Äàøáîðä: ìåòðèêè è ìîíèòîðèíã */
.dashboard-metric-label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.dashboard-metric-value {
margin: 0.25rem 0 0;
font-size: 1.5rem;
font-weight: 700;
}
.dashboard-monitor-table table {
width: 100%;
border-collapse: collapse;
}
.dashboard-monitor-table th,
.dashboard-monitor-table td {
text-align: left;
padding: 0.75rem;
}
.dashboard-state-row {
padding: 1.5rem;
text-align: center;
color: var(--text-secondary);
}
.dashboard-state-row--wide {
padding: 2rem;
}
.dashboard-free-hint {
color: var(--text-secondary);
font-size: 0.9rem;
}
.dashboard-free-busy {
color: var(--error);
font-size: 0.9rem;
font-weight: 500;
}
.dashboard-free-badge {
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.2);
}
/* Äàøáîðä: öåíòð ïðåäóïðåæäåíèé (Red Zone) */
.red-zone-card {
border-top: 4px solid var(--error, #ef4444);
}
.red-zone-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.red-zone-title {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.red-zone-subtitle {
color: var(--text-secondary);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.red-zone-conflicts {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.dashboard-check-loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
/* ===== Ýòàï 5 (âîëíà 2): êëàññû inline-ñòèëåé ïåðåíåñ¸ííûõ âêëàäîê ===== */
/* Øèðîêèå ìîäàëüíûå îêíà (ïîäãðóïïû, ïðîôèëè, êàëåíäàðíûå ãðàôèêè) */
.modal-content--wide {
max-width: 700px;
width: 90%;
}
.modal-content--xwide {
max-width: 800px;
width: 90%;
}
.modal-content--full {
max-width: 960px;
width: 90%;
}
/* Ôîðìà ïðîôèëÿ íà âêëàäêå ñòðóêòóðû âóçà */
.profile-form-spec {
flex: 1;
min-width: 200px;
}
.profile-form-name {
flex: 1.5;
min-width: 250px;
}
.profile-form-description {
flex: 2;
min-width: 250px;
}
/* Ðÿä êíîïîê ôîðìû, âûðîâíåííûõ ïî íèæíåé êðîìêå */
.form-actions-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
/* Âîëíà 3 (ýòàï 5): çàìåíà inline-ñòèëÿ èç legacy department-workspace.html */
.schedule-section-label--flush {
margin: 0;
}

View File

@@ -265,6 +265,20 @@
margin: 0.25rem 0.5rem;
}
/* ===== Корневой контейнер ===== */
/* body — flex-контейнер, поэтому без явного растяжения корневой
div сжимается до max-content контента: страницы с узким контентом
(карточки, формы) не занимают всю ширину монитора, а дашборд
«растягивался» только после загрузки данных. Растягиваем корень
ровно на ширину окна (flex-basis 0), а не под контент: intrinsic-замер
max-content раздувает вложенные flex-фильтры (например, панель
загруженности аудиторий) до ~1 млн px горизонтального скролла. */
#admin-root {
flex: 1 1 0%;
min-width: 0;
}
/* ===== Main ===== */
.main {
flex: 1;

View File

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

View File

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

View File

@@ -30,7 +30,7 @@
right: -1.5rem;
bottom: 1.2rem;
color: rgba(255, 255, 255, .055);
font-family: Georgia, serif;
font-family: var(--ui-font-display);
font-size: clamp(4rem, 10vw, 9rem);
letter-spacing: -.08em;
pointer-events: none;
@@ -53,7 +53,7 @@
.version-hero h2 {
max-width: 760px;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: clamp(2rem, 4.5vw, 4.5rem);
font-weight: 500;
letter-spacing: -.05em;
@@ -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;
@@ -128,11 +136,15 @@
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border));
}
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.55rem; }
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
.version-constructor-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;
@@ -207,7 +219,7 @@
margin-bottom: 1rem;
}
.version-section-head > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 2rem; }
.version-section-head > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 2rem; }
.version-draft-list { display: grid; gap: .8rem; }
.version-card {
@@ -225,7 +237,7 @@
right: .5rem;
top: -.8rem;
color: color-mix(in srgb, var(--version-copper) 12%, transparent);
font-family: Georgia, serif;
font-family: var(--ui-font-display);
font-size: 5rem;
}
@@ -240,7 +252,7 @@
place-content: center;
text-align: center;
}
.version-empty > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 4rem; }
.version-empty > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 4rem; }
.version-empty p { max-width: 360px; }
.version-diff-score {
@@ -250,7 +262,7 @@
margin: 1rem 0;
}
.version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; }
.version-diff-score strong { display: block; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.75rem; }
.version-diff-score strong { display: block; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.75rem; }
.version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; }
.version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; }
@@ -280,7 +292,7 @@
}
.version-modal[hidden] { display: none; }
.version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 2rem; }
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 2rem; }
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; }
.version-modal-card form { display: grid; gap: .65rem; }
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }
@@ -304,5 +316,7 @@
.version-diff-score { grid-template-columns: 1fr; }
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
.version-constructor-controls { grid-template-columns: 1fr; }
.version-constructor-controls label { grid-column: auto; }
.version-constructor-field-wide { grid-column: auto; }
.version-constructor-actions { grid-column: auto; flex-direction: column; }
.version-hero-period-grid { grid-template-columns: 1fr; }
}

View File

@@ -89,6 +89,15 @@
border-radius: var(--radius-lg);
}
.absence-create-card {
z-index: 20;
overflow: visible;
}
.absence-create-card:focus-within {
z-index: 30;
}
.absence-create-card .card-header-row > div:first-child,
.absence-registry-head > div:first-child {
display: flex;
@@ -178,6 +187,7 @@
}
.absence-registry-title {
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
}
@@ -211,21 +221,31 @@
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 0.65rem;
padding: 0.25rem 0.65rem;
flex: 0 0 auto;
align-self: flex-start;
max-width: 100%;
line-height: 1.4;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.69rem;
font-weight: 800;
letter-spacing: 0.03em;
white-space: nowrap;
white-space: normal;
}
.absence-status.is-pending { color: #f59e0b; }
.absence-status.is-approved { color: #fb7185; }
.absence-status.is-approved { color: #34d399; }
.absence-status.is-resolved { color: #34d399; }
.absence-status.is-rejected,
.absence-status.is-cancelled { color: var(--text-secondary); }
[data-theme="light"] .absence-status.is-pending { color: #92400e; }
[data-theme="light"] .absence-status.is-approved,
[data-theme="light"] .absence-status.is-resolved { color: #047857; }
.absence-status.is-rejected { color: #fb7185; }
[data-theme="light"] .absence-status.is-rejected { color: #be123c; }
.absence-registry-buttons {
justify-content: flex-end;
flex-wrap: wrap;
@@ -472,7 +492,7 @@ body.absence-wizard-open {
.teacher-dialogue-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
@@ -526,13 +546,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 {
@@ -544,7 +615,7 @@ body.absence-wizard-open {
.teacher-dialogue-item {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) minmax(180px, .42fr);
grid-template-columns: 7px minmax(0, 1fr);
gap: .85rem;
padding: .85rem;
border: 1px solid var(--bg-card-border);
@@ -553,6 +624,7 @@ body.absence-wizard-open {
}
.teacher-dialogue-mark {
grid-row: 1 / 3;
border-radius: 99px;
background: #94a3b8;
}
@@ -571,6 +643,11 @@ body.absence-wizard-open {
font-size: .68rem;
}
.teacher-dialogue-copy {
min-width: 0;
overflow-wrap: anywhere;
}
.teacher-dialogue-copy h3 {
margin: .22rem 0;
font-size: .92rem;
@@ -608,17 +685,32 @@ body.absence-wizard-open {
.teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; }
.teacher-dialogue-action {
grid-column: 2;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: stretch;
gap: .55rem;
}
.teacher-dialogue-action > div {
display: flex;
justify-content: flex-end;
justify-content: flex-start;
flex-wrap: wrap;
gap: .35rem;
gap: .5rem;
}
.teacher-dialogue-action > div > .btn {
flex: 1 1 120px;
min-width: 0;
white-space: normal;
line-height: 1.35;
padding-block: 0.4rem;
}
.teacher-dialogue-toolbar > .btn {
align-self: center;
min-height: 44px;
}
.teacher-dialogue-comment {
@@ -669,6 +761,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);
}
@@ -731,6 +833,6 @@ body.absence-wizard-open {
.teacher-dialogue-action {
grid-column: 2;
align-items: flex-start;
align-items: stretch;
}
}

View File

@@ -15,246 +15,13 @@
<link rel="stylesheet" href="css/teacher-absences.css">
<link rel="stylesheet" href="css/schedule-quality.css">
<link rel="stylesheet" href="css/schedule-versions.css">
<link rel="stylesheet" href="../ui-foundation.css">
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
stroke-linejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Magistr</span>
</div>
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<!-- Главная -->
<a href="#" class="nav-item" data-tab="dashboard">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="9" />
<rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg>
<span>Дашборд</span>
</a>
<div id="admin-root"></div>
<a href="#" class="nav-item" data-tab="teacher-requests">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M8 11h8" />
<path d="M8 16h5" />
</svg>
<span>Заявки</span>
<span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
</a>
<a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18" />
<path d="M5 21V7l7-4 7 4v14" />
<path d="M9 21v-6h6v6" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
<span>Кабинет кафедры</span>
</a>
<!-- Раздел: Расписание -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div>
<a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span>Конструктор правил</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-versions">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
<path d="M16 3l4 4-4 4" />
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
<path d="M8 21l-4-4 4-4" />
</svg>
<span>Версии расписания</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-quality">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19V9" />
<path d="M10 19V5" />
<path d="M16 19v-7" />
<path d="M22 19V3" />
<path d="M2 19h20" />
</svg>
<span>Качество расписания</span>
</a>
<a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3z"></path>
<path d="M3 9h18"></path>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
<path d="M3 15h18"></path>
</svg>
<span>Календарный график</span>
</a>
<a href="#" class="nav-item" data-tab="auditorium-workload">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Загруженность</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="teacher-absences">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
<path d="M18 8l3 3" />
<path d="M21 8l-3 3" />
</svg>
<span>Запросы преподавателей</span>
</a>
<!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудиторный фонд</span>
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="university-structure">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
<span>Структура вуза</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a>
<!-- Раздел: Система -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div>
<a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="settings-dropdown" id="settings-dropdown">
<button class="btn-settings" id="btn-settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Настройки</span>
<svg class="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div class="settings-menu" id="settings-menu">
<a href="/admin/settings/" class="settings-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Общие настройки
</a>
<div class="settings-menu-divider"></div>
<button class="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger" id="btn-logout">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Выйти
</button>
</div>
</div>
</div>
</aside>
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Main -->
<main class="main">
<header class="topbar">
<div class="topbar-title-group">
<h1 id="page-title">Загрузка...</h1>
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
</div>
</header>
<!-- Dynamic Content Injected Here -->
<section class="content" id="app-content">
<!-- Content loaded via main.js -->
</section>
</main>
<script src="/theme-toggle.js"></script>
<script type="module" src="js/main.js"></script>
<script type="module" src="/react/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,51 @@
import { preferredScheduleSemester } from './schedule-period.js';
export function academicYearsFromSemesters(semesters = []) {
const years = new Map();
semesters.forEach(semester => {
const id = String(semester?.academicYearId ?? '');
if (!id || years.has(id)) return;
years.set(id, {
id,
title: semester.academicYearTitle || `Учебный год ${id}`
});
});
return [...years.values()];
}
export function semestersForAcademicYear(semesters = [], academicYearId = '') {
return semesters.filter(semester =>
String(semester?.academicYearId ?? '') === String(academicYearId)
);
}
export function resolveAcademicPeriod(semesters = [], options = {}) {
if (!semesters.length) return { academicYearId: '', semesterId: '' };
const requestedSemester = semesters.find(semester =>
String(semester.id) === String(options.semesterId || '')
);
const availableYearIds = new Set(academicYearsFromSemesters(semesters).map(year => year.id));
const requestedYearId = availableYearIds.has(String(options.academicYearId || ''))
? String(options.academicYearId)
: '';
const preferred = preferredScheduleSemester(semesters, options.date || isoToday()) || semesters[0];
const academicYearId = requestedSemester
? String(requestedSemester.academicYearId)
: (requestedYearId || String(preferred?.academicYearId || semesters[0].academicYearId));
const yearSemesters = semestersForAcademicYear(semesters, academicYearId);
const semester = requestedSemester && String(requestedSemester.academicYearId) === academicYearId
? requestedSemester
: (preferredScheduleSemester(yearSemesters, options.date || isoToday()) || yearSemesters[0]);
return {
academicYearId,
semesterId: String(semester?.id || '')
};
}
function isoToday() {
const date = new Date();
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}

View File

@@ -135,7 +135,8 @@ export async function logout() {
export const api = {
get: async (url) => {
const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
const isCachable = !url.includes('/page') && !url.includes('/options')
&& CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
if (isCachable) {
if (!cache.has(url)) {
cache.set(url, await apiFetch(url, 'GET'));

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

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

View File

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

View File

@@ -1,327 +0,0 @@
import { api, initializeSession, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
import { initializeTelemetry } from '../../telemetry.js';
async function bootstrap() {
const session = await initializeSession();
if (!session || !ADMIN_APP_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const currentRole = session.role;
void initializeTelemetry();
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
// Configuration
const ROUTES = {
dashboard: {
title: 'Дашборд',
file: '/admin/views/dashboard.html',
init: async () => {
const module = await import('./views/dashboard.js');
await module.initDashboard();
}
},
users: {
title: 'Управление пользователями',
file: '/admin/views/users.html',
init: async () => {
const module = await import('./views/users.js');
await module.initUsers();
}
},
'teacher-requests': {
title: 'Заявки',
file: '/admin/views/teacher-requests.html',
init: async () => {
const module = await import('./views/teacher-requests.js');
await module.initTeacherRequests();
}
},
groups: {
title: 'Управление группами',
file: '/admin/views/groups.html',
init: async () => {
const module = await import('./views/groups.js');
await module.initGroups();
}
},
classrooms: {
title: 'Аудиторный фонд',
file: '/admin/views/classrooms.html',
init: async () => {
const module = await import('./views/classrooms.js');
await module.initClassrooms();
}
},
subjects: {
title: 'Дисциплины',
file: '/admin/views/subjects.html',
init: async () => {
const module = await import('./views/subjects.js');
await module.initSubjects();
}
},
'university-structure': {
title: 'Структура вуза',
file: '/admin/views/university-structure.html',
init: async () => {
const module = await import('./views/university-structure.js');
await module.initUniversityStructure();
}
},
'department-workspace': {
title: 'Кабинет кафедры',
file: '/admin/views/department-workspace.html',
init: async () => {
const module = await import('./views/department-workspace.js');
await module.initDepartmentWorkspace();
}
},
'schedule-view': {
title: 'Просмотр расписаний',
file: '/admin/views/schedule-view.html',
init: async () => {
const module = await import('./views/schedule-view.js');
await module.initScheduleView();
}
},
schedule: {
title: 'Конструктор правил',
file: '/admin/views/schedule.html',
init: async () => {
const module = await import('./views/schedule.js');
await module.initSchedule();
}
},
'schedule-versions': {
title: 'Версии расписания',
file: '/admin/views/schedule-versions.html',
init: async () => {
const module = await import('./views/schedule-versions.js');
await module.initScheduleVersions();
}
},
'schedule-quality': {
title: 'Анализ качества расписания',
file: '/admin/views/schedule-quality.html',
init: async () => {
const module = await import('./views/schedule-quality.js');
await module.initScheduleQuality();
}
},
'academic-calendar': {
title: 'Календарный учебный график',
file: '/admin/views/academic-calendar.html',
init: async () => {
const module = await import('./views/academic-calendar.js');
await module.initAcademicCalendar();
}
},
'auditorium-workload': {
title: 'Загруженность',
file: '/admin/views/auditorium-workload.html',
init: async () => {
const module = await import('./views/auditorium-workload.js');
await module.initAuditoriumWorkload();
}
},
'teacher-absences': {
title: 'Запросы преподавателей',
file: '/admin/views/teacher-absences.html',
init: async () => {
const module = await import('./views/teacher-absences.js');
await module.initTeacherAbsences();
}
},
};
let currentTab = null;
// DOM Elements
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleCapabilities = capabilitiesForRole(currentRole);
const allowedTabs = new Set(roleCapabilities.adminTabs);
// Setup Global Effects
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Sidebar Close (X button)
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Settings Dropdown
const settingsDropdown = document.getElementById('settings-dropdown');
const btnSettings = document.getElementById('btn-settings');
btnSettings.addEventListener('click', (e) => {
e.stopPropagation();
settingsDropdown.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!settingsDropdown.contains(e.target)) {
settingsDropdown.classList.remove('open');
}
});
// Logout
btnLogout.addEventListener('click', async () => {
await logout();
window.location.href = '/';
});
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
// UI Update
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
// Load template
try {
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
const html = await response.text();
appContent.innerHTML = html;
// Initialize logic for the tab
if (ROUTES[tab].init) {
await ROUTES[tab].init();
}
currentTab = tab;
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
} catch (e) {
renderStatusMessage(appContent, 'Не удалось загрузить выбранную вкладку');
console.error('Не удалось загрузить вкладку');
}
// Close mobile menu if open
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
function configureRoleInterface() {
navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
});
const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]');
if (settingsLink) {
settingsLink.hidden = !SETTINGS_ROLES.includes(currentRole);
}
const firstAllowedTitle = {
ADMIN: 'Magistr',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Расписания'
}[currentRole] || 'Magistr';
document.querySelector('.logo span').textContent = firstAllowedTitle;
}
async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
: countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок');
}
}
function updateTeacherRequestBadge(element, count, label) {
if (!element) return;
const normalizedCount = Number(count) || 0;
element.textContent = label;
element.hidden = normalizedCount <= 0;
element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
document.addEventListener('teacherRequestsChanged', (event) => {
refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
});
const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleCapabilities.defaultAdminTab);
window.addEventListener('hashchange', () => {
const hashTab = window.location.hash.replace('#', '');
if (allowedTabs.has(hashTab) && hashTab !== currentTab) {
switchTab(hashTab);
}
});
}
await bootstrap();

View File

@@ -0,0 +1,43 @@
export function preferredScheduleSemester(semesters, date) {
const items = (semesters || [])
.filter(semester => semester?.startDate && semester?.endDate);
const current = items.find(semester =>
semester.startDate <= date && semester.endDate >= date
);
if (current) return current;
const upcoming = items
.filter(semester => semester.startDate > date)
.sort((first, second) => first.startDate.localeCompare(second.startDate));
if (upcoming.length) return upcoming[0];
return items
.filter(semester => semester.endDate < date)
.sort((first, second) => second.endDate.localeCompare(first.endDate))[0] || null;
}
export function defaultSchedulePeriod(semesters, date, durationDays = 14) {
const semester = preferredScheduleSemester(semesters, date);
const startDate = semester && (date < semester.startDate || date > semester.endDate)
? semester.startDate
: date;
const calculatedEnd = addIsoDays(startDate, Math.max(1, durationDays) - 1);
return {
semester,
startDate,
endDate: semester && calculatedEnd > semester.endDate
? semester.endDate
: calculatedEnd
};
}
function addIsoDays(value, days) {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(year, month - 1, day);
date.setDate(date.getDate() + days);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,301 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
async function fetchEquipments() {
try {
return await api.get('/api/equipments');
} catch (e) {
console.error('Не удалось загрузить список оборудования');
return [];
}
}
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments || !equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
updateSelectText(containerId, selectTextId);
return;
}
container.innerHTML = equipments.map(eq => `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(eq.name)}</span>
</label>
`).join('');
updateSelectText(containerId, selectTextId);
}
export async function initClassrooms() {
const classroomsTbody = document.getElementById('classrooms-tbody');
const createClassroomForm = document.getElementById('create-classroom-form');
const equipmentCheckboxes = document.getElementById('equipment-checkboxes');
const editEquipmentCheckboxes = document.getElementById('edit-equipment-checkboxes');
const modalEditClassroom = document.getElementById('modal-edit-classroom');
const modalEditClassroomClose = document.getElementById('modal-edit-classroom-close');
const editClassroomForm = document.getElementById('edit-classroom-form');
let allEquipments = [];
let editingClassroomData = null;
initMultiSelect('equipment-select-box', 'equipment-dropdown-menu', 'equipment-select-text', 'equipment-checkboxes');
initMultiSelect('edit-equipment-select-box', 'edit-equipment-dropdown-menu', 'edit-equipment-select-text', 'edit-equipment-checkboxes');
async function loadInitialData() {
try {
allEquipments = await fetchEquipments();
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
await loadClassrooms();
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
async function loadClassrooms() {
try {
const classrooms = await api.get('/api/classrooms?includeArchived=true');
renderClassrooms(classrooms);
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderClassrooms(classrooms) {
if (!classrooms || !classrooms.length) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет аудиторий</td></tr>';
return;
}
classroomsTbody.innerHTML = classrooms.map(c => {
const equipHtml = c.equipments && c.equipments.length
? c.equipments.map(eq => escapeHtml(eq.name)).join(', ')
: '—';
return `
<tr>
<td>${c.id}</td>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td>${c.capacity} чел.</td>
<td><small>${equipHtml}</small></td>
<td>
<div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span>
<button class="btn btn-icon-sm btn-secondary btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус" aria-label="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button>
</div>
</td>
<td style="text-align: right;">
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
</td>
</tr>`;
}).join('');
}
createClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-classroom-alert');
const name = document.getElementById('new-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('new-classroom-capacity').value, 10);
const checkedBoxes = Array.from(equipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert('create-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.post('/api/classrooms', { name, capacity, equipmentIds, isAvailable: true });
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" добавлена`, 'success');
createClassroomForm.reset();
updateSelectText('equipment-checkboxes', 'equipment-select-text');
loadClassrooms();
} catch (e) {
showAlert('create-classroom-alert', e.message || 'Ошибка создания', 'error');
}
});
classroomsTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-archive-classroom');
const btnToggleStatus = e.target.closest('.btn-icon-toggle');
const btnEdit = e.target.closest('.classroom-edit-action');
if (btnDelete) {
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
try {
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms();
} catch (err) { alert('Ошибка архивирования'); }
}
const btnRestore = e.target.closest('.btn-restore-classroom');
if (btnRestore) {
try {
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
loadClassrooms();
} catch (err) { alert('Ошибка восстановления аудитории'); }
}
if (btnToggleStatus) {
const id = btnToggleStatus.dataset.id;
const currentStatus = btnToggleStatus.dataset.currentStatus === 'true';
try {
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
loadClassrooms();
} catch (err) { alert('Ошибка изменения статуса'); }
}
if (btnEdit) {
openEditClassroomModal(btnEdit.dataset.id);
}
});
async function openEditClassroomModal(id) {
try {
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data
const classrooms = await api.get('/api/classrooms?includeArchived=true');
editingClassroomData = classrooms.find(c => c.id == id);
if (!editingClassroomData) return;
document.getElementById('edit-classroom-id').value = editingClassroomData.id;
document.getElementById('edit-classroom-name').value = editingClassroomData.name;
document.getElementById('edit-classroom-capacity').value = editingClassroomData.capacity;
const existingEquipIds = editingClassroomData.equipments.map(e => e.id);
renderEquipmentCheckboxes(allEquipments, 'edit-equipment-checkboxes', 'edit-equipment-select-text', existingEquipIds);
hideAlert('edit-classroom-alert');
modalEditClassroom.classList.add('open');
} catch (e) {
alert('Ошибка загрузки данных аудитории');
}
}
modalEditClassroomClose.addEventListener('click', () => {
modalEditClassroom.classList.remove('open');
});
modalEditClassroom.addEventListener('click', (e) => {
if (e.target === modalEditClassroom) {
modalEditClassroom.classList.remove('open');
}
});
editClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-classroom-alert');
const id = document.getElementById('edit-classroom-id').value;
const name = document.getElementById('edit-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('edit-classroom-capacity').value, 10);
const checkedBoxes = Array.from(editEquipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert('edit-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.put('/api/classrooms/' + id, {
name,
capacity,
equipmentIds,
isAvailable: editingClassroomData.isAvailable
});
modalEditClassroom.classList.remove('open');
// We show alert on the main create form area or we could use toast
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" обновлена`, 'success');
loadClassrooms();
} catch (e) {
showAlert('edit-classroom-alert', e.message || 'Ошибка обновления', 'error');
}
});
// Элементы модалки оборудования
const btnManageEquipments = document.getElementById('btn-manage-equipments');
const modalManageEquipments = document.getElementById('modal-manage-equipments');
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
const manageEquipmentForm = document.getElementById('manage-equipment-form');
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
modalManageEquipments.addEventListener('click', (e) => {
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
});
async function openManageEquipmentsModal() {
hideAlert('manage-equipment-alert');
manageEquipmentForm.reset();
modalManageEquipments.classList.add('open');
await loadManageEquipments();
}
async function loadManageEquipments() {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try {
allEquipments = await fetchEquipments();
renderManageEquipmentsTable();
// Обновим чекбоксы на основной странице
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
} catch (e) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderManageEquipmentsTable() {
if (!allEquipments || !allEquipments.length) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
</tr>
`).join('');
}
manageEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-equipment-alert');
const name = manageEquipmentNameInput.value.trim();
if (!name) {
showAlert('manage-equipment-alert', 'Введите название', 'error');
return;
}
try {
const data = await api.post('/api/equipments', { name });
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
manageEquipmentForm.reset();
await loadManageEquipments();
} catch (err) {
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
}
});
manageEquipmentTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete-equipment');
if (!btnDelete) return;
if (!confirm('Удалить оборудование из каталога?')) return;
try {
await api.delete('/api/equipments/' + btnDelete.dataset.id);
await loadManageEquipments();
} catch (err) {
alert(err.message || 'Ошибка удаления');
}
});
loadInitialData();
}

View File

@@ -1,376 +0,0 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
import {
CONFLICT_CHECK_STATE,
conflictCheckPresentation,
currentWeekDateRange,
formatLocalDate,
loadDepartmentSchedules
} from '../dashboard-conflicts.js';
export async function initDashboard() {
// DOM Элементы
const metricGroups = document.getElementById('metric-groups');
const metricStudents = document.getElementById('metric-students');
const metricTeachers = document.getElementById('metric-teachers');
const metricClassrooms = document.getElementById('metric-classrooms');
const currentTimeSlot = document.getElementById('current-time-slot');
const currentLessonsTbody = document.getElementById('current-lessons-tbody');
const freeClassroomsList = document.getElementById('free-classrooms-list');
const conflictsContainer = document.getElementById('conflicts-container');
const btnRecheck = document.getElementById('btn-recheck-conflicts');
let departments = [];
let classrooms = [];
let groups = [];
let teachers = [];
let todayLessons = [];
let timeSlots = [];
let dashboardDataReady = false;
btnRecheck.addEventListener('click', async () => {
btnRecheck.disabled = true;
btnRecheck.setAttribute('aria-busy', 'true');
btnRecheck.textContent = 'Проверка…';
try {
if (dashboardDataReady) {
await checkConflicts();
} else {
await loadData();
}
} finally {
btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false;
btnRecheck.removeAttribute('aria-busy');
}
});
async function loadData() {
dashboardDataReady = false;
try {
// Загружаем базовые справочники
[departments, classrooms, groups, teachers] = await Promise.all([
api.get('/api/departments'),
api.get('/api/classrooms?includeArchived=false'),
api.get('/api/groups?includeArchived=false'),
api.get('/api/users/teachers')
]);
// Выводим метрики
metricGroups.textContent = groups.length;
const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0);
metricStudents.textContent = totalStudents;
metricTeachers.textContent = teachers.length;
const todayStr = formatLocalDate(new Date());
// Загружаем временные слоты и сегодняшнее расписание
const lessonsPromises = departments.map(d =>
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => [])
);
const lessonsArrays = await Promise.all(lessonsPromises);
todayLessons = lessonsArrays.flat();
try {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`);
} catch (e) {
// Если эндпоинт effective не поддерживается, используем базовые слоты
const baseSlots = await api.get('/api/admin/time-slots').catch(() => []);
// Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично)
timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId);
}
// Сортируем слоты по времени
timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
// Посчитаем занятость аудиторий сегодня
const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean));
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
const classPercent = activeClassrooms.length
? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100)
: 0;
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
updateRealtimeMonitoring();
dashboardDataReady = true;
await checkConflicts();
} catch (e) {
dashboardDataReady = false;
console.error('Не удалось загрузить данные панели', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
});
}
}
function updateRealtimeMonitoring() {
const now = new Date();
const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
// Находим текущий временной слот
let currentSlot = null;
for (const slot of timeSlots) {
if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) {
currentSlot = slot;
break;
}
}
if (currentSlot) {
currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} – ${currentSlot.endTime.substring(0, 5)})`;
renderCurrentLessons(currentSlot.orderNumber);
renderFreeClassrooms(currentSlot.orderNumber);
} else {
currentTimeSlot.textContent = 'Вне сетки пар';
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
renderFreeClassrooms(null);
}
}
function renderCurrentLessons(slotOrder) {
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
if (!activeLessons.length) {
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
return;
}
currentLessonsTbody.innerHTML = activeLessons.map(l => `
<tr>
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
</tr>
`).join('');
}
function renderFreeClassrooms(slotOrder) {
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
let occupiedIds = new Set();
if (slotOrder !== null) {
occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId));
}
const free = activeClassrooms.filter(c => !occupiedIds.has(c.id));
if (!free.length) {
freeClassroomsList.innerHTML = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
return;
}
freeClassroomsList.innerHTML = free.map(c => `
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
${escapeHtml(c.name)} (${c.capacity} м.)
</span>
`).join('');
}
// --- Анализ конфликтов (Red Zone) ---
async function checkConflicts() {
conflictsContainer.setAttribute('aria-busy', 'true');
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
try {
const { startDate, endDate } = currentWeekDateRange(new Date());
const scheduleLoad = await loadDepartmentSchedules(
departments,
department => api.get(
`/api/schedule/search?departmentId=${department.id}`
+ `&startDate=${startDate}&endDate=${endDate}`
)
);
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
renderConflicts([], scheduleLoad, { startDate, endDate });
return;
}
const weekLessons = scheduleLoad.lessons;
const conflicts = [];
// Мапы для быстрого поиска характеристик групп и аудиторий
const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0]));
const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0]));
// 1. Проверка накладок преподавателей
// Ключ: date + '_' + timeSlotOrder + '_' + teacherId
const teacherSlots = new Map();
weekLessons.forEach(l => {
if (!l.teacherId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`;
if (!teacherSlots.has(key)) {
teacherSlots.set(key, []);
}
teacherSlots.get(key).push(l);
});
teacherSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт
const classroomIds = new Set(lessons.map(l => l.classroomId));
if (classroomIds.size > 1) {
const first = lessons[0];
const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', ');
conflicts.push({
type: 'TEACHER_COLLISION',
title: 'Накладка в расписании преподавателя',
description: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 2. Проверка накладок аудиторий
// Ключ: date + '_' + timeSlotOrder + '_' + classroomId
const classroomSlots = new Map();
weekLessons.forEach(l => {
if (!l.classroomId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`;
if (!classroomSlots.has(key)) {
classroomSlots.set(key, []);
}
classroomSlots.get(key).push(l);
});
classroomSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина)
const teacherIds = new Set(lessons.map(l => l.teacherId));
const subjectIds = new Set(lessons.map(l => l.subjectId));
if (teacherIds.size > 1 || subjectIds.size > 1) {
const first = lessons[0];
const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И ');
conflicts.push({
type: 'CLASSROOM_COLLISION',
title: 'Накладка в аудитории',
description: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 3. Проверка превышения вместимости аудитории
// Считаем сумму студентов для каждого уникального занятия в аудитории
classroomSlots.forEach((lessons, key) => {
if (!lessons.length) return;
const first = lessons[0];
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
// Суммируем численность всех групп, назначенных на этот слот в этой аудитории
const groupIds = new Set();
lessons.forEach(l => {
if (l.groupIds) {
l.groupIds.forEach(id => groupIds.add(id));
}
});
let totalStudents = 0;
groupIds.forEach(id => {
totalStudents += groupSizeMap.get(id) || 0;
});
if (totalStudents > capacity) {
const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', ');
conflicts.push({
type: 'CAPACITY_OVERFLOW',
title: 'Превышение вместимости аудитории',
description: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'warning'
});
}
});
renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
} catch (e) {
console.error('Не удалось выполнить проверку конфликтов', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Во время анализа произошла ошибка. Повторите проверку позже.'
});
} finally {
conflictsContainer.removeAttribute('aria-busy');
}
}
function renderConflicts(conflicts, scheduleLoad, range) {
const stateCards = [];
const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range);
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
conflictsContainer.innerHTML = renderCheckStateCard(presentation);
return;
}
if (presentation) {
stateCards.push(renderCheckStateCard(presentation));
}
if (!conflicts.length) {
conflictsContainer.innerHTML = stateCards.join('');
return;
}
const conflictCards = conflicts.map(c => {
const isError = c.severity === 'error';
const badgeIcon = isError
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
return `
<div class="dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}">
<div class="dashboard-conflict-card__icon">${badgeIcon}</div>
<div class="dashboard-conflict-card__body">
<h4>${escapeHtml(c.title)}</h4>
<p>${escapeHtml(c.description)}</p>
</div>
</div>
`;
}).join('');
conflictsContainer.innerHTML = stateCards.join('') + conflictCards;
}
function renderCheckStateCard({ tone, title, description, meta = '' }) {
const icon = tone === 'success'
? '<path d="M20 6 9 17l-5-5" />'
: tone === 'warning'
? '<path d="M12 9v4m0 4h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />'
: '<circle cx="12" cy="12" r="9" /><path d="M12 8v5m0 3h.01" />';
return `
<section class="dashboard-check-state dashboard-check-state--${tone}">
<span class="dashboard-check-state__icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
</span>
<span class="dashboard-check-state__body">
<strong>${escapeHtml(title)}</strong>
<span>${escapeHtml(description)}</span>
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
</span>
</section>
`;
}
// Загружаем данные при старте
await loadData();
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
const realtimeTimer = setInterval(() => {
if (document.getElementById('current-time-slot')) {
updateRealtimeMonitoring();
} else {
clearInterval(realtimeTimer);
}
}, 30000);
}

View File

@@ -1,571 +0,0 @@
import { api, getSession } from '../api.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export async function initDepartmentWorkspace() {
const session = getSession();
const role = session?.role;
const userDepartmentId = session?.departmentId;
const departmentField = document.getElementById('department-workspace-department-field');
const departmentSelect = document.getElementById('department-workspace-department');
const startInput = document.getElementById('department-workspace-start');
const endInput = document.getElementById('department-workspace-end');
const refreshButton = document.getElementById('department-workspace-refresh');
const importForm = document.getElementById('department-subject-import-form');
const importRowsContainer = document.getElementById('subject-import-rows');
const importAddButton = document.getElementById('subject-import-add');
const assignTeacherForm = document.getElementById('department-teacher-assign-form');
const assignTeacherSelect = document.getElementById('department-teacher-select');
const assignTeacherComment = document.getElementById('department-teacher-assign-comment');
const teacherRequestForm = document.getElementById('department-teacher-request-form');
const teacherRequestsTbody = document.getElementById('department-teacher-requests-tbody');
const teacherRequestCount = document.getElementById('department-teacher-request-count');
const subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card');
const commentsTitle = document.getElementById('department-comments-title');
const commentsList = document.getElementById('department-comment-list');
const commentsForm = document.getElementById('department-comment-form');
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
const commentsTextInput = document.getElementById('department-comment-text');
const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null;
let lastWorkload = null;
let allTeachers = [];
let teacherRequests = null;
let teachersErrorMessage = '';
let workloadErrorMessage = '';
setDefaultDates(startInput, endInput);
refreshButton?.addEventListener('click', loadWorkspace);
departmentSelect?.addEventListener('change', loadWorkspace);
startInput?.addEventListener('change', handleStartDateChange);
endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects);
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
importRowsContainer?.addEventListener('click', (event) => {
const removeBtn = event.target.closest('.subject-import-remove');
if (removeBtn) {
const rows = readImportRows();
rows.splice(Number(removeBtn.dataset.index), 1);
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
}
});
commentsForm?.addEventListener('submit', saveComment);
commentsCloseButton?.addEventListener('click', () => {
commentsCard.hidden = true;
commentsSubjectInput.value = '';
});
subjectsTbody?.addEventListener('click', event => {
const button = event.target.closest('.department-comments-open');
if (button) {
openComments(button.dataset.subjectId, button.dataset.subjectName);
}
});
try {
await loadDepartments(role, userDepartmentId);
if (role === ROLE_DEPARTMENT) {
departmentField.hidden = true;
departmentSelect.value = userDepartmentId || '';
}
await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]);
await loadWorkspace();
} catch (error) {
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
}
async function loadWorkspace() {
hideAlert('department-workspace-alert');
lastTeachers = null;
lastWorkload = null;
teachersErrorMessage = '';
workloadErrorMessage = '';
renderTeachers();
teacherRequests = null;
renderTeacherRequests();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
}
async function loadSubjects() {
const departmentId = selectedDepartmentId();
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
subjectCount.textContent = 'Загрузка...';
try {
const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`);
subjectCount.textContent = subjectCountLabel(subjects.length);
if (!subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Дисциплины не найдены</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(subject => `
<tr>
<td>${escapeHtml(subject.code || '-')}</td>
<td>${escapeHtml(subject.name || '-')}</td>
<td>${escapeHtml(subject.description || '-')}</td>
<td>
<button type="button" class="btn btn-sm btn-secondary department-comments-open"
data-subject-id="${escapeHtml(subject.id)}"
data-subject-name="${escapeHtml(subject.name || '')}">
Открыть
</button>
</td>
</tr>
`).join('');
} catch (error) {
subjectsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}</td></tr>`;
subjectCount.textContent = '0 дисциплин';
}
}
function renderImportRows(rows) {
if (!importRowsContainer) return;
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
<div class="subject-import-row" data-index="${index}">
<div class="form-group">
<label>Код</label>
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
</div>
<div class="form-group">
<label>Название</label>
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
</div>
<button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
}
function readImportRows() {
if (!importRowsContainer) return [];
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
name: (row.querySelector('.subject-import-name')?.value || '').trim()
}));
}
async function importSubjects(event) {
event.preventDefault();
const alertId = 'department-subject-import-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const rows = readImportRows()
.filter(item => item.name)
.map(item => ({
code: item.code,
name: item.name,
departmentId: Number(departmentId)
}));
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!rows.length) {
showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
return;
}
try {
const result = await api.post('/api/department/subjects/import', rows);
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
renderImportRows([{ code: '', name: '' }]);
await loadSubjects();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
}
}
async function assignTeacherToDepartment(event) {
event.preventDefault();
const alertId = 'department-teacher-assign-alert';
hideAlert(alertId);
const teacherId = assignTeacherSelect?.value;
const departmentId = selectedDepartmentId();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!teacherId) {
showAlert(alertId, 'Выберите преподавателя', 'error');
return;
}
try {
await api.post(`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`, {
departmentId: Number(departmentId),
comment: assignTeacherComment?.value.trim() || null
});
showAlert(alertId, 'Преподаватель привязан к кафедре', 'success');
if (assignTeacherComment) assignTeacherComment.value = '';
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (error) {
showAlert(alertId, error.message || 'Ошибка привязки преподавателя', 'error');
}
}
async function submitTeacherRequest(event) {
event.preventDefault();
const alertId = 'department-teacher-request-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const username = document.getElementById('teacher-request-username')?.value.trim();
const fullName = document.getElementById('teacher-request-fullname')?.value.trim();
const jobTitle = document.getElementById('teacher-request-jobtitle')?.value.trim();
const comment = document.getElementById('teacher-request-comment')?.value.trim();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!username || !fullName) {
showAlert(alertId, 'Заполните логин и ФИО преподавателя', 'error');
return;
}
try {
await api.post('/api/department/teacher-requests', {
departmentId: Number(departmentId),
username,
fullName,
jobTitle,
comment
});
showAlert(alertId, 'Заявка отправлена администратору', 'success');
teacherRequestForm.reset();
await loadTeacherRequests();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка отправки заявки', 'error');
}
}
async function openComments(subjectId, subjectName) {
commentsCard.hidden = false;
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
commentsSubjectInput.value = subjectId;
commentsTextInput.value = '';
hideAlert('department-comment-alert');
commentsList.textContent = 'Загрузка...';
try {
const comments = await api.get(`/api/department/subjects/${subjectId}/comments`);
commentsList.innerHTML = comments.length
? comments.map(comment => `
<article class="department-comment-item">
<strong>${escapeHtml(comment.authorName || 'Автор не указан')}</strong>
<span>${escapeHtml(formatDateTime(comment.createdAt))}</span>
<p>${escapeHtml(comment.comment)}</p>
</article>
`).join('')
: '<div class="loading-row">Комментариев пока нет</div>';
} catch (error) {
commentsList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки комментариев')}</div>`;
}
}
async function saveComment(event) {
event.preventDefault();
const subjectId = commentsSubjectInput.value;
const comment = commentsTextInput.value.trim();
if (!subjectId) {
showAlert('department-comment-alert', 'Выберите дисциплину', 'error');
return;
}
if (!comment) {
showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error');
return;
}
try {
await api.post(`/api/department/subjects/${subjectId}/comments`, { comment });
showAlert('department-comment-alert', 'Комментарий сохранён', 'success');
await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', ''));
} catch (error) {
showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error');
}
}
async function loadTeachers() {
lastTeachers = null;
teachersErrorMessage = '';
renderTeachers();
renderTeacherSelect();
try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
lastTeachers = Array.isArray(teachers) ? teachers : [];
renderTeachers();
renderTeacherSelect();
} catch (error) {
lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers();
renderTeacherSelect();
}
}
async function loadAllTeachers() {
try {
const teachers = await api.get('/api/users/teachers');
allTeachers = Array.isArray(teachers) ? teachers : [];
renderTeacherSelect();
} catch (error) {
allTeachers = [];
if (assignTeacherSelect) {
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
}
}
}
async function loadTeacherRequests() {
teacherRequests = null;
renderTeacherRequests();
try {
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
teacherRequests = Array.isArray(requests) ? requests : [];
renderTeacherRequests();
} catch (error) {
teacherRequests = [];
if (teacherRequestsTbody) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
}
}
async function loadWorkload() {
if (!startInput.value || !endInput.value) {
lastWorkload = [];
workloadErrorMessage = 'Выберите начало и окончание периода';
renderTeachers();
return;
}
const params = new URLSearchParams({
startDate: startInput.value,
endDate: endInput.value
});
if (selectedDepartmentId()) {
params.set('departmentId', selectedDepartmentId());
}
lastWorkload = null;
workloadErrorMessage = '';
renderTeachers();
try {
const workload = await api.get(`/api/workload/teachers?${params}`);
lastWorkload = Array.isArray(workload) ? workload : [];
renderTeachers();
} catch (error) {
lastWorkload = [];
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
renderTeachers();
}
}
async function handleStartDateChange() {
if (startInput.value && endInput.value && startInput.value > endInput.value) {
endInput.value = '';
}
await loadWorkload();
}
function renderTeachers() {
const container = document.getElementById('department-teachers-list');
if (!container) return;
if (lastTeachers === null || lastWorkload === null) {
container.textContent = 'Загрузка...';
return;
}
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
const workloadByTeacher = new Map();
const usedWorkloadKeys = new Set();
workload
.filter(item => item && (item.id || item.name))
.forEach(item => {
workloadKeys(item).forEach(key => {
if (!workloadByTeacher.has(key)) {
workloadByTeacher.set(key, item);
}
});
});
const rows = teachers.map(teacher => {
const workloadItem = workloadKeys(teacher)
.map(key => workloadByTeacher.get(key))
.find(Boolean);
if (workloadItem) {
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
}
return renderTeacherMetric({
name: teacher.fullName || teacher.username || '-',
meta: teacher.jobTitle || 'Должность не указана',
workload: workloadItem
}, workloadErrorMessage);
});
workload
.filter(item => item && (item.id || item.name))
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
.forEach(item => {
rows.push(renderTeacherMetric({
name: item.name || '-',
meta: 'Есть занятия в расписании кафедры',
workload: item
}, workloadErrorMessage));
});
if (rows.length) {
container.innerHTML = rows.join('');
return;
}
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
function renderTeacherSelect() {
if (!assignTeacherSelect) return;
if (lastTeachers === null) {
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
return;
}
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(teacher => `
<option value="${escapeHtml(teacher.id)}">
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
</option>
`).join('');
if (!availableTeachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
}
}
function renderTeacherRequests() {
if (!teacherRequestsTbody) return;
if (teacherRequests === null) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
return;
}
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
if (!teacherRequests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
<tr>
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
<td>${escapeHtml(request.username || '-')}</td>
<td>${escapeHtml(request.fullName || '-')}</td>
<td>${escapeHtml(request.jobTitle || '-')}</td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
</tr>
`).join('');
}
function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
}
}
async function loadDepartments(role, userDepartmentId) {
const departments = await api.get('/api/departments');
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
if (role === ROLE_DEPARTMENT && userDepartmentId) {
document.getElementById('department-workspace-department').value = userDepartmentId;
} else if (!document.getElementById('department-workspace-department').value && departments.length) {
document.getElementById('department-workspace-department').value = departments[0].id;
}
}
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
const select = document.getElementById(id);
if (!select) return;
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
}
function setDefaultDates(startInput, endInput) {
const today = new Date();
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
startInput.value = formatLocalDate(today);
endInput.value = formatLocalDate(nextWeek);
}
function formatDateTime(value) {
if (!value) return '-';
return String(value).replace('T', ' ').slice(0, 16);
}
function subjectCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
if (count % 10 === 1) return `${count} дисциплина`;
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`;
}
function requestCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} заявок`;
if (count % 10 === 1) return `${count} заявка`;
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
return `${count} заявок`;
}
function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
function renderTeacherMetric(item, workloadErrorMessage) {
return `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
</article>
`;
}
function workloadLabel(item, workloadErrorMessage) {
if (workloadErrorMessage) {
return workloadErrorMessage;
}
if (!item) {
return '0 ак. ч., 0 занятий за период';
}
const hours = String(item.academicHours ?? 0);
const lessons = String(item.lessonCount ?? 0);
return `${hours} ак. ч., ${lessons} занятий за период`;
}
function workloadKeys(item) {
return [
item.id == null ? null : `id:${item.id}`,
normalizeTeacherName(item.fullName || item.name || item.username)
].filter(Boolean);
}
function normalizeTeacherName(value) {
return value ? `name:${String(value).trim().toLowerCase()}` : null;
}

View File

@@ -1,887 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
async function fetchEducationForms() {
try {
return await api.get('/api/education-forms');
} catch (e) {
console.error('Не удалось загрузить формы обучения');
return [];
}
}
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form');
const newGroupEfSelect = document.getElementById('new-group-ef');
const newGroupDepartmentSelect = document.getElementById('new-group-department');
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
const newGroupProfileSelect = document.getElementById('new-group-profile');
const filterEfBox = document.getElementById('filter-ef-box');
const filterEfMenu = document.getElementById('filter-ef-menu');
const filterEfText = document.getElementById('filter-ef-text');
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
// Модалка группы
const modalEditGroup = document.getElementById('modal-edit-group');
const modalEditGroupClose = document.getElementById('modal-edit-group-close');
const editGroupForm = document.getElementById('edit-group-form');
const editGroupEfSelect = document.getElementById('edit-group-ef');
const editGroupDepartmentSelect = document.getElementById('edit-group-department');
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
const editGroupProfileSelect = document.getElementById('edit-group-profile');
// Модалка подгрупп
const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
const subgroupsGroupContext = document.getElementById('subgroups-group-context');
const subgroupFormTitle = document.getElementById('subgroup-form-title');
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
// Модалка календарей
const modalManageCalendar = document.getElementById('modal-manage-calendar');
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
const calendarGroupContext = document.getElementById('calendar-group-context');
const manageCalendarForm = document.getElementById('manage-calendar-form');
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
const manageCalendarSelect = document.getElementById('manage-calendar-id');
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
let allGroups = [];
let subgroups = [];
let educationForms = [];
let departments = [];
let specialties = [];
let profilesBySpecialty = new Map();
let academicYears = [];
let calendars = [];
let currentAssignments = [];
let activeSubgroupGroup = null;
bindEvents();
await loadInitialData();
function bindEvents() {
initFilterEfDropdown();
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
createGroupForm.addEventListener('submit', createGroup);
editGroupForm.addEventListener('submit', updateGroup);
// Модалка подгрупп
manageSubgroupForm.addEventListener('submit', saveSubgroup);
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
// Модалка календарей
manageCalendarForm.addEventListener('submit', saveAssignment);
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
groupsTbody.addEventListener('click', handleGroupTableClick);
// Закрытие модалок
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
modalEditGroup.addEventListener('click', (event) => {
if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
});
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
modalManageSubgroups.addEventListener('click', (event) => {
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
});
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
modalManageCalendar.addEventListener('click', (event) => {
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
});
}
function initFilterEfDropdown() {
if (!filterEfBox || !filterEfMenu) return;
const toggleDropdown = (event) => {
event.stopPropagation();
const isOpen = filterEfMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (!isOpen) {
filterEfMenu.classList.add('open');
filterEfBox.classList.add('active');
filterEfBox.setAttribute('aria-expanded', 'true');
} else {
filterEfBox.setAttribute('aria-expanded', 'false');
}
};
filterEfBox.addEventListener('click', toggleDropdown);
filterEfBox.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleDropdown(event);
}
});
filterEfMenu.addEventListener('click', (event) => {
event.stopPropagation();
});
}
async function loadInitialData() {
try {
[educationForms, departments, specialties, academicYears, calendars] = await Promise.all([
fetchEducationForms(),
api.get('/api/departments'),
api.get('/api/specialties'),
api.get('/api/admin/calendar/years'),
api.get('/api/admin/academic-calendars')
]);
await loadProfiles();
populateEfSelects(educationForms);
populateDepartmentSelects(departments);
populateSpecialitySelects(specialties);
populateYearSelect();
await loadGroups();
await loadSubgroups();
} catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
}
}
async function loadProfiles() {
const pairs = await Promise.all(specialties.map(async (speciality) => {
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
return [String(speciality.id), profiles];
}));
profilesBySpecialty = new Map(pairs);
}
async function loadGroups() {
try {
allGroups = await api.get('/api/groups?includeArchived=true');
applyGroupFilter();
} catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
async function loadSubgroups() {
try {
subgroups = await api.get('/api/subgroups');
} catch (error) {
console.error('Не удалось загрузить подгруппы');
}
}
function applyGroupFilter() {
const selectedIds = selectedFilterEducationFormIds();
const filtered = selectedIds.length
? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
: allGroups;
renderGroups(filtered);
}
function handleFilterEfChange(event) {
const changedCheckbox = event.target.closest('input[type="checkbox"]');
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (changedCheckbox?.value === 'all' && changedCheckbox.checked) {
filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(checkbox => {
checkbox.checked = false;
});
} else if (changedCheckbox?.value !== 'all' && changedCheckbox?.checked) {
allCheckbox.checked = false;
}
if (!selectedFilterEducationFormIds().length) {
allCheckbox.checked = true;
}
updateFilterEfText();
applyGroupFilter();
}
function selectedFilterEducationFormIds() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
if (allCheckbox?.checked) return [];
return Array.from(filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'))
.map(checkbox => String(checkbox.value));
}
function updateFilterEfText() {
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
const selectedIds = selectedFilterEducationFormIds();
if (allCheckbox?.checked) {
filterEfText.textContent = 'Все';
return;
}
if (!selectedIds.length) {
filterEfText.textContent = FILTER_EF_EMPTY_TEXT;
return;
}
if (selectedIds.length === 1) {
const form = educationForms.find(item => String(item.id) === selectedIds[0]);
filterEfText.textContent = form?.name || FILTER_EF_EMPTY_TEXT;
return;
}
filterEfText.textContent = `Выбрано: ${selectedIds.length}`;
}
function renderGroups(groups) {
if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(group => `
<tr>
<td>${group.id}</td>
<td><strong>${escapeHtml(group.name)}</strong></td>
<td>${escapeHtml(String(group.groupSize))}</td>
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
<td>${group.course || '-'}</td>
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
<td class="group-actions-cell">
<div class="group-actions">
<button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
${group.status === 'ARCHIVED'
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
</div>
</td>
</tr>
`).join('');
}
function populateEfSelects(forms) {
populateEfSelect(newGroupEfSelect, forms);
populateEfSelect(editGroupEfSelect, forms);
renderFilterEfCheckboxes(forms);
}
function renderFilterEfCheckboxes(forms) {
const selectedIds = new Set(selectedFilterEducationFormIds());
const allChecked = !selectedIds.size;
filterEfCheckboxes.innerHTML = `
<label class="checkbox-item">
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
<span class="checkmark"></span>
<span>Все</span>
</label>
${forms.map(form => {
const id = String(form.id);
return `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(form.name)}</span>
</label>
`;
}).join('')}
`;
updateFilterEfText();
}
function populateEfSelect(select, forms) {
const currentVal = select.value;
select.innerHTML = forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentVal && forms.find(form => form.id == currentVal)) {
select.value = currentVal;
}
syncSelects(select);
}
function populateDepartmentSelects(items) {
populateDepartmentSelect(newGroupDepartmentSelect, items);
populateDepartmentSelect(editGroupDepartmentSelect, items);
}
function populateDepartmentSelect(select, items) {
const currentVal = select.value;
select.innerHTML = '<option value="">Выберите кафедру</option>' +
items.map(department => {
const name = department.departmentName || department.name || 'Без названия';
const code = department.departmentCode || department.code || department.id;
return `<option value="${department.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
}).join('');
if (currentVal && items.find(department => department.id == currentVal)) {
select.value = currentVal;
}
syncSelects(select);
}
function populateSpecialitySelects(items) {
populateSpecialitySelect(newGroupSpecialitySelect, items);
populateSpecialitySelect(editGroupSpecialitySelect, items);
populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value);
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value);
}
function populateSpecialitySelect(select, items) {
const currentVal = select.value;
select.innerHTML = '<option value="">Выберите специальность</option>' +
items.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality.id))}</option>`).join('');
if (currentVal && items.find(speciality => speciality.id == currentVal)) {
select.value = currentVal;
}
syncSelects(select);
}
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
select.innerHTML = profiles.length
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
).join('')
: '<option value="">Нет профилей</option>';
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
select.value = selectedProfileId;
}
syncSelects(select);
}
function populateYearSelect() {
manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
if (academicYears.length) {
manageCalendarYearSelect.value = String(academicYears[0].id);
}
syncSelects(manageCalendarYearSelect);
}
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);
}
async function createGroup(event) {
event.preventDefault();
hideAlert('create-group-alert');
const payload = readGroupPayload('new');
if (!payload) return;
try {
const data = await api.post('/api/groups', payload);
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" создана`, 'success');
createGroupForm.reset();
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
await loadGroups();
} catch (error) {
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
}
}
async function updateGroup(event) {
event.preventDefault();
hideAlert('edit-group-alert');
const id = document.getElementById('edit-group-id').value;
const payload = readGroupPayload('edit');
if (!payload) return;
try {
const data = await api.put('/api/groups/' + id, payload);
modalEditGroup.classList.remove('open');
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
await loadGroups();
} catch (error) {
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
}
}
function readGroupPayload(prefix) {
const isEdit = prefix === 'edit';
const alertId = isEdit ? 'edit-group-alert' : 'create-group-alert';
const name = document.getElementById(`${prefix}-group-name`).value.trim();
const groupSize = document.getElementById(`${prefix}-group-size`).value;
const educationFormId = (isEdit ? editGroupEfSelect : newGroupEfSelect).value;
const departmentId = (isEdit ? editGroupDepartmentSelect : newGroupDepartmentSelect).value;
const yearStartStudy = document.getElementById(`${prefix}-group-yearStartStudy`).value;
const specialtyId = (isEdit ? editGroupSpecialitySelect : newGroupSpecialitySelect).value;
const specialtyProfileId = (isEdit ? editGroupProfileSelect : newGroupProfileSelect).value;
if (!name) { showAlert(alertId, 'Введите название группы', 'error'); return null; }
if (!groupSize) { showAlert(alertId, 'Введите размер группы', 'error'); return null; }
if (!educationFormId) { showAlert(alertId, 'Выберите форму обучения', 'error'); return null; }
if (!departmentId) { showAlert(alertId, 'Выберите кафедру', 'error'); return null; }
if (!yearStartStudy) { showAlert(alertId, 'Введите год начала обучения', 'error'); return null; }
if (!specialtyId) { showAlert(alertId, 'Выберите специальность', 'error'); return null; }
if (!specialtyProfileId) { showAlert(alertId, 'Выберите профиль обучения', 'error'); return null; }
return {
name,
groupSize: Number(groupSize),
educationFormId: Number(educationFormId),
departmentId: Number(departmentId),
yearStartStudy: Number(yearStartStudy),
specialtyId: Number(specialtyId),
specialtyProfileId: Number(specialtyProfileId)
};
}
async function handleGroupTableClick(event) {
const btnDelete = event.target.closest('.btn-archive-group');
const btnEdit = event.target.closest('.btn-edit-group');
const btnRestore = event.target.closest('.btn-restore-group');
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
const btnCalendar = event.target.closest('.btn-manage-calendar');
if (btnDelete) {
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
try {
await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups();
} catch (error) {
alert(error.message || 'Ошибка архивирования');
}
}
if (btnRestore) {
try {
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
await loadGroups();
} catch (error) {
alert(error.message || 'Ошибка восстановления');
}
}
if (btnEdit) {
openEditGroupModal(btnEdit.dataset.id);
}
if (btnSubgroups) {
const groupId = btnSubgroups.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openSubgroupsModal(group);
}
if (btnCalendar) {
const groupId = btnCalendar.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openCalendarModal(group);
}
}
function openEditGroupModal(id) {
const group = allGroups.find(item => item.id == id);
if (!group) {
alert('Группа не найдена');
return;
}
document.getElementById('edit-group-id').value = group.id;
document.getElementById('edit-group-name').value = group.name || '';
document.getElementById('edit-group-size').value = group.groupSize || '';
editGroupEfSelect.value = group.educationFormId || '';
editGroupDepartmentSelect.value = group.departmentId || '';
document.getElementById('edit-group-yearStartStudy').value = group.yearStartStudy || '';
editGroupSpecialitySelect.value = group.specialtyId || group.specialityCode || '';
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value, group.specialtyProfileId);
syncSelects(editGroupEfSelect, editGroupDepartmentSelect, editGroupSpecialitySelect, editGroupProfileSelect);
hideAlert('edit-group-alert');
modalEditGroup.classList.add('open');
}
// --- Управление подгруппами ---
function openSubgroupsModal(group) {
activeSubgroupGroup = group;
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
manageSubgroupGroupIdInput.value = group.id;
resetSubgroupForm();
modalManageSubgroups.classList.add('open');
renderSubgroupsList(group.id);
}
function renderSubgroupsList(groupId) {
const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
if (!list.length) {
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
return;
}
const canDeleteSingleSubgroup = list.length === 1;
const deleteTitle = canDeleteSingleSubgroup
? 'Удалить подгруппу'
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
manageSubgroupsTbody.innerHTML = list.map(sub => `
<tr>
<td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
${canDeleteSingleSubgroup ? '' : 'disabled'}
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
</td>
</tr>
`).join('');
}
function resetSubgroupForm() {
manageSubgroupIdInput.value = '';
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
const count = [2, 3].includes(current.length) ? current.length : 0;
manageSubgroupCountSelect.value = String(count);
syncSelects(manageSubgroupCountSelect);
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
subgroupFormTitle.textContent = 'Настройка подгрупп';
btnSaveSubgroup.textContent = 'Применить';
btnCancelSubgroupEdit.hidden = true;
hideAlert('manage-subgroup-alert');
}
function currentGroupSubgroups(groupId) {
return subgroups
.filter(sub => String(sub.groupId) === String(groupId))
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
}
function naturalSubgroupOrder(name) {
const number = String(name || '').match(/\d+/)?.[0];
return number ? String(number).padStart(3, '0') : String(name || '');
}
function renderSubgroupCapacityFields(count, capacities = []) {
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
if (!count) {
manageSubgroupCapacities.innerHTML = '';
return;
}
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
const suggested = suggestedSubgroupCapacities(groupSize, count);
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
const value = capacities[index] ?? suggested[index] ?? '';
return `
<div class="form-group subgroup-capacity-field">
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
</div>
`;
}).join('');
}
function suggestedSubgroupCapacities(groupSize, count) {
if (!groupSize || !count) return [];
const base = Math.floor(groupSize / count);
const remainder = groupSize % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
async function saveSubgroup(event) {
event.preventDefault();
hideAlert('manage-subgroup-alert');
const groupId = manageSubgroupGroupIdInput.value;
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
const count = Number(manageSubgroupCountSelect.value);
const existingSubgroups = currentGroupSubgroups(groupId);
if (!group) {
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
return;
}
if (count === 0) {
await applyNoSubgroups(groupId, existingSubgroups);
return;
}
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
.map(input => Number(input.value));
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
if (validationError) {
showAlert('manage-subgroup-alert', validationError, 'error');
return;
}
try {
await syncSubgroups(groupId, existingSubgroups, capacities);
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
await loadSubgroups();
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
}
}
function validateSubgroupCapacities(capacities, groupSize, count) {
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
return 'Укажите положительную численность для каждой подгруппы';
}
const total = capacities.reduce((sum, value) => sum + value, 0);
if (total !== groupSize) {
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
}
return null;
}
async function applyNoSubgroups(groupId, existingSubgroups) {
try {
for (const subgroup of existingSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
await loadSubgroups();
resetSubgroupForm();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка отключения подгрупп', 'error');
}
}
async function syncSubgroups(groupId, existingSubgroups, capacities) {
const targetCount = capacities.length;
const extraSubgroups = existingSubgroups.slice(targetCount);
for (const subgroup of extraSubgroups) {
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
}
const updates = [];
const creates = [];
for (let index = 0; index < targetCount; index += 1) {
const payload = {
name: `Подгруппа ${index + 1}`,
studentCapacity: capacities[index]
};
const existing = existingSubgroups[index];
if (existing) {
updates.push({ existing, payload });
} else {
creates.push(payload);
}
}
updates.sort((left, right) => {
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
return Number(leftIncreases) - Number(rightIncreases);
});
for (const update of updates) {
await api.put(`/api/groups/${groupId}/subgroups/${update.existing.id}`, update.payload);
}
for (const payload of creates) {
await api.post(`/api/groups/${groupId}/subgroups`, payload);
}
}
async function handleSubgroupTableClick(event) {
const editButton = event.target.closest('.btn-edit-subgroup');
const deleteButton = event.target.closest('.btn-delete-subgroup');
const groupId = manageSubgroupGroupIdInput.value;
if (editButton) {
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
if (!subgroup) return;
resetSubgroupForm();
const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
input?.focus();
hideAlert('manage-subgroup-alert');
return;
}
if (deleteButton) {
if (deleteButton.disabled) {
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
return;
}
if (!confirm('Удалить подгруппу?')) return;
try {
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
resetSubgroupForm();
await loadSubgroups();
renderSubgroupsList(groupId);
} catch (error) {
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
}
}
}
// --- Управление календарными графиками ---
async function openCalendarModal(group) {
calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
manageCalendarGroupIdInput.value = group.id;
hideAlert('manage-calendar-alert');
populateCalendarSelect();
modalManageCalendar.classList.add('open');
await loadAssignmentsList(group.id);
}
async function loadAssignmentsList(groupId) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignmentsList();
} catch (error) {
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignmentsList() {
if (!currentAssignments.length) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
return;
}
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function assignmentCalendarTitle(assignment) {
const calendar = calendars.find(item =>
String(item.id) === String(assignment.calendarId)
);
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
}
function renderAssignmentSubjects(subjects) {
if (!subjects.length) {
return '<span class="muted">Не привязаны</span>';
}
const grouped = subjects.reduce((acc, subject) => {
const key = String(subject.semesterNumber || '-');
if (!acc.has(key)) acc.set(key, []);
acc.get(key).push(subject);
return acc;
}, new Map());
return `<div class="assignment-subjects">${Array.from(grouped.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([semesterNumber, items]) => `
<div class="assignment-subject-semester">
<strong>${escapeHtml(semesterNumber)} семестр</strong>
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
</div>
`).join('')}</div>`;
}
async function saveAssignment(event) {
event.preventDefault();
hideAlert('manage-calendar-alert');
const groupId = manageCalendarGroupIdInput.value;
const academicYearId = manageCalendarYearSelect.value;
const calendarId = manageCalendarSelect.value;
if (!academicYearId || !calendarId) {
showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
return;
}
try {
await api.put(`/api/groups/${groupId}/calendar-assignments`, {
academicYearId: Number(academicYearId),
calendarId: Number(calendarId)
});
showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
await loadAssignmentsList(groupId);
} catch (error) {
showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
}
}
manageCalendarTbody.addEventListener('click', async (event) => {
const deleteButton = event.target.closest('.btn-delete-assignment');
if (!deleteButton) return;
if (!confirm('Удалить назначение графика?')) return;
const groupId = manageCalendarGroupIdInput.value;
try {
await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
await loadAssignmentsList(groupId);
} catch (error) {
showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
}
});
// --- Вспомогательные функции ---
function departmentLabel(departmentId) {
const department = departments.find(item => item.id == departmentId);
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
function specialityLabel(specialityId) {
const speciality = specialties.find(item => item.id == specialityId);
if (!speciality) return specialityId || '-';
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} — ${name}` : code;
}
function subjectLabel(subject) {
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
}
function statusLabel(group) {
if (group.status === 'ARCHIVED') return 'Архив';
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
}
function statusBadgeClass(group) {
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
return 'badge-unavailable';
}
if (group.studyState === 'NOT_STARTED') {
return 'badge-ef';
}
return 'badge-available';
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
}

View File

@@ -1,611 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
const ACTION_LABELS = {
MOVE: 'Перенос',
REPLACE: 'Замена',
CANCEL: 'Отмена'
};
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTH_LABELS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
export function canEditScheduleOverrides(role) {
return EDIT_ROLES.has(role);
}
export function overrideActionForChanges(changes) {
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
return null;
}
export function weekDates(value) {
const selected = parseIsoDate(value);
const day = selected.getDay() || 7;
const monday = addDays(selected, 1 - day);
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
}
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
const safeSlots = Array.isArray(slots) ? slots : [];
const byId = preferredId == null
? null
: safeSlots.find(slot => String(slot.id) === String(preferredId));
if (byId) return byId;
const normalizedStart = trimTime(startTime);
const normalizedEnd = trimTime(endTime);
if (!normalizedStart || !normalizedEnd) return null;
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
&& trimTime(slot.endTime) === normalizedEnd) || null;
}
export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) {
if (!canEditScheduleOverrides(role)) {
return {
setRegistry() {},
openLesson() {},
openRegistry() {}
};
}
const drawer = document.getElementById('schedule-override-drawer');
const form = document.getElementById('schedule-override-form');
const empty = document.getElementById('schedule-override-empty');
const registryButton = document.getElementById('schedule-view-overrides-open');
const registryCount = document.getElementById('schedule-view-overrides-count');
const registry = document.getElementById('schedule-override-registry');
const registryPeriod = document.getElementById('schedule-override-registry-period');
const calendar = document.getElementById('schedule-override-calendar');
const calendarToggle = document.getElementById('schedule-override-calendar-toggle');
const week = document.getElementById('schedule-override-week');
const timeSelect = document.getElementById('schedule-override-time');
const teacherSelect = document.getElementById('schedule-override-teacher');
const classroomSelect = document.getElementById('schedule-override-classroom');
const formatSelect = document.getElementById('schedule-override-format');
const commentInput = document.getElementById('schedule-override-comment');
const restoreButton = document.getElementById('schedule-override-restore');
const cancelButton = document.getElementById('schedule-override-cancel');
const saveButton = document.getElementById('schedule-override-save');
const state = {
overrides: [],
selectedLesson: null,
selectedOverride: null,
source: null,
availability: null,
targetDate: null,
timeSlots: [],
busy: false
};
registryButton.hidden = false;
fillReferenceSelects();
bindEvents();
renderRegistry();
return { setRegistry, openLesson, openRegistry };
function bindEvents() {
drawer.querySelectorAll('[data-override-close]').forEach(button => {
button.addEventListener('click', close);
});
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.addEventListener('click', () => activateTab(button.dataset.overrideTab));
});
registryButton.addEventListener('click', openRegistry);
calendarToggle.addEventListener('click', () => {
calendar.hidden = !calendar.hidden;
calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра';
});
week.addEventListener('click', selectDateFromButton);
calendar.addEventListener('click', selectDateFromButton);
registry.addEventListener('click', event => {
const button = event.target.closest('[data-override-registry-id]');
if (!button) return;
const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId);
if (item) openOverride(item);
});
[timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => {
select.addEventListener('change', updateComparison);
});
form.addEventListener('submit', save);
cancelButton.addEventListener('click', cancelLesson);
restoreButton.addEventListener('click', restoreRule);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !drawer.hidden) close();
});
}
function setRegistry(overrides, range) {
state.overrides = Array.isArray(overrides) ? overrides : [];
registryCount.textContent = String(state.overrides.length);
const activeRange = range || getRange?.();
registryPeriod.textContent = activeRange
? `${formatDate(activeRange.startDate)} — ${formatDate(activeRange.endDate)}`
: 'Период не выбран';
renderRegistry();
}
async function openLesson(lesson) {
if (!lesson?.scheduleRuleSlotId) return;
const originalDate = lesson.originalLessonDate || lesson.date;
const existing = state.overrides.find(override =>
String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId)
&& override.lessonDate === originalDate
) || null;
await prepareEditor(lesson, existing);
}
async function openOverride(override) {
const syntheticLesson = {
scheduleRuleSlotId: override.baseRuleSlotId,
date: override.targetLessonDate || override.lessonDate,
originalLessonDate: override.lessonDate,
scheduleOverrideId: override.id,
subjectName: override.subjectName,
lessonTypeName: override.lessonTypeName,
groupNames: override.groupNames,
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
timeSlotOrder: override.sourceTimeSlotOrder,
startTime: override.sourceStartTime,
endTime: override.sourceEndTime,
teacherId: override.newTeacherId || override.sourceTeacherId,
teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName,
classroomId: override.newClassroomId || override.sourceClassroomId,
classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName,
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
};
await prepareEditor(syntheticLesson, override);
}
function openRegistry() {
open();
activateTab('registry');
renderRegistry();
}
async function prepareEditor(lesson, existing) {
open();
activateTab('edit');
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Загрузка доступных дат и времени...';
hideAlert('schedule-override-alert');
state.selectedLesson = lesson;
state.selectedOverride = existing;
state.source = sourceFrom(lesson, existing);
state.targetDate = existing?.targetLessonDate || state.source.lessonDate;
try {
state.availability = await api.get(
`/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}`
+ `&lessonDate=${encodeURIComponent(state.source.lessonDate)}`
);
renderDateChoices();
await loadTimeSlots(initialTimePreference(existing));
setEditorValues(existing);
renderSource();
updateComparison();
restoreButton.hidden = !existing;
form.hidden = false;
empty.hidden = true;
} catch (error) {
empty.textContent = error.message || 'Не удалось загрузить данные занятия';
}
}
function sourceFrom(lesson, existing) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
startTime: existing?.sourceStartTime || lesson.startTime,
endTime: existing?.sourceEndTime || lesson.endTime,
teacherId: existing?.sourceTeacherId || lesson.teacherId,
teacherName: existing?.sourceTeacherName || lesson.teacherName,
classroomId: existing?.sourceClassroomId || lesson.classroomId,
classroomName: existing?.sourceClassroomName || lesson.classroomName,
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
};
}
function initialTimePreference(existing) {
return {
id: existing?.newTimeSlotId || state.source.timeSlotId,
startTime: existing?.newTimeSlotId ? null : state.source.startTime,
endTime: existing?.newTimeSlotId ? null : state.source.endTime
};
}
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 || '';
ensureSelectOption(
teacherSelect,
effectiveTeacherId,
teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}`
);
ensureSelectOption(
classroomSelect,
effectiveClassroomId,
classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}`
);
teacherSelect.value = String(effectiveTeacherId);
classroomSelect.value = String(effectiveClassroomId);
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
commentInput.value = existing?.comment || '';
}
async function selectDateFromButton(event) {
const button = event.target.closest('[data-override-date]');
if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return;
state.targetDate = button.dataset.overrideDate;
renderDateChoices();
await loadTimeSlots({
id: state.source.timeSlotId,
startTime: state.source.startTime,
endTime: state.source.endTime
});
updateComparison();
}
function renderDateChoices() {
const available = new Set(state.availability?.availableDates || []);
week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join('');
calendar.innerHTML = renderSemesterCalendar(
state.availability.semesterStartDate,
state.availability.semesterEndDate,
available,
state.targetDate
);
}
function dateButton(date, label, available) {
const selected = date === state.targetDate ? ' active' : '';
const disabled = available.has(date) ? '' : ' disabled';
return `
<button type="button" class="schedule-override-date${selected}" data-override-date="${escapeHtml(date)}"${disabled}>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(formatDateShort(date))}</strong>
</button>
`;
}
async function loadTimeSlots(preference) {
timeSelect.disabled = true;
timeSelect.innerHTML = '<option value="">Загрузка...</option>';
state.timeSlots = await api.get(
`/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}`
);
const selected = pickMatchingTimeSlot(
state.timeSlots,
preference?.id,
preference?.startTime,
preference?.endTime
);
timeSelect.innerHTML = '<option value="">Выберите время</option>' + state.timeSlots.map(slot =>
`<option value="${escapeHtml(slot.id)}">${escapeHtml(timeSlotLabel(slot))}</option>`
).join('');
timeSelect.value = selected ? String(selected.id) : '';
timeSelect.disabled = false;
}
function renderSource() {
document.getElementById('schedule-override-before-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-before-meta').textContent = [
formatDate(state.source.lessonDate),
intervalLabel(state.source.startTime, state.source.endTime),
state.source.teacherName,
state.source.classroomName,
state.source.lessonFormat,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
function updateComparison() {
if (!state.source) return;
const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value);
document.getElementById('schedule-override-after-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-after-meta').textContent = [
formatDate(state.targetDate),
selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
teacherName(teacherSelect.value),
classroomName(classroomSelect.value),
formatSelect.value,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
async function save(event) {
event.preventDefault();
if (state.busy) return;
hideAlert('schedule-override-alert');
const payload = buildPayload();
if (!payload) return;
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
function buildPayload() {
if (!timeSelect.value) {
showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error');
return null;
}
const dateChanged = state.targetDate !== state.source.lessonDate;
const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId);
const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId);
const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId);
const formatChanged = formatSelect.value !== state.source.lessonFormat;
const action = overrideActionForChanges({
dateChanged,
timeChanged,
teacherChanged,
classroomChanged,
formatChanged
});
if (!action) {
showAlert(
'schedule-override-alert',
state.selectedOverride
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
: 'Измените дату, время, преподавателя, аудиторию или формат',
'error'
);
return null;
}
return {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: dateChanged ? state.targetDate : null,
action,
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null,
newTeacherId: teacherChanged ? Number(teacherSelect.value) : null,
newClassroomId: classroomChanged ? Number(classroomSelect.value) : null,
newLessonFormat: formatChanged ? formatSelect.value : null,
comment: commentInput.value.trim() || null
};
}
async function cancelLesson() {
if (state.busy || !state.source) return;
const payload = {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: null,
action: 'CANCEL',
newTimeSlotId: null,
newTeacherId: null,
newClassroomId: null,
newLessonFormat: null,
comment: commentInput.value.trim() || null
};
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
async function restoreRule() {
if (state.busy || !state.selectedOverride) return;
await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`));
}
async function mutate(operation) {
setBusy(true);
hideAlert('schedule-override-alert');
try {
await operation();
await onChanged?.();
state.selectedLesson = null;
state.selectedOverride = null;
state.source = null;
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.';
openRegistry();
} catch (error) {
showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error');
} finally {
setBusy(false);
}
}
function setBusy(busy) {
state.busy = busy;
saveButton.disabled = busy;
cancelButton.disabled = busy;
restoreButton.disabled = busy;
saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить';
}
function renderRegistry() {
if (!registry) return;
if (!state.overrides.length) {
registry.innerHTML = '<div class="schedule-override-empty">За выбранный период разовых изменений нет.</div>';
return;
}
registry.innerHTML = state.overrides.map(override => {
const target = override.targetLessonDate
? ` → ${formatDate(override.targetLessonDate)}`
: '';
const result = override.action === 'CANCEL'
? 'Занятие отменено'
: [
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
teacherName(override.newTeacherId),
classroomName(override.newClassroomId),
override.newLessonFormat
].filter(Boolean).join(' · ');
return `
<article class="schedule-override-registry-item">
<div class="schedule-override-registry-topline">
<span class="schedule-override-action action-${escapeHtml(override.action.toLowerCase())}">${escapeHtml(ACTION_LABELS[override.action] || override.action)}</span>
<time>${escapeHtml(formatDate(override.lessonDate))}${escapeHtml(target)}</time>
</div>
<h4>${escapeHtml(override.subjectName || 'Занятие')}</h4>
<p>${escapeHtml([
override.lessonTypeName,
(override.groupNames || []).join(', '),
result || 'Параметры изменены'
].filter(Boolean).join(' · '))}</p>
${override.comment ? `<blockquote>${escapeHtml(override.comment)}</blockquote>` : ''}
<button type="button" class="btn btn-md btn-secondary" data-override-registry-id="${escapeHtml(override.id)}">Открыть</button>
</article>
`;
}).join('');
}
function activateTab(tab) {
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.overrideTab === tab);
});
drawer.querySelectorAll('[data-override-section]').forEach(section => {
section.hidden = section.dataset.overrideSection !== tab;
});
}
function open() {
drawer.hidden = false;
drawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('schedule-override-open');
}
function close() {
drawer.hidden = true;
drawer.setAttribute('aria-hidden', 'true');
document.body.classList.remove('schedule-override-open');
}
function teacherName(id) {
if (id == null || id === '') return '';
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
return teacher?.fullName || teacher?.username || '';
}
function classroomName(id) {
if (id == null || id === '') return '';
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
}
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
if (!startDate || !endDate) return '';
const start = parseIsoDate(startDate);
const end = parseIsoDate(endDate);
const months = [];
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
cursor <= end;
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(renderMonth(cursor, startDate, endDate, available, selectedDate));
}
return months.join('');
}
function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) {
const year = month.getFullYear();
const monthIndex = month.getMonth();
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const blanks = Array.from({ length: firstDay - 1 }, () => '<span class="schedule-override-calendar-blank"></span>').join('');
const days = Array.from({ length: dayCount }, (_, index) => {
const date = toIsoDate(new Date(year, monthIndex, index + 1));
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
const active = date === selectedDate ? ' active' : '';
return `<button type="button" class="schedule-override-calendar-day${active}" data-override-date="${date}"${enabled ? '' : ' disabled'}>${index + 1}</button>`;
}).join('');
return `
<section class="schedule-override-month">
<h4>${MONTH_LABELS[monthIndex]} ${year}</h4>
<div class="schedule-override-calendar-grid">
${DAY_LABELS.map(label => `<strong>${label}</strong>`).join('')}
${blanks}${days}
</div>
</section>
`;
}
function timeSlotLabel(slot) {
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
return `${order}${intervalLabel(slot.startTime, slot.endTime)}`;
}
function intervalLabel(start, end) {
return `${trimTime(start)}–${trimTime(end)}`;
}
export function classroomOptionLabel(room) {
return room?.name || '';
}
function ensureSelectOption(select, value, label) {
if (value == null || value === '') return;
const exists = Array.from(select.options).some(option => String(option.value) === String(value));
if (exists) return;
const option = document.createElement('option');
option.value = String(value);
option.textContent = label;
select.append(option);
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}

View File

@@ -1,358 +0,0 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.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 };
root.querySelector('#quality-run')?.addEventListener('click', runAnalysis);
root.querySelector('#quality-semester')?.addEventListener('change', loadVersions);
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
await loadSemesters();
}
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>';
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;
}
await loadVersions();
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function loadVersions() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const select = qualityState?.root.querySelector('#quality-version');
if (!select || !semesterId) return;
select.innerHTML = '<option value="">Загрузка версий…</option>';
try {
qualityState.versions = await api.get(
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
);
select.innerHTML = qualityState.versions.map(version => `
<option value="${escapeHtml(String(version.id))}">
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
</option>
`).join('');
if (!qualityState.versions.length) {
select.innerHTML = '<option value="">Версии не созданы</option>';
return;
}
const savedVersionId = localStorage.getItem('magistr.quality.versionId');
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.semesterId', semesterId);
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
}
}
async function runAnalysis() {
const semesterId = qualityState?.root.querySelector('#quality-semester')?.value;
const versionId = qualityState?.root.querySelector('#quality-version')?.value;
if (!semesterId) {
qualityStatus('Выберите семестр для анализа.', 'warning');
return;
}
if (!versionId) {
qualityStatus('Выберите версию расписания для анализа.', 'warning');
return;
}
localStorage.setItem('magistr.quality.semesterId', semesterId);
localStorage.setItem('magistr.quality.versionId', versionId);
const button = qualityState.root.querySelector('#quality-run');
button.disabled = true;
button.textContent = 'Анализируем…';
qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading');
qualityState.root.querySelector('#quality-results').hidden = true;
try {
qualityState.analysis = await api.get(
`/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}`
);
qualityState.selectedProblemId = null;
renderAnalysis();
qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
'success'
);
} catch (error) {
qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error');
} finally {
button.disabled = false;
button.textContent = 'Выполнить анализ';
}
}
function renderAnalysis() {
const analysis = qualityState?.analysis;
if (!analysis) return;
qualityState.root.querySelector('#quality-results').hidden = false;
const band = qualityBand(analysis.score);
const orbit = qualityState.root.querySelector('#quality-score-orbit');
orbit.dataset.band = band.code;
qualityState.root.querySelector('#quality-score-ring-value')
.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`);
qualityState.root.querySelector('#quality-score').textContent = String(analysis.score);
qualityState.root.querySelector('#quality-score-band').textContent = band.label;
qualityState.root.querySelector('#quality-score-band').dataset.band = band.code;
qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel;
qualityState.root.querySelector('#quality-score-summary').textContent = band.summary;
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
`;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
.map((metric, index) => `
<article class="quality-metric quality-metric-${index + 1}">
<span class="quality-metric-index">${String(index + 1).padStart(2, '0')}</span>
<strong>${escapeHtml(formatMetricValue(metric))}</strong>
<h4>${escapeHtml(metric.label)}</h4>
<p>${escapeHtml(metric.explanation)}</p>
<span class="quality-metric-penalty">штраф ${escapeHtml(String(metric.penalty))}</span>
</article>
`).join('');
const totalLabel = analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
: `Правил с проблемами: ${analysis.totalProblemCount}`;
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
renderProblems();
renderRulePanelEmpty();
}
function renderProblems() {
const container = qualityState?.root.querySelector('#quality-problems');
if (!container || !qualityState.analysis) return;
const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || '';
const category = qualityState.root.querySelector('#quality-type-filter')?.value || '';
qualityState.visibleProblems = filterQualityProblems(
qualityState.analysis.problems || [], severity, category
);
if (!qualityState.visibleProblems.length) {
container.innerHTML = `
<div class="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
`;
return;
}
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
const selected = problem.id === qualityState.selectedProblemId;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
return `
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
<button type="button" class="quality-problem-main" data-problem-index="${index}">
<span class="quality-severity">${escapeHtml(severityLabel(problem.severity))}</span>
<span class="quality-problem-copy">
<strong>${escapeHtml(problem.title)}</strong>
<span>${escapeHtml(problem.description)}</span>
<span class="quality-rule-issues">${issueLabels.map(label => `
<span>${escapeHtml(label)}</span>
`).join('')}</span>
<small>
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
· затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}
· суммарный штраф
</small>
</span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button>
<div class="quality-problem-action">
${canEdit
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
Изменить правило
</button>`
: '<span>Только просмотр</span>'}
</div>
</article>
`;
}).join('');
}
function handleProblemClick(event) {
const button = event.target.closest('[data-problem-index]');
if (!button) return;
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
if (!problem) return;
if (button.dataset.action === 'edit-rule') {
openRuleInConstructor(problem);
return;
}
qualityState.selectedProblemId = problem.id;
renderProblems();
renderRuleDetails(problem);
}
function renderRulePanelEmpty() {
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
renderRulePanelMessage(
archived ? 'Архивная версия' : 'Работа с правилом',
archived
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
);
}
function renderRuleDetails(problem) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
panel.innerHTML = `
<div class="quality-recommendations-head">
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
<p>Затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
</div>
<div class="quality-rule-detail-list">
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
</div>
${canEdit ? `
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
Открыть правило в конструкторе
</button>
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
`;
}
function handleRulePanelClick(event) {
const button = event.target.closest('[data-edit-rule-id]');
if (!button) return;
const problem = (qualityState?.analysis?.problems || [])
.find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
if (problem) openRuleInConstructor(problem);
}
function openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
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();
}
function renderRulePanelMessage(title, message) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
panel.innerHTML = `
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(message)}</p>
</div>
`;
}
function qualityStatus(message, tone = '') {
const status = qualityState?.root.querySelector('#quality-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity;
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches;
});
}
function problemCategory(type = '') {
if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS';
if (type.includes('LOAD')) return 'LOAD';
if (type.includes('ROOM')) return 'ROOM';
return 'PREFERENCE';
}
export function qualityBand(score) {
if (score >= 90) {
return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' };
}
if (score >= 75) {
return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' };
}
if (score >= 55) {
return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' };
}
return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' };
}
export function formatMetricValue(metric) {
const value = Number(metric?.value || 0);
const formatted = Number.isInteger(value)
? String(value)
: value.toLocaleString('ru-RU', { maximumFractionDigits: 1 });
return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`;
}
function severityLabel(severity) {
return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`;
}
function formatDate(value) {
if (!value) return 'дата не указана';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function localIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function lessonWord(count) {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
if (mod10 === 1) return 'занятие';
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
return 'занятий';
}
export function versionStatusLabel(status) {
return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия';
}

View File

@@ -1,424 +0,0 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
let versionState = null;
export async function initScheduleVersions() {
const root = document.querySelector('.version-studio');
if (!root) return;
versionState = {
root,
semesters: [],
versions: [],
history: [],
modal: null,
loading: false
};
bindEvents();
await loadSemesters();
}
function bindEvents() {
const root = versionState.root;
root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace);
root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal());
root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-archive')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-current')?.addEventListener('click', handleVersionAction);
root.querySelector('#version-modal-close')?.addEventListener('click', closeModal);
root.querySelector('#version-modal-cancel')?.addEventListener('click', closeModal);
root.querySelector('#version-modal')?.addEventListener('click', event => {
if (event.target.id === 'version-modal') closeModal();
});
root.querySelector('#version-modal-form')?.addEventListener('submit', submitModal);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !root.querySelector('#version-modal')?.hidden) closeModal();
});
}
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>';
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);
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Не удалось загрузить семестры.', 'error');
}
}
async function loadWorkspace() {
const semesterId = selectedSemesterId();
if (!semesterId || versionState.loading) return;
versionState.loading = true;
localStorage.setItem('magistr.schedule.semesterId', semesterId);
setStatus('Собираем версии, правила и журнал публикаций…', 'loading');
try {
[versionState.versions, versionState.history] = await Promise.all([
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`),
api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`)
]);
renderWorkspace();
setStatus('Контур версий синхронизирован.', 'success');
} catch (error) {
setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error');
} finally {
versionState.loading = false;
}
}
function renderWorkspace() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
const drafts = versionState.versions.filter(version => version.status === 'DRAFT');
const archive = versionState.versions.filter(version => version.status === 'ARCHIVED');
renderCurrent(published);
renderDrafts(drafts);
renderArchive(archive);
renderHistory(versionState.history);
}
function renderCurrent(version) {
const container = versionState.root.querySelector('#version-current');
if (!version) {
container.innerHTML = `
<div class="version-live-mark">ПУСТО</div>
<div><h3>Опубликованной версии нет</h3><p>Создайте первый черновик и опубликуйте его после проверки.</p></div>
`;
return;
}
container.innerHTML = `
<div class="version-live-mark">LIVE</div>
<div>
<span class="version-eyebrow">Сейчас видят пользователи</span>
<h3>${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}</h3>
<p>${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}</p>
<div class="version-stats">
<span>${escapeHtml(String(version.ruleCount))} правил</span>
<span>${escapeHtml(String(version.slotCount))} слотов</span>
<span>${escapeHtml(formatDateTime(version.publishedAt))}</span>
</div>
</div>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
</div>
`;
}
function renderDrafts(drafts) {
const container = versionState.root.querySelector('#version-drafts');
versionState.root.querySelector('#version-draft-count').textContent = String(drafts.length);
if (!drafts.length) {
container.innerHTML = `
<div class="version-empty">
<span>+</span><h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
`;
return;
}
container.innerHTML = drafts.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<div class="version-card-top">
<div>
<span class="version-status-chip">Черновик · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
</div>
</div>
<p>Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="publish" data-id="${version.id}">Опубликовать</button>
</div>
</article>
`).join('');
}
function renderArchive(versions) {
const container = versionState.root.querySelector('#version-archive');
if (!versions.length) {
container.innerHTML = '<p class="version-empty-note">Предыдущих публикаций пока нет.</p>';
return;
}
container.innerHTML = versions.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<span class="version-status-chip">Архив · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
<p>${escapeHtml(version.changeReason || 'Причина не указана')}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-danger" data-action="restore" data-id="${version.id}">Восстановить</button>
</div>
</article>
`).join('');
}
function renderHistory(history) {
const container = versionState.root.querySelector('#version-history');
if (!history.length) {
container.innerHTML = '<p class="version-empty-note">Действия ещё не зафиксированы.</p>';
return;
}
container.innerHTML = history.map(item => `
<article class="version-history-row">
<time>${escapeHtml(formatDateTime(item.createdAt))}</time>
<div>
<strong>${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)}</strong>
<p>${escapeHtml(item.reason || 'Без комментария')}</p>
</div>
<span class="version-status-chip">${escapeHtml(item.actorName || 'Система')}</span>
</article>
`).join('');
}
async function handleVersionAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const version = versionState.versions.find(item => String(item.id) === button.dataset.id);
if (!version) return;
const action = button.dataset.action;
if (action === 'edit') {
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
navigateTo('schedule');
return;
}
if (action === 'quality') {
localStorage.setItem('magistr.quality.semesterId', String(version.semesterId));
localStorage.setItem('magistr.quality.versionId', String(version.id));
navigateTo('schedule-quality');
return;
}
if (action === 'diff') {
await loadDiff(version);
return;
}
if (action === 'publish') {
await openPublishModal(version);
return;
}
if (action === 'restore') {
openActionModal('restore', version);
}
}
async function loadDiff(version) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = '<div class="version-empty"><span>…</span><h3>Сравниваем расписания</h3><p>Генерируем занятия обеих версий по всему семестру.</p></div>';
try {
const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`);
renderDiff(diff);
} catch (error) {
panel.innerHTML = `<div class="version-empty"><span>!</span><h3>Сравнение недоступно</h3><p>${escapeHtml(error.message)}</p></div>`;
}
}
function renderDiff(diff) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = `
<span class="version-eyebrow">${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)}</span>
<h3>Что изменится после публикации</h3>
<div class="version-diff-score">
<div><strong>+${escapeHtml(String(diff.addedLessonCount))}</strong><span>занятий</span></div>
<div><strong>${escapeHtml(String(diff.changedLessonCount))}</strong><span>изменено</span></div>
<div><strong>−${escapeHtml(String(diff.removedLessonCount))}</strong><span>удалено</span></div>
</div>
<p class="version-diff-caption">Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}</p>
<div class="version-change-list">
${(diff.ruleChanges || []).length
? diff.ruleChanges.map(ruleChangeCard).join('')
: '<div class="version-empty"><h3>Правила совпадают</h3><p>Структурных изменений между версиями нет.</p></div>'}
</div>
`;
}
function ruleChangeCard(change) {
const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type;
return `
<article class="version-change" data-type="${escapeHtml(change.type)}">
<strong>${escapeHtml(label)} · ${escapeHtml(change.subjectName)}</strong>
<span>${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')}</span>
${change.beforeSummary ? `<span>Было: ${escapeHtml(change.beforeSummary)}</span>` : ''}
${change.afterSummary ? `<span>Стало: ${escapeHtml(change.afterSummary)}</span>` : ''}
</article>
`;
}
function openCreateModal() {
const published = versionState.versions.find(version => version.status === 'PUBLISHED');
openModal({
mode: 'create',
version: published,
kicker: 'Изолированная рабочая копия',
title: 'Создать новый черновик',
copy: published
? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.`
: 'Будет создан пустой черновик для первого расписания семестра.',
label: 'Название черновика',
value: '',
submit: 'Создать черновик'
});
}
async function openPublishModal(version) {
setStatus('Проверяем целостность черновика перед публикацией…', 'loading');
try {
const [validation, diff] = await Promise.all([
api.get(`/api/edu-office/schedule/versions/${version.id}/validate`),
api.get(`/api/edu-office/schedule/versions/${version.id}/diff`)
]);
const summary = validation.valid
? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.`
: `Публикация заблокирована: ${(validation.issues || []).join('; ')}`;
openModal({
mode: 'publish',
version,
valid: validation.valid,
kicker: 'Атомарная публикация',
title: `Опубликовать версию ${version.versionNumber}`,
copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.',
label: 'Причина изменений',
value: '',
submit: 'Опубликовать',
summary
});
setStatus(validation.valid ? 'Черновик готов к публикации.' : 'Черновик требует исправлений.', validation.valid ? 'success' : 'error');
} catch (error) {
setStatus(error.message || 'Не удалось проверить черновик.', 'error');
}
}
function openActionModal(mode, version) {
openModal({
mode,
version,
kicker: 'Контролируемый возврат',
title: `Восстановить версию ${version.versionNumber}`,
copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.',
label: 'Причина восстановления',
value: '',
submit: 'Восстановить'
});
}
function openModal(config) {
versionState.modal = config;
const root = versionState.root;
root.querySelector('#version-modal-kicker').textContent = config.kicker;
root.querySelector('#version-modal-title').textContent = config.title;
root.querySelector('#version-modal-copy').textContent = config.copy;
root.querySelector('#version-modal-label').textContent = config.label;
root.querySelector('#version-modal-value').value = config.value || '';
const summary = root.querySelector('#version-modal-summary');
summary.hidden = !config.summary;
summary.textContent = config.summary || '';
const submit = root.querySelector('#version-modal-submit');
submit.textContent = config.submit;
submit.disabled = config.valid === false;
root.querySelector('#version-modal').hidden = false;
root.querySelector('#version-modal-value').focus();
}
function closeModal() {
const modal = versionState?.root.querySelector('#version-modal');
if (modal) modal.hidden = true;
if (versionState) versionState.modal = null;
}
async function submitModal(event) {
event.preventDefault();
const config = versionState.modal;
if (!config || config.valid === false) return;
const value = versionState.root.querySelector('#version-modal-value').value.trim();
if (!value) {
setStatus(config.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.', 'error');
return;
}
const submit = versionState.root.querySelector('#version-modal-submit');
submit.disabled = true;
submit.textContent = 'Выполняем…';
try {
if (config.mode === 'create') {
const created = await api.post('/api/edu-office/schedule/versions', {
semesterId: Number(selectedSemesterId()),
name: value,
basedOnVersionId: config.version?.id || null
});
localStorage.setItem('magistr.schedule.versionId', String(created.id));
setStatus(`Черновик «${created.name}» создан.`, 'success');
} else {
await api.post(
`/api/edu-office/schedule/versions/${config.version.id}/${config.mode}`,
{ reason: value }
);
setStatus(config.mode === 'publish' ? 'Версия опубликована атомарно.' : 'Предыдущая версия восстановлена.', 'success');
}
closeModal();
await loadWorkspace();
} catch (error) {
setStatus(error.message || 'Операцию не удалось выполнить.', 'error');
submit.disabled = false;
submit.textContent = config.submit;
}
}
function navigateTo(tab) {
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.click();
}
function selectedSemesterId() {
return versionState?.root.querySelector('#version-semester')?.value || '';
}
function setStatus(message, tone = '') {
const status = versionState?.root.querySelector('#version-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
export function historyActionLabel(action) {
return {
CREATED: 'Создан черновик',
PUBLISHED: 'Опубликовано',
ARCHIVED: 'Архивировано',
RESTORED: 'Восстановлено'
}[action] || action;
}
function semesterLabel(semester) {
const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний';
return `${semester.academicYearTitle} · ${type} семестр`;
}
function formatDateTime(value) {
if (!value) return 'дата не указана';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
}).format(new Date(value));
}
function localIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,263 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.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 modalManageTeachers = document.getElementById('modal-manage-teachers');
const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close');
const subjectTeachersContext = document.getElementById('subject-teachers-context');
const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form');
const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id');
const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select');
const manageTsTbody = document.getElementById('manage-ts-tbody');
let allSubjects = [];
let allTeachers = [];
let allDepartments = [];
let teacherSubjects = [];
bindEvents();
await loadInitialData();
function bindEvents() {
createSubjectForm.addEventListener('submit', createSubject);
subjectsTbody.addEventListener('click', handleSubjectsTableClick);
manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment);
manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick);
modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open'));
modalManageTeachers.addEventListener('click', (e) => {
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
});
}
async function loadInitialData() {
try {
[allTeachers, allDepartments] = await Promise.all([
api.get('/api/users/teachers'),
api.get('/api/departments')
]);
populateDepartmentsSelect();
await loadSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
}
}
function populateDepartmentsSelect() {
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
allDepartments.map(d => {
const name = d.departmentName || d.name || 'Без названия';
const code = d.departmentCode || d.code || d.id;
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
}).join('');
}
async function loadSubjects() {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try {
[allSubjects, teacherSubjects] = await Promise.all([
api.get('/api/subjects'),
api.get('/api/teacher-subjects')
]);
renderSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
}
}
function renderSubjects() {
if (!allSubjects || !allSubjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
return;
}
// Группируем преподавателей по дисциплинам
const teachersBySubject = new Map();
teacherSubjects.forEach(ts => {
if (!teachersBySubject.has(ts.subjectId)) {
teachersBySubject.set(ts.subjectId, []);
}
teachersBySubject.get(ts.subjectId).push(ts);
});
subjectsTbody.innerHTML = allSubjects.map(s => {
const teachers = teachersBySubject.get(s.id) || [];
const teachersHtml = teachers.length
? teachers.map(t => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
return `
<tr>
<td>${s.id}</td>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td>${escapeHtml(s.code || '-')}</td>
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
<td style="text-align: right; white-space: nowrap;">
<button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
<button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
</td>
</tr>
`;
}).join('');
}
async function createSubject(e) {
e.preventDefault();
hideAlert('create-subject-alert');
const name = document.getElementById('new-subject-name').value.trim();
const code = document.getElementById('new-subject-code').value.trim();
const departmentId = newSubjectDeptSelect.value;
if (!name || !code || !departmentId) {
showAlert('create-subject-alert', 'Заполните все поля', 'error');
return;
}
try {
const data = await api.post('/api/subjects', {
name,
code,
departmentId: Number(departmentId)
});
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success');
createSubjectForm.reset();
await loadSubjects();
} catch (error) {
showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error');
}
}
async function handleSubjectsTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-subject');
const btnTeachers = e.target.closest('.btn-manage-teachers');
if (btnDelete) {
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return;
try {
await api.delete('/api/subjects/' + btnDelete.dataset.id);
await loadSubjects();
} catch (error) {
alert(error.message || 'Ошибка удаления');
}
}
if (btnTeachers) {
const subjectId = btnTeachers.dataset.id;
const subject = allSubjects.find(s => s.id == subjectId);
if (subject) openTeachersModal(subject);
}
}
// --- Управление привязками преподавателей к дисциплине ---
function openTeachersModal(subject) {
subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
manageTsSubjectIdInput.value = subject.id;
hideAlert('manage-ts-alert');
modalManageTeachers.classList.add('open');
renderSubjectTeachers(subject.id);
populateTeachersSelect(subject.id);
}
function renderSubjectTeachers(subjectId) {
const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId));
if (!list.length) {
manageTsTbody.innerHTML = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
return;
}
manageTsTbody.innerHTML = list.map(ts => `
<tr>
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
<td>
<button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
</td>
</tr>
`).join('');
}
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('');
}
async function saveTeacherAssignment(e) {
e.preventDefault();
hideAlert('manage-ts-alert');
const subjectId = manageTsSubjectIdInput.value;
const userId = manageTsTeacherSelect.value;
if (!userId) {
showAlert('manage-ts-alert', 'Выберите преподавателя', 'error');
return;
}
try {
await api.post('/api/teacher-subjects', {
userId: Number(userId),
subjectId: Number(subjectId)
});
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
// Перезагрузим привязки и обновим интерфейс
teacherSubjects = await api.get('/api/teacher-subjects');
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Также обновим главную таблицу
renderSubjects();
} catch (error) {
showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error');
}
}
async function handleTeacherAssignmentTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-ts');
if (!btnDelete) return;
if (!confirm('Удалить привязку преподавателя?')) return;
const subjectId = manageTsSubjectIdInput.value;
try {
await api.delete('/api/teacher-subjects', {
userId: Number(btnDelete.dataset.userId),
subjectId: Number(btnDelete.dataset.subjectId)
});
// Перезагрузим привязки
teacherSubjects = await api.get('/api/teacher-subjects');
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Обновим главную таблицу
renderSubjects();
} catch (error) {
alert(error.message || 'Ошибка удаления привязки');
}
}
// --- Вспомогательные функции ---
function departmentLabel(departmentId) {
const department = allDepartments.find(item => item.id == departmentId);
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
}

View File

@@ -1,562 +0,0 @@
import { api, getSession } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
export async function initTeacherAbsences() {
const session = getSession();
const role = session?.role;
if (!MANAGER_ROLES.has(role)) return;
const form = document.getElementById('teacher-absence-form');
const teacherSelect = document.getElementById('teacher-absence-teacher');
const startInput = document.getElementById('teacher-absence-start');
const endInput = document.getElementById('teacher-absence-end');
const reasonInput = document.getElementById('teacher-absence-reason');
const statusFilter = document.getElementById('teacher-absence-status-filter');
const registry = document.getElementById('teacher-absence-registry');
const overlay = document.getElementById('absence-wizard-overlay');
const wizardLessons = document.getElementById('absence-wizard-lessons');
const preferenceSemesterFilter = document.getElementById('teacher-preference-semester-filter');
const preferenceStatusFilter = document.getElementById('teacher-preference-status-filter');
const preferenceReviewList = document.getElementById('teacher-preference-review-list');
const changeStatusFilter = document.getElementById('teacher-change-status-filter');
const changeReviewList = document.getElementById('teacher-change-review-list');
let absences = [];
let currentWizard = null;
let preferenceItems = [];
let changeItems = [];
setDefaultPeriod(startInput, endInput);
form?.addEventListener('submit', createAbsence);
statusFilter?.addEventListener('change', loadAbsences);
document.getElementById('teacher-absence-refresh')?.addEventListener('click', loadAbsences);
document.getElementById('absence-wizard-close')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-cancel')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-apply')?.addEventListener('click', applyWizard);
overlay?.addEventListener('click', event => {
if (event.target === overlay) closeWizard();
});
registry?.addEventListener('click', handleRegistryAction);
preferenceSemesterFilter?.addEventListener('change', loadPreferenceReviews);
preferenceStatusFilter?.addEventListener('change', loadPreferenceReviews);
document.getElementById('teacher-preference-refresh')?.addEventListener('click', loadPreferenceReviews);
preferenceReviewList?.addEventListener('click', handlePreferenceReview);
changeStatusFilter?.addEventListener('change', loadChangeReviews);
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>`;
}
}
async function loadAbsences() {
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
try {
const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
absences = await api.get(`/api/teacher-absences${params}`);
renderRegistry();
updateStats(absences);
} catch (error) {
registry.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}</div>`;
}
}
async function createAbsence(event) {
event.preventDefault();
hideAlert('teacher-absence-form-alert');
if (!teacherSelect.value || !startInput.value || !endInput.value || !reasonInput.value.trim()) {
showAlert('teacher-absence-form-alert', 'Заполните преподавателя, период и причину', 'error');
return;
}
if (endInput.value < startInput.value) {
showAlert('teacher-absence-form-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
}
try {
await api.post('/api/teacher-absences', {
teacherId: Number(teacherSelect.value),
startDate: startInput.value,
endDate: endInput.value,
reason: reasonInput.value.trim()
});
showAlert('teacher-absence-form-alert', 'Отсутствие зарегистрировано', 'success');
reasonInput.value = '';
await loadAbsences();
} catch (error) {
showAlert('teacher-absence-form-alert', error.message || 'Не удалось зарегистрировать отсутствие', 'error');
}
}
function renderRegistry() {
if (!absences.length) {
registry.innerHTML = '<div class="absence-empty"><strong>Инцидентов не найдено</strong><span>Измените фильтр или зарегистрируйте отсутствие.</span></div>';
return;
}
registry.innerHTML = absences.map(item => `
<article class="absence-registry-item" data-status="${escapeHtml(item.status)}">
<div class="absence-date-rail">
<strong>${escapeHtml(dayOfMonth(item.startDate))}</strong>
<span>${escapeHtml(monthShort(item.startDate))}</span>
<i></i>
<strong>${escapeHtml(dayOfMonth(item.endDate))}</strong>
<span>${escapeHtml(monthShort(item.endDate))}</span>
</div>
<div class="absence-registry-main">
<div class="absence-registry-title">
<div>
<h3>${escapeHtml(item.teacherName || 'Преподаватель не указан')}</h3>
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
</div>
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(statusLabel(item.status))}</span>
</div>
<div class="absence-meta-row">
<span>${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))}</span>
<span>${escapeHtml(decisionCountLabel(item.decisionCount || 0))}</span>
${item.reviewComment ? `<span>${escapeHtml(item.reviewComment)}</span>` : ''}
</div>
</div>
<div class="absence-registry-buttons">
${item.status === 'PENDING' ? `
<button type="button" class="btn btn-sm btn-primary" data-action="approve" data-id="${item.id}">Подтвердить</button>
<button type="button" class="btn btn-sm btn-danger-subtle" data-action="reject" data-id="${item.id}">Отклонить</button>
` : ''}
${['APPROVED', 'RESOLVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-secondary" data-action="wizard" data-id="${item.id}">
${item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
</button>
` : ''}
${['PENDING', 'APPROVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-ghost" data-action="cancel" data-id="${item.id}">Отменить</button>
` : ''}
</div>
</article>
`).join('');
}
async function handleRegistryAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const id = Number(button.dataset.id);
button.disabled = true;
try {
if (button.dataset.action === 'approve' || button.dataset.action === 'reject') {
await api.post(`/api/teacher-absences/${id}/review`, {
approved: button.dataset.action === 'approve',
comment: button.dataset.action === 'approve'
? 'Подтверждено кафедрой или ответственным сотрудником'
: 'Заявка отклонена ответственным сотрудником'
});
await loadAbsences();
} else if (button.dataset.action === 'cancel') {
await api.delete(`/api/teacher-absences/${id}`);
await loadAbsences();
} else if (button.dataset.action === 'wizard') {
await openWizard(id);
}
} catch (error) {
window.alert(error.message || 'Не удалось выполнить действие');
} finally {
button.disabled = false;
}
}
async function openWizard(id) {
overlay.hidden = false;
document.body.classList.add('absence-wizard-open');
wizardLessons.innerHTML = '<div class="loading-row">Подбираем свободные ресурсы...</div>';
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.get(`/api/teacher-absences/${id}/wizard`);
renderWizard();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось открыть мастер замены', 'error');
}
}
function closeWizard() {
overlay.hidden = true;
document.body.classList.remove('absence-wizard-open');
currentWizard = null;
}
function renderWizard() {
const absence = currentWizard.absence;
document.getElementById('absence-wizard-title').textContent = absence.teacherName || 'Мастер замены';
document.getElementById('absence-wizard-summary').textContent =
`${formatDate(absence.startDate)} — ${formatDate(absence.endDate)} · ${absence.reason}`;
const applyButton = document.getElementById('absence-wizard-apply');
applyButton.hidden = absence.status !== 'APPROVED';
wizardLessons.innerHTML = currentWizard.lessons.length
? currentWizard.lessons.map((lesson, index) => renderLessonDecision(lesson, index, absence.status)).join('')
: '<div class="absence-empty"><strong>Затронутых занятий нет</strong><span>Инцидент можно завершить без точечных изменений.</span></div>';
renderJournal(currentWizard.journal || []);
}
function renderLessonDecision(lesson, index, absenceStatus) {
const options = decisionOptions(lesson);
const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus);
return `
<article class="absence-lesson" data-index="${index}" data-slot-id="${lesson.baseRuleSlotId}" data-date="${lesson.lessonDate}">
<div class="absence-lesson-time">
<strong>${escapeHtml(trimTime(lesson.startTime))}</strong>
<span>${escapeHtml(trimTime(lesson.endTime))}</span>
<small>${escapeHtml(formatDate(lesson.lessonDate))}</small>
</div>
<div class="absence-lesson-copy">
<span class="absence-lesson-type">${escapeHtml(lesson.lessonTypeName || 'Занятие')}</span>
<h3>${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}</h3>
<p>${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}</p>
${lesson.existingOverrideId ? '<small class="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>' : ''}
</div>
<div class="absence-lesson-decision">
<label for="absence-decision-${index}">Решение</label>
<select id="absence-decision-${index}" class="absence-decision-select" ${locked ? 'disabled' : ''}>
<option value="">Оставить без изменений</option>
${options.map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`).join('')}
</select>
${lesson.decisionStatus ? `<span class="absence-decision-fixed">${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}</span>` : ''}
</div>
</article>
`;
}
async function applyWizard() {
if (!currentWizard) return;
const decisions = Array.from(wizardLessons.querySelectorAll('.absence-lesson')).map(card => {
const value = card.querySelector('.absence-decision-select')?.value;
if (!value) return null;
return {
baseRuleSlotId: Number(card.dataset.slotId),
lessonDate: card.dataset.date,
...parseDecisionOption(value)
};
}).filter(Boolean);
if (!decisions.length && currentWizard.lessons.length) {
showAlert('absence-wizard-alert', 'Выберите хотя бы одно решение', 'error');
return;
}
const button = document.getElementById('absence-wizard-apply');
button.disabled = true;
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.post(`/api/teacher-absences/${currentWizard.absence.id}/resolve`, { decisions });
renderWizard();
showAlert('absence-wizard-alert', 'Выбранные решения применены к расписанию', 'success');
await loadAbsences();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось применить решения', 'error');
} finally {
button.disabled = false;
}
}
async function loadPreferenceMeta() {
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка семестров...</div>';
try {
const meta = await api.get('/api/teacher-preferences/meta');
preferenceSemesterFilter.innerHTML = '<option value="">Все семестры</option>' + (meta.semesters || []).map(item => `
<option value="${item.id}">${escapeHtml(semesterLabel(item))}</option>
`).join('');
const today = isoDate(new Date());
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
if (current) preferenceSemesterFilter.value = String(current.id);
await loadPreferenceReviews();
} catch (error) {
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить семестры')}</div>`;
}
}
async function loadPreferenceReviews() {
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка пожеланий...</div>';
const params = new URLSearchParams();
if (preferenceSemesterFilter.value) params.set('semesterId', preferenceSemesterFilter.value);
if (preferenceStatusFilter.value) params.set('status', preferenceStatusFilter.value);
try {
preferenceItems = await api.get(`/api/teacher-preferences${params.size ? `?${params}` : ''}`);
document.getElementById('teacher-preference-pending-count').textContent = String(
preferenceItems.filter(item => item.status === 'PENDING').length
);
renderPreferenceReviews();
} catch (error) {
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить пожелания')}</div>`;
}
}
function renderPreferenceReviews() {
preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => `
<article class="teacher-dialogue-item">
<span class="teacher-dialogue-mark preference-${escapeHtml(item.preferenceType.toLowerCase())}"></span>
<div class="teacher-dialogue-copy">
<small>${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')}</small>
<h3>${escapeHtml(item.teacherName)}</h3>
<p>${escapeHtml(preferenceDescription(item))}</p>
${item.comment ? `<blockquote>${escapeHtml(item.comment)}</blockquote>` : ''}
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
</div>
<div class="teacher-dialogue-action">
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
<div>
<button class="btn btn-sm btn-primary" type="button" data-preference-review="approve" data-id="${item.id}">Согласовать</button>
<button class="btn btn-sm btn-danger-subtle" type="button" data-preference-review="reject" data-id="${item.id}">Отклонить</button>
</div>
` : ''}
</div>
</article>
`).join('') : '<div class="absence-empty"><strong>Пожеланий не найдено</strong><span>Измените фильтры или дождитесь новых отметок преподавателей.</span></div>';
}
async function handlePreferenceReview(event) {
const button = event.target.closest('[data-preference-review][data-id]');
if (!button) return;
const card = button.closest('.teacher-dialogue-item');
const approved = button.dataset.preferenceReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
window.alert('При отклонении укажите комментарий');
return;
}
button.disabled = true;
try {
await api.post(`/api/teacher-preferences/${button.dataset.id}/review`, {
approved,
comment: comment || 'Согласовано ответственным сотрудником'
});
await loadPreferenceReviews();
} catch (error) {
window.alert(error.message || 'Не удалось рассмотреть пожелание');
button.disabled = false;
}
}
async function loadChangeReviews() {
changeReviewList.innerHTML = '<div class="loading-row">Загрузка заявок...</div>';
const params = changeStatusFilter.value ? `?status=${encodeURIComponent(changeStatusFilter.value)}` : '';
try {
changeItems = await api.get(`/api/teacher-change-requests${params}`);
document.getElementById('teacher-change-pending-count').textContent = String(
changeItems.filter(item => item.status === 'PENDING').length
);
renderChangeReviews();
} catch (error) {
changeReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить заявки')}</div>`;
}
}
function renderChangeReviews() {
const canApply = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
changeReviewList.innerHTML = changeItems.length ? changeItems.map(item => {
const target = item.requestType === 'MOVE'
? `${formatDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`
: item.requestType === 'CHANGE_CLASSROOM' ? item.requestedClassroomName : 'Отмена занятия';
return `<article class="teacher-dialogue-item change-item">
<span class="teacher-dialogue-mark change-${escapeHtml(item.requestType.toLowerCase())}"></span>
<div class="teacher-dialogue-copy">
<small>${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)}</small>
<h3>${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}</h3>
<p>${escapeHtml(changeTypeLabel(item.requestType))} → <strong>${escapeHtml(target)}</strong></p>
<blockquote>${escapeHtml(item.reason)}</blockquote>
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
<details><summary>История · ${(item.history || []).length}</summary>
<ol>${(item.history || []).map(entry => `<li><b>${genericStatusLabel(entry.status)}</b> · ${escapeHtml(entry.comment || 'Без комментария')}</li>`).join('')}</ol>
</details>
</div>
<div class="teacher-dialogue-action">
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
${item.status === 'PENDING' && canApply ? `
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
<div>
<button class="btn btn-sm btn-primary" type="button" data-change-review="approve" data-id="${item.id}">Применить</button>
<button class="btn btn-sm btn-danger-subtle" type="button" data-change-review="reject" data-id="${item.id}">Отклонить</button>
</div>
` : item.status === 'PENDING' ? '<small>Применение доступно учебному отделу</small>' : ''}
</div>
</article>`;
}).join('') : '<div class="absence-empty"><strong>Заявок не найдено</strong><span>Очередь изменений сейчас пуста.</span></div>';
}
async function handleChangeReview(event) {
const button = event.target.closest('[data-change-review][data-id]');
if (!button) return;
const card = button.closest('.teacher-dialogue-item');
const approved = button.dataset.changeReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
window.alert('При отклонении укажите комментарий');
return;
}
button.disabled = true;
try {
await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) {
window.alert(error.message || 'Не удалось применить решение');
button.disabled = false;
}
}
}
export function decisionOptions(lesson) {
const options = [];
(lesson.teacherCandidates || []).forEach(candidate => options.push({
value: `REPLACE_TEACHER|${candidate.id}`,
label: `Заменить: ${candidate.name}`
}));
(lesson.moveCandidates || []).forEach(candidate => options.push({
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
label: `Перенести: ${formatDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
}));
(lesson.classroomCandidates || []).forEach(candidate => options.push({
value: `CHANGE_CLASSROOM|${candidate.id}`,
label: `Другая аудитория: ${candidate.name}`
}));
if (lesson.cancellationAvailable) {
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
}
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
return options;
}
export function parseDecisionOption(value) {
const [resolution, first, second] = String(value || '').split('|');
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
if (resolution === 'MOVE_TIME') {
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
}
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
throw new Error('Неизвестное решение мастера замены');
}
function renderJournal(journal) {
document.getElementById('absence-journal-count').textContent = decisionCountLabel(journal.length);
document.getElementById('absence-journal-list').innerHTML = journal.length ? journal.map(item => `
<article>
<span class="absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}"></span>
<div><strong>${escapeHtml(resolutionLabel(item.resolution))}</strong><small>${escapeHtml(formatDate(item.lessonDate))}</small></div>
<span>${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''}</span>
</article>
`).join('') : '<div class="loading-row">Решений пока нет</div>';
}
function updateStats(items) {
document.getElementById('absence-stat-active').textContent = items.filter(item => item.status === 'APPROVED').length;
document.getElementById('absence-stat-pending').textContent = items.filter(item => item.status === 'PENDING').length;
document.getElementById('absence-stat-resolved').textContent = items.filter(item => item.status === 'RESOLVED').length;
}
function setDefaultPeriod(startInput, endInput) {
const today = new Date();
startInput.value = isoDate(today);
endInput.value = isoDate(today);
}
function isoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function dayOfMonth(value) {
return String(value || '').split('-')[2] || '—';
}
function monthShort(value) {
const month = Number(String(value || '').split('-')[1]);
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function statusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Требует решения',
REJECTED: 'Отклонено',
RESOLVED: 'Разрешено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function statusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
function genericStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function semesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}–${formatDate(semester.endDate)}`;
}
function preferenceDescription(item) {
const type = {
HARD_UNAVAILABLE: 'Строгая недоступность',
SOFT_PREFERRED: 'Предпочтительное время',
SOFT_UNWANTED: 'Нежелательное время',
CONSECUTIVE: 'Пожелание «пары подряд»',
NO_GAPS: 'Пожелание «без окон»'
}[item.preferenceType] || item.preferenceType;
if (item.preferenceDate) return `${type}: ${formatDate(item.preferenceDate)}`;
if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`;
return type;
}
function changeTypeLabel(type) {
return {
MOVE: 'Перенос даты или времени',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена занятия'
}[type] || type || 'Изменение';
}
function resolutionLabel(resolution) {
return {
REPLACE_TEACHER: 'Замена преподавателя',
MOVE_TIME: 'Перенос занятия',
CHANGE_CLASSROOM: 'Замена аудитории',
CANCEL: 'Отмена занятия',
REJECT: 'Предложение отклонено'
}[resolution] || resolution || 'Решение';
}
function decisionStatusLabel(status) {
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
}
function decisionCountLabel(count) {
const value = Number(count) || 0;
const tail = value % 100;
if (tail >= 11 && tail <= 14) return `${value} решений`;
if (value % 10 === 1) return `${value} решение`;
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
return `${value} решений`;
}

View File

@@ -1,136 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.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');
let departments = [];
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
const normalizedRequests = Array.isArray(requests) ? requests : [];
renderRequests(normalizedRequests);
updateCount(normalizedRequests.length);
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
requestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50" autocomplete="username"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="password" class="teacher-request-password" placeholder="Минимум 8 символов" autocomplete="new-password"></td>
<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>
</div>
</td>
</tr>
`).join('');
}
function updateCount(count) {
if (!requestsCount) return;
requestsCount.textContent = requestCountLabel(count);
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
async function approveRequest(row, requestId) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
}
async function rejectRequest(requestId) {
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
}
refreshButton?.addEventListener('click', loadRequests);
requestsTbody.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
await approveRequest(row, requestId);
return;
}
await rejectRequest(requestId);
});
try {
await loadDepartments();
await loadRequests();
} catch (error) {
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
}
}

View File

@@ -1,534 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initUniversityStructure() {
const deptTbody = document.getElementById('departments-tbody');
const specTbody = document.getElementById('specialties-tbody');
const createDeptForm = document.getElementById('create-department-form');
const createSpecForm = document.getElementById('create-specialty-form');
const editDeptForm = document.getElementById('edit-department-form');
const editSpecForm = document.getElementById('edit-specialty-form');
const editDeptModal = document.getElementById('modal-edit-department');
const editSpecModal = document.getElementById('modal-edit-specialty');
const editDeptClose = document.getElementById('modal-edit-department-close');
const editSpecClose = document.getElementById('modal-edit-specialty-close');
// Элементы вкладок
const tabDepartments = document.getElementById('btn-tab-departments');
const tabSpecialties = document.getElementById('btn-tab-specialties');
const tabProfiles = document.getElementById('btn-tab-profiles');
const contentDepartments = document.getElementById('tab-content-departments');
const contentSpecialties = document.getElementById('tab-content-specialties');
const contentProfiles = document.getElementById('tab-content-profiles');
// Элементы модалки профилей
const profilesModal = document.getElementById('modal-manage-profiles');
const profilesModalClose = document.getElementById('modal-manage-profiles-close');
const profilesSpecContext = document.getElementById('profiles-spec-context');
const profileFormTitle = document.getElementById('profile-form-title');
const manageProfileForm = document.getElementById('manage-profile-form');
const profileIdInput = document.getElementById('manage-profile-id');
const profileSpecIdInput = document.getElementById('manage-profile-spec-id');
const profileNameInput = document.getElementById('manage-profile-name');
const profileDescInput = document.getElementById('manage-profile-description');
const btnSaveProfile = document.getElementById('btn-save-profile');
const btnCancelProfileEdit = document.getElementById('btn-cancel-profile-edit');
const profilesTbody = document.getElementById('manage-profiles-tbody');
// Элементы вкладки Профилей
const tabProfileTbody = document.getElementById('profiles-tbody');
const createProfileTabForm = document.getElementById('create-profile-tab-form');
const tabProfileIdInput = document.getElementById('tab-profile-id');
const tabProfileSpecSelect = document.getElementById('tab-profile-spec');
const tabProfileNameInput = document.getElementById('tab-profile-name');
const tabProfileDescInput = document.getElementById('tab-profile-description');
const mainProfileFormTitle = document.getElementById('main-profile-form-title');
const btnTabSaveProfile = document.getElementById('btn-tab-save-profile');
const btnTabCancelProfileEdit = document.getElementById('btn-tab-cancel-profile-edit');
let departments = [];
let specialties = [];
let currentProfiles = [];
let allProfiles = [];
// --- Логика переключения табов ---
const structureTabs = [
{ button: tabDepartments, panel: contentDepartments },
{ button: tabSpecialties, panel: contentSpecialties },
{ button: tabProfiles, panel: contentProfiles },
];
function activateStructureTab(activeButton) {
structureTabs.forEach(({ button, panel }) => {
const active = button === activeButton;
button?.classList.toggle('active', active);
button?.setAttribute('aria-selected', active ? 'true' : 'false');
if (panel) {
panel.classList.toggle('active', active);
panel.hidden = !active;
}
});
}
structureTabs.forEach(({ button }) => {
button?.addEventListener('click', () => activateStructureTab(button));
});
// --- Загрузка данных ---
async function loadData() {
// Загрузка кафедр
try {
departments = await api.get('/api/departments');
renderDepartments();
} catch (e) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
// Загрузка специальностей
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
populateSpecialtiesDropdown();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
// Загрузка всех профилей
try {
allProfiles = await api.get('/api/specialties/profiles');
renderAllProfiles();
} catch (e) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
return;
}
deptTbody.innerHTML = departments.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-department" data-id="${d.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-department" data-id="${d.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
return;
}
specTbody.innerHTML = specialties.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-specialty" data-id="${s.id}">Изменить</button>
<button class="btn btn-sm btn-secondary btn-manage-profiles" data-id="${s.id}">Профили</button>
<button class="btn btn-sm btn-danger btn-delete-specialty" data-id="${s.id}">Удалить</button>
</td>
</tr>
`).join('');
}
// --- Кафедры: CRUD ---
createDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-dept-alert');
const name = document.getElementById('dept-name').value.trim();
const code = document.getElementById('dept-code').value.trim();
if (!name || !code) {
showAlert('create-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
createDeptForm.reset();
loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
}
});
deptTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-department');
const deleteBtn = e.target.closest('.btn-delete-department');
if (editBtn) {
const department = departments.find(item => item.id == editBtn.dataset.id);
if (!department) return;
document.getElementById('edit-dept-id').value = department.id;
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
hideAlert('edit-dept-alert');
editDeptModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return;
try {
await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
}
}
});
editDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-dept-alert');
const id = document.getElementById('edit-dept-id').value;
const name = document.getElementById('edit-dept-name').value.trim();
const code = document.getElementById('edit-dept-code').value.trim();
if (!name || !code) {
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
editDeptModal.classList.remove('open');
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
}
});
// --- Специальности: CRUD ---
createSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-spec-alert');
const name = document.getElementById('spec-name').value.trim();
const code = document.getElementById('spec-code').value.trim();
if (!name || !code) {
showAlert('create-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
createSpecForm.reset();
loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
}
});
specTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-specialty');
const deleteBtn = e.target.closest('.btn-delete-specialty');
const profilesBtn = e.target.closest('.btn-manage-profiles');
if (editBtn) {
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
if (!speciality) return;
document.getElementById('edit-spec-id').value = speciality.id;
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
hideAlert('edit-spec-alert');
editSpecModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить специальность?')) return;
try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
}
}
if (profilesBtn) {
const specId = profilesBtn.dataset.id;
const speciality = specialties.find(item => item.id == specId);
if (!speciality) return;
openProfilesModal(speciality);
}
});
editSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-spec-alert');
const id = document.getElementById('edit-spec-id').value;
const name = document.getElementById('edit-spec-name').value.trim();
const code = document.getElementById('edit-spec-code').value.trim();
if (!name || !code) {
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
editSpecModal.classList.remove('open');
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
}
});
// --- Профили обучения: Логика ---
async function openProfilesModal(speciality) {
profilesSpecContext.textContent = `${speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code} · ${speciality.specialityName || speciality.name}`;
profileSpecIdInput.value = speciality.id;
resetProfileForm();
profilesModal.classList.add('open');
await loadProfiles(speciality.id);
}
async function loadProfiles(specId) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentProfiles = await api.get(`/api/specialties/${specId}/profiles`);
renderProfilesTable();
} catch (e) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
}
}
function renderProfilesTable() {
if (!currentProfiles || !currentProfiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = currentProfiles.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-profile" data-id="${p.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-profile" data-id="${p.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function resetProfileForm() {
manageProfileForm.reset();
profileIdInput.value = '';
profileFormTitle.textContent = 'Создание профиля';
btnSaveProfile.textContent = 'Создать';
btnCancelProfileEdit.hidden = true;
hideAlert('manage-profile-alert');
}
manageProfileForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-profile-alert');
const specId = profileSpecIdInput.value;
const profileId = profileIdInput.value;
const name = profileNameInput.value.trim();
const description = profileDescInput.value.trim();
if (!name) {
showAlert('manage-profile-alert', 'Введите название профиля', 'error');
return;
}
try {
if (profileId) {
// Обновление
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
showAlert('manage-profile-alert', `Профиль "${name}" обновлен`, 'success');
} else {
// Создание
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
showAlert('manage-profile-alert', `Профиль "${name}" создан`, 'success');
}
resetProfileForm();
await loadProfiles(specId);
} catch (error) {
showAlert('manage-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
}
});
profilesTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-profile');
const deleteBtn = e.target.closest('.btn-delete-profile');
const specId = profileSpecIdInput.value;
if (editBtn) {
const profile = currentProfiles.find(item => item.id == editBtn.dataset.id);
if (!profile) return;
profileIdInput.value = profile.id;
profileNameInput.value = profile.name || '';
profileDescInput.value = profile.description || '';
profileFormTitle.textContent = 'Редактирование профиля';
btnSaveProfile.textContent = 'Сохранить';
btnCancelProfileEdit.hidden = false;
hideAlert('manage-profile-alert');
return;
}
if (deleteBtn) {
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
showAlert('manage-profile-alert', 'Профиль удален', 'success');
await loadProfiles(specId);
} catch (error) {
showAlert('manage-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
btnCancelProfileEdit.addEventListener('click', resetProfileForm);
// Закрытие модалок
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
profilesModalClose.addEventListener('click', () => profilesModal.classList.remove('open'));
editDeptModal.addEventListener('click', (e) => {
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
});
editSpecModal.addEventListener('click', (e) => {
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
});
profilesModal.addEventListener('click', (e) => {
if (e.target === profilesModal) profilesModal.classList.remove('open');
});
// --- Дополнительные функции для вкладки Профилей ---
function populateSpecialtiesDropdown() {
const currentValue = tabProfileSpecSelect.value;
tabProfileSpecSelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode)} · ${escapeHtml(s.specialityName || s.name)}</option>`).join('');
tabProfileSpecSelect.value = currentValue;
}
function renderAllProfiles() {
if (!allProfiles || !allProfiles.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
return;
}
tabProfileTbody.innerHTML = allProfiles.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
<td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function resetTabProfileForm() {
createProfileTabForm.reset();
tabProfileIdInput.value = '';
tabProfileSpecSelect.disabled = false;
mainProfileFormTitle.textContent = 'Создание профиля';
btnTabSaveProfile.textContent = 'Создать';
btnTabCancelProfileEdit.hidden = true;
hideAlert('tab-profile-alert');
tabProfileSpecSelect.dispatchEvent(new Event('change'));
}
createProfileTabForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('tab-profile-alert');
const specId = tabProfileSpecSelect.value;
const profileId = tabProfileIdInput.value;
const name = tabProfileNameInput.value.trim();
const description = tabProfileDescInput.value.trim();
if (!specId) {
showAlert('tab-profile-alert', 'Выберите специальность', 'error');
return;
}
if (!name) {
showAlert('tab-profile-alert', 'Введите название профиля', 'error');
return;
}
try {
if (profileId) {
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
showAlert('tab-profile-alert', `Профиль "${name}" обновлен`, 'success');
} else {
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
showAlert('tab-profile-alert', `Профиль "${name}" создан`, 'success');
}
resetTabProfileForm();
await loadData();
} catch (error) {
showAlert('tab-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
}
});
tabProfileTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-tab-profile');
const deleteBtn = e.target.closest('.btn-delete-tab-profile');
if (editBtn) {
const profile = allProfiles.find(item => item.id == editBtn.dataset.id);
if (!profile) return;
tabProfileIdInput.value = profile.id;
tabProfileSpecSelect.value = profile.specialityId;
tabProfileSpecSelect.disabled = true;
tabProfileSpecSelect.dispatchEvent(new Event('change'));
tabProfileNameInput.value = profile.name || '';
tabProfileDescInput.value = profile.description || '';
mainProfileFormTitle.textContent = 'Редактирование профиля';
btnTabSaveProfile.textContent = 'Сохранить';
btnTabCancelProfileEdit.hidden = false;
hideAlert('tab-profile-alert');
createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
if (deleteBtn) {
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
if (!profile) return;
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
showAlert('tab-profile-alert', 'Профиль удален', 'success');
await loadData();
} catch (error) {
showAlert('tab-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
btnTabCancelProfileEdit.addEventListener('click', resetTabProfileForm);
await loadData();
}

View File

@@ -1,122 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_LABELS = {
ADMIN: 'Администратор',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Просмотр расписаний',
TEACHER: 'Преподаватель',
STUDENT: 'Студент'
};
const ROLE_BADGE = {
ADMIN: 'badge-admin',
EDUCATION_OFFICE: 'badge-admin',
DEPARTMENT: 'badge-teacher',
SCHEDULE_VIEWER: 'badge-student',
TEACHER: 'badge-teacher',
STUDENT: 'badge-student'
};
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
async function loadUsers() {
try {
const users = await api.get('/api/users?includeArchived=true');
renderUsers(users);
} catch (e) {
usersTbody.innerHTML =
'<tr><td colspan="7" class="loading-row">Ошибка загрузки: ' +
escapeHtml(e.message) + '</td></tr>';
}
}
function renderUsers(users) {
if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
return;
}
usersTbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td>${escapeHtml(u.fullName || '-')}</td>
<td>${escapeHtml(u.jobTitle || '-')}</td>
<td>${escapeHtml(u.departmentName || '-')}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td>
${u.status === 'ARCHIVED'
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
</td>
</tr>
`).join('');
}
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-alert');
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
const fullName = document.getElementById('new-fullname').value.trim();
const jobTitle = document.getElementById('new-jobtitle').value.trim();
const departmentId = document.getElementById('new-department').value;
if (!username || !password || !fullName || !jobTitle || !departmentId) {
showAlert('create-alert', 'Заполните все поля', 'error');
return;
}
if (password.length < 8) {
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
const data = await api.post('/api/users', {
username,
password,
role,
fullName,
jobTitle,
departmentId: Number(departmentId)
});
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
createForm.reset();
loadUsers();
} catch (err) {
showAlert('create-alert', err.message || 'Ошибка соединения', 'error');
}
});
usersTbody.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return;
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
try {
await api.delete('/api/users/' + deleteBtn.dataset.id);
loadUsers();
} catch (err) {
alert(err.message || 'Ошибка архивирования');
}
});
usersTbody.addEventListener('click', async (e) => {
const restoreBtn = e.target.closest('.btn-restore-user');
if (!restoreBtn) return;
try {
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
loadUsers();
} catch (err) {
alert(err.message || 'Ошибка восстановления');
}
});
await loadUsers();
}

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