Compare commits
6 Commits
ca0b9352f9
...
react-migr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97bf3cc50a | ||
|
|
c6083b35e0 | ||
|
|
e3b6797713 | ||
|
|
a1c4ee4298 | ||
|
|
3599d850f6 | ||
|
|
52d516dd8f |
13
AGENTS.md
13
AGENTS.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект представляет собой систему управления университетским расписанием.
|
Проект представляет собой систему управления университетским расписанием.
|
||||||
- **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
- **Backend**: Java 17, Spring Boot 3.5.16 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
||||||
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
|
- **Frontend**: поэтапный переход с Vanilla JavaScript на React 19 + HTML/CSS
|
||||||
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
|
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
|
||||||
- **Локальный URL**: localhost:80
|
- **Локальный URL**: localhost:80
|
||||||
- **Продакшн URL**: https://magistr.zuev.company
|
- **Продакшн URL**: https://magistr.zuev.company
|
||||||
@@ -25,6 +25,7 @@ magistr/
|
|||||||
│ ├── utils/ # Утилиты
|
│ ├── utils/ # Утилиты
|
||||||
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
|
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
|
||||||
├── frontend/ # Статические файлы
|
├── frontend/ # Статические файлы
|
||||||
|
│ ├── react/ # React entrypoint и компоненты уже перенесённых страниц
|
||||||
│ ├── admin/ # Интерфейс администратора
|
│ ├── admin/ # Интерфейс администратора
|
||||||
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
||||||
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
|
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
|
||||||
@@ -62,8 +63,14 @@ docker compose logs -f backend
|
|||||||
## Критические правила для агентов
|
## Критические правила для агентов
|
||||||
|
|
||||||
### Flyway миграции
|
### Flyway миграции
|
||||||
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
|
- До отдельного решения владельца проект работает в режиме пересобираемого baseline:
|
||||||
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
**все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
|
||||||
|
только в `V1__init.sql`**.
|
||||||
|
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
|
||||||
|
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
|
||||||
|
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
|
||||||
|
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
|
||||||
|
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
|
||||||
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
||||||
|
|
||||||
### Языковые требования
|
### Языковые требования
|
||||||
|
|||||||
@@ -63,18 +63,22 @@
|
|||||||
|
|
||||||
**Файлы:**
|
**Файлы:**
|
||||||
|
|
||||||
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265`
|
- `backend/src/main/resources/db/migration/V2__test_data.sql`
|
||||||
- `docs/README.md:45-54`
|
- `docs/README.md`
|
||||||
|
|
||||||
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
|
**Проблема:** схема и данные разделены: V1 теперь не содержит доменных записей, однако
|
||||||
|
`V2__test_data.sql` всё равно автоматически применяется общим Flyway location ко всем tenant-БД.
|
||||||
|
V2 создаёт администратора с публично документированным коротким паролем, несколько известных
|
||||||
|
demo-пользователей и большой демонстрационный набор. Пароли 150 массовых преподавателей случайны
|
||||||
|
и непубличны, но это не устраняет риск доступного администратора и загрязнения production-БД.
|
||||||
|
|
||||||
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
|
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
|
||||||
|
|
||||||
**Как исправить:**
|
**Как исправить:**
|
||||||
|
|
||||||
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
|
- Исключать `V2__test_data.sql` из production Flyway locations либо включать её только явным
|
||||||
|
dev/test-профилем.
|
||||||
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
|
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
|
||||||
- Разнести demo/dev seed и обязательную production-схему.
|
|
||||||
- Ротировать пароли уже созданных tenant-БД.
|
- Ротировать пароли уже созданных tenant-БД.
|
||||||
|
|
||||||
## Высокий приоритет
|
## Высокий приоритет
|
||||||
|
|||||||
676
UX_IMPROVEMENT_PLAN.md
Normal file
676
UX_IMPROVEMENT_PLAN.md
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
# План улучшения пользовательского взаимодействия Magistr
|
||||||
|
|
||||||
|
## 1. Назначение документа
|
||||||
|
|
||||||
|
Этот документ описывает целевое пользовательское взаимодействие для всех ролей Magistr и поэтапный план его улучшения. Главная цель — сделать систему удобной не только на демонстрационных данных, но и в реальной работе университета: при сотнях групп и преподавателей, десятках календарных графиков, большом аудиторном фонде, нескольких версиях расписания и постоянном потоке изменений.
|
||||||
|
|
||||||
|
План составлен по текущей реализации frontend, ролевой матрице, API и бизнес-логике проекта. Это экспертный UX-аудит кода и документации, а не замена наблюдению за реальными пользователями. Перед крупными изменениями гипотезы следует проверить на сотрудниках учебного отдела, кафедр, преподавателях и студентах.
|
||||||
|
|
||||||
|
## 2. Краткий вывод
|
||||||
|
|
||||||
|
В Magistr уже реализована значительная часть предметной логики: ролевой доступ, календарные графики, конструктор и версии расписания, анализ качества, разовые изменения, отсутствия, пожелания преподавателей, нагрузка и отдельные кабинеты конечных пользователей.
|
||||||
|
|
||||||
|
Основная UX-проблема — функции организованы преимущественно как набор отдельных форм, таблиц и справочников. Живой пользователь мыслит не сущностями базы данных, а задачами:
|
||||||
|
|
||||||
|
- «подготовить новый учебный год»;
|
||||||
|
- «проверить готовность данных кафедр»;
|
||||||
|
- «составить и опубликовать расписание»;
|
||||||
|
- «найти замену отсутствующему преподавателю»;
|
||||||
|
- «быстро понять, где у меня следующая пара»;
|
||||||
|
- «узнать, приняли ли мою заявку».
|
||||||
|
|
||||||
|
При росте данных длинные селекты, таблицы без серверной пагинации, ручная настройка каждой группы и графика, а также потеря контекста между разделами станут главным ограничением. Поэтому приоритет — не визуальный редизайн сам по себе, а переход к интерфейсу задач, очередей, массовых операций и устойчивого рабочего контекста.
|
||||||
|
|
||||||
|
## 3. Принципы целевого интерфейса
|
||||||
|
|
||||||
|
1. **Сначала текущая задача, затем справочник.** Главный экран роли должен отвечать на вопрос «что требует моего внимания сегодня?».
|
||||||
|
2. **Контекст показывается только там, где влияет на результат.** Учебный год, семестр и версия выбираются каскадом внутри профильной вкладки и сохраняются при возврате; общая панель не занимает место в остальных разделах.
|
||||||
|
3. **Поиск вместо прокрутки.** Любая коллекция более 20–30 элементов должна иметь поиск, фильтры, сортировку и понятный счётчик результатов.
|
||||||
|
4. **Массовые действия — обязательны.** Повторяющиеся операции над группами, графиками, дисциплинами и заявками нельзя заставлять выполнять по одной записи.
|
||||||
|
5. **Безопасность изменений.** До сохранения пользователь видит последствия, конфликты и область действия; после сохранения — подтверждение, журнал и возможность отмены там, где это допустимо.
|
||||||
|
6. **Прогрессивное раскрытие.** Редкие и сложные параметры скрываются до необходимости, а основной сценарий остаётся коротким.
|
||||||
|
7. **Одинаковые паттерны во всех кабинетах.** Поиск, фильтры, статусы, пустые состояния, подтверждения, боковые панели и сообщения работают одинаково.
|
||||||
|
8. **Мобильный интерфейс ориентирован на просмотр и оперативные действия.** Большие редакторы могут оставаться desktop-first, но расписание, заявки, статусы и замены должны быть удобны с телефона.
|
||||||
|
9. **Доступность является критерием готовности.** Полная клавиатурная навигация, видимый фокус, корректные подписи, контраст, понятные ошибки и отсутствие зависимости только от цвета.
|
||||||
|
|
||||||
|
## 4. Текущее состояние и основные риски
|
||||||
|
|
||||||
|
### Что уже сделано хорошо
|
||||||
|
|
||||||
|
- Роли направляются в подходящие кабинеты, а недоступные разделы скрываются.
|
||||||
|
- Учебный отдел и администратор могут пройти путь от правил до версии, анализа качества и публикации.
|
||||||
|
- Для точечных изменений есть сравнение «было / станет» и журнал изменений.
|
||||||
|
- Кафедра имеет собственную рабочую область, а преподаватель — пожелания, отсутствия и заявки.
|
||||||
|
- Студенческое и преподавательское расписание имеют недельную навигацию и сохраняют часть локальных настроек.
|
||||||
|
- У календарного графика есть диапазонное заполнение, а не только изменение каждой даты по отдельности.
|
||||||
|
- Система использует архивирование и сохраняет исторический контекст.
|
||||||
|
|
||||||
|
### Критические UX-риски при больших объёмах
|
||||||
|
|
||||||
|
| Риск | Как проявится у пользователя | Последствие |
|
||||||
|
|---|---|---|
|
||||||
|
| Полная загрузка коллекций | Долгое открытие разделов групп, пользователей, дисциплин и расписания | Ощущение зависания, лишний трафик, тяжёлый DOM |
|
||||||
|
| Длинные селекты | Поиск нужной группы, преподавателя или графика среди сотен элементов | Ошибочный выбор и потеря времени |
|
||||||
|
| Нет системной пагинации и сортировки | Таблицы становятся длинными, позиция после действия теряется | Невозможность эффективно обрабатывать реестры |
|
||||||
|
| Операции по одной записи | Назначение графиков, подготовка групп и привязок повторяется вручную | Большое число кликов и высокий риск пропусков |
|
||||||
|
| Контекст хранится фрагментарно | Год, семестр, версия и фильтры приходится выбирать повторно | Ошибки работы «не в том семестре» или «не в той версии» |
|
||||||
|
| Разделы отражают модель данных | Пользователь сам собирает бизнес-процесс из нескольких вкладок | Непонятно, что делать дальше и готов ли процесс |
|
||||||
|
| Нативные `confirm` и `prompt` | Опасные действия имеют мало контекста, комментарии вводятся в системное окно | Слабая предсказуемость и доступность |
|
||||||
|
| Нет общего центра уведомлений | Пользователь узнаёт о новых заявках и решениях только после открытия раздела | Задержка согласований и публикаций |
|
||||||
|
| Нет общей модели несохранённых изменений | Переход может привести к потере сложной настройки | Повторная работа и недоверие к системе |
|
||||||
|
| Ограниченный экспорт и обмен | Расписание и отчёты сложно передать вне системы | Возврат к скриншотам и ручным таблицам |
|
||||||
|
|
||||||
|
## 5. Пользовательские сценарии по ролям
|
||||||
|
|
||||||
|
### 5.1. Администратор
|
||||||
|
|
||||||
|
#### Реальные цели
|
||||||
|
|
||||||
|
- подготовить структуру и справочники университета;
|
||||||
|
- создать и сопровождать пользователей;
|
||||||
|
- контролировать качество исходных данных;
|
||||||
|
- одобрять заявки кафедр на преподавателей;
|
||||||
|
- помогать устранять ошибки доступа и конфигурации;
|
||||||
|
- видеть состояние системы, а не вручную обходить все разделы.
|
||||||
|
|
||||||
|
#### Типичный сценарий сейчас
|
||||||
|
|
||||||
|
Администратор входит на дашборд, затем отдельно открывает пользователей, структуру вуза, группы, дисциплины, аудитории, календарные графики и заявки. Формы создания постоянно видимы над реестрами, даже если большую часть времени пользователь только ищет или проверяет записи.
|
||||||
|
|
||||||
|
#### Целевой сценарий
|
||||||
|
|
||||||
|
1. На главной странице администратор видит очередь внимания: ожидающие заявки, неполные данные, архивные зависимости, ошибки проверки и недавние действия.
|
||||||
|
2. Выбирает учебный год внутри мастера подготовки периода.
|
||||||
|
3. Открывает мастер «Подготовить учебный год» с шагами и прогрессом.
|
||||||
|
4. Исправляет только отмеченные проблемы через глубокие ссылки на конкретную запись.
|
||||||
|
5. Выполняет массовое создание или импорт, получает предварительный просмотр и отчёт по строкам.
|
||||||
|
6. После завершения видит статус готовности по каждому блоку.
|
||||||
|
|
||||||
|
#### Улучшения
|
||||||
|
|
||||||
|
- заменить постоянные формы создания кнопкой «Добавить», открывающей боковую панель;
|
||||||
|
- добавить глобальный поиск по пользователям, группам, кафедрам, дисциплинам и аудиториям;
|
||||||
|
- добавить фильтры по статусу, роли, кафедре и периоду действия;
|
||||||
|
- показывать историю записи и зависимые объекты до архивирования;
|
||||||
|
- добавить импорт пользователей, групп, аудиторий и дисциплин с dry-run-проверкой;
|
||||||
|
- ввести мастер первоначальной настройки тенанта и нового учебного года;
|
||||||
|
- показывать «здоровье данных»: группы без графика, дисциплины без преподавателей, аудитории без вместимости/оборудования, незакрытые заявки.
|
||||||
|
|
||||||
|
### 5.2. Учебный отдел
|
||||||
|
|
||||||
|
#### Реальные цели
|
||||||
|
|
||||||
|
- подготовить календарную и временную основу семестра;
|
||||||
|
- убедиться, что кафедры предоставили данные;
|
||||||
|
- составить расписание без конфликтов;
|
||||||
|
- сравнить, проверить и опубликовать версию;
|
||||||
|
- быстро обрабатывать отсутствия, переносы и замены;
|
||||||
|
- находить свободные аудитории и понимать последствия изменений.
|
||||||
|
|
||||||
|
#### Целевой основной путь «Составить и опубликовать расписание»
|
||||||
|
|
||||||
|
1. Выбрать учебный год, семестр и версию в локальной панели конструктора расписания.
|
||||||
|
2. Открыть чек-лист готовности: календарные графики назначены, дисциплины заполнены, преподаватели привязаны, пожелания рассмотрены, временная сетка настроена.
|
||||||
|
3. Создать черновик из опубликованной версии или с нуля.
|
||||||
|
4. Работать в конструкторе с поиском, пакетным добавлением и видимой матрицей ресурсов.
|
||||||
|
5. Получать конфликт сразу в месте редактирования, с предложенными вариантами решения.
|
||||||
|
6. Запустить анализ качества, перейти из проблемы прямо к правилу, вернуться с сохранёнными фильтрами.
|
||||||
|
7. Сравнить версию, проверить число изменений и затронутых групп/преподавателей.
|
||||||
|
8. Опубликовать с причиной и получить подтверждение доставки изменений конечным пользователям.
|
||||||
|
|
||||||
|
#### Целевой оперативный путь «Изменение в течение семестра»
|
||||||
|
|
||||||
|
1. Открыть единую очередь инцидентов: отсутствия, заявки преподавателей, конфликты и ручные изменения.
|
||||||
|
2. Увидеть приоритет, срок, автора и затронутые занятия.
|
||||||
|
3. Выбрать предложенную замену или найти ресурс через контекстный поиск.
|
||||||
|
4. До подтверждения увидеть «было / станет», новые конфликты и список уведомляемых людей.
|
||||||
|
5. Применить решение пакетом и получить запись в журнале.
|
||||||
|
|
||||||
|
#### Улучшения
|
||||||
|
|
||||||
|
- объединить версии, качество и конструктор общим пошаговым контуром, не удаляя отдельные экспертные экраны;
|
||||||
|
- добавить глобальную панель «Учебный год · Семестр · Версия»;
|
||||||
|
- ввести автосохранение черновика или явный индикатор «Сохранено / Есть изменения / Ошибка»;
|
||||||
|
- добавить undo для локально обратимых действий и журнал последних операций;
|
||||||
|
- сделать массовое назначение правил и слотов нескольким группам;
|
||||||
|
- добавить боковую инспекцию группы, преподавателя и аудитории без ухода со страницы;
|
||||||
|
- показывать конфликты и пожелания непосредственно в визуальной сетке;
|
||||||
|
- добавить командную палитру или быстрый поиск для опытных диспетчеров;
|
||||||
|
- сохранить представления фильтров: «1 курс ИТ», «вечерние аудитории корпуса Б», «кафедра ВТ».
|
||||||
|
|
||||||
|
### 5.3. Кафедра
|
||||||
|
|
||||||
|
#### Реальные цели
|
||||||
|
|
||||||
|
- поддерживать список дисциплин и преподавателей своей кафедры;
|
||||||
|
- назначать преподавателей на дисциплины;
|
||||||
|
- передавать учебному отделу корректные исходные данные;
|
||||||
|
- контролировать нагрузку;
|
||||||
|
- согласовывать пожелания и отсутствия;
|
||||||
|
- отслеживать результат заявок.
|
||||||
|
|
||||||
|
#### Проблема текущей компоновки
|
||||||
|
|
||||||
|
В одной рабочей области одновременно находятся период, импорт дисциплин, добавление преподавателя, заявка на преподавателя, нагрузка, заявки, дисциплины и комментарии. При повседневной работе это создаёт длинную страницу и смешивает редкие настройки с ежедневными задачами.
|
||||||
|
|
||||||
|
#### Целевой сценарий
|
||||||
|
|
||||||
|
1. Главная кафедры показывает готовность к семестру и очередь действий.
|
||||||
|
2. Раздел «Подготовка семестра» группирует дисциплины, преподавателей, привязки и пожелания.
|
||||||
|
3. Раздел «Нагрузка» показывает отклонения, недогруз и перегруз, а не только список метрик.
|
||||||
|
4. Раздел «Заявки» объединяет создание преподавателя, отсутствия и изменения занятий со статусами и сроками.
|
||||||
|
5. После отправки данных кафедра видит, что принято, отклонено или требует уточнения.
|
||||||
|
|
||||||
|
#### Улучшения
|
||||||
|
|
||||||
|
- разделить рабочую область на «Обзор», «Дисциплины», «Преподаватели и нагрузка», «Заявки»;
|
||||||
|
- добавить поиск и фильтры по дисциплинам и преподавателям;
|
||||||
|
- поддержать вставку строк из Excel/буфера и загрузку файла вместо ручного добавления строк импорта;
|
||||||
|
- добавить массовую привязку преподавателей к дисциплинам;
|
||||||
|
- показывать полноту данных по каждой дисциплине;
|
||||||
|
- отображать нагрузку с нормой, отклонением и объяснением расчёта;
|
||||||
|
- дать кафедре читаемый timeline заявки и явное следующее действие;
|
||||||
|
- уведомлять о решениях учебного отдела и администратора.
|
||||||
|
|
||||||
|
### 5.4. Пользователь просмотра расписаний
|
||||||
|
|
||||||
|
#### Реальные цели
|
||||||
|
|
||||||
|
- быстро найти расписание группы, преподавателя, аудитории или кафедры;
|
||||||
|
- переключаться между несколькими результатами;
|
||||||
|
- распечатать, экспортировать или отправить ссылку;
|
||||||
|
- понимать, актуально ли расписание и есть ли разовые изменения.
|
||||||
|
|
||||||
|
#### Улучшения
|
||||||
|
|
||||||
|
- единая поисковая строка с типизированными результатами: «группа», «преподаватель», «аудитория»;
|
||||||
|
- недавние и избранные расписания;
|
||||||
|
- URL, содержащий выбранный объект, семестр, дату и режим представления;
|
||||||
|
- кнопки «Скопировать ссылку», «Печать», «PDF», «ICS»;
|
||||||
|
- явная отметка «Опубликовано …», легенда замен и отмен;
|
||||||
|
- режим сравнения двух расписаний для поиска общего свободного времени;
|
||||||
|
- сохранение фильтров после перезагрузки и возврата на страницу.
|
||||||
|
|
||||||
|
### 5.5. Преподаватель
|
||||||
|
|
||||||
|
#### Реальные цели
|
||||||
|
|
||||||
|
- за несколько секунд увидеть ближайшую пару и аудиторию;
|
||||||
|
- понять, что изменилось с последнего просмотра;
|
||||||
|
- сообщить об отсутствии;
|
||||||
|
- задать доступность и пожелания до составления расписания;
|
||||||
|
- запросить перенос, аудиторию или отмену и отследить решение.
|
||||||
|
|
||||||
|
#### Целевой сценарий
|
||||||
|
|
||||||
|
1. После входа преподаватель видит «Сегодня» и карточку следующего занятия.
|
||||||
|
2. Изменённые, перенесённые и отменённые пары выделены и объяснены текстом.
|
||||||
|
3. Из карточки занятия доступны контекстные действия: маршрут до аудитории, заявка на изменение, добавление в календарь.
|
||||||
|
4. Заявка создаётся с уже заполненными данными занятия; система сразу показывает допустимые даты, пары и аудитории.
|
||||||
|
5. Статус заявки меняется в понятном timeline и сопровождается уведомлением.
|
||||||
|
|
||||||
|
#### Улучшения
|
||||||
|
|
||||||
|
- сделать «Сегодня / Неделя / Семестр» тремя явными режимами;
|
||||||
|
- добавить карточку следующего занятия и блок недавних изменений;
|
||||||
|
- показывать корпус, этаж и оборудование аудитории;
|
||||||
|
- сохранить черновик длинной заявки при случайном закрытии;
|
||||||
|
- объяснять разницу между строгой недоступностью и мягким пожеланием до выбора режима;
|
||||||
|
- добавить массовое заполнение предпочтений: копирование дня, диапазон пар, типовая неделя;
|
||||||
|
- показывать дедлайн подачи пожеланий и статус согласования;
|
||||||
|
- добавить экспорт в личный календарь и подписку ICS.
|
||||||
|
|
||||||
|
### 5.6. Студент
|
||||||
|
|
||||||
|
#### Реальные цели
|
||||||
|
|
||||||
|
- один раз выбрать свою группу;
|
||||||
|
- быстро увидеть занятия сегодня и завтра;
|
||||||
|
- узнать об отмене, переносе или смене аудитории;
|
||||||
|
- перейти к нужной неделе без знания чётности;
|
||||||
|
- поделиться расписанием или добавить его в календарь.
|
||||||
|
|
||||||
|
#### Проблема масштаба
|
||||||
|
|
||||||
|
Выбор группы обычным списком неудобен при сотнях групп. Пользователь не должен каждый раз разбираться в полном университетском справочнике.
|
||||||
|
|
||||||
|
#### Улучшения
|
||||||
|
|
||||||
|
- первый запуск: поиск группы по названию с подсказками по специальности, курсу и кафедре;
|
||||||
|
- после выбора сделать группу постоянным профилем, а смену вынести в отдельное действие;
|
||||||
|
- стартовый режим «Сегодня», ниже — «Завтра» и ближайшее изменение;
|
||||||
|
- добавить быстрый переход «Эта неделя / Следующая неделя»;
|
||||||
|
- показывать статус обновления расписания и понятную легенду изменений;
|
||||||
|
- добавить shareable URL, печать, PDF и ICS;
|
||||||
|
- предусмотреть кеш последнего расписания для слабой сети;
|
||||||
|
- на мобильном устройстве использовать карточки дней и sticky-навигацию, а не уменьшенную широкую таблицу.
|
||||||
|
|
||||||
|
## 6. Сквозные процессы между ролями
|
||||||
|
|
||||||
|
### 6.1. Подготовка семестра
|
||||||
|
|
||||||
|
```text
|
||||||
|
Администратор настраивает структуру и справочники
|
||||||
|
↓
|
||||||
|
Кафедра заполняет дисциплины, преподавателей и пожелания
|
||||||
|
↓
|
||||||
|
Учебный отдел проверяет готовность календарей и данных
|
||||||
|
↓
|
||||||
|
Учебный отдел создаёт и проверяет черновик
|
||||||
|
↓
|
||||||
|
Публикация → преподаватели, студенты и просмотр расписаний
|
||||||
|
```
|
||||||
|
|
||||||
|
Для процесса нужен единый статус готовности с владельцем каждой проблемы. Пользователь должен видеть не просто «ошибка», а «что не готово → кто исправляет → куда перейти».
|
||||||
|
|
||||||
|
### 6.2. Отсутствие и замена
|
||||||
|
|
||||||
|
```text
|
||||||
|
Преподаватель сообщает об отсутствии
|
||||||
|
↓
|
||||||
|
Кафедра подтверждает факт
|
||||||
|
↓
|
||||||
|
Учебный отдел выбирает решения по затронутым занятиям
|
||||||
|
↓
|
||||||
|
Расписание изменяется, решение попадает в аудит
|
||||||
|
↓
|
||||||
|
Преподаватель и затронутые группы получают уведомление
|
||||||
|
```
|
||||||
|
|
||||||
|
Все роли должны видеть один и тот же номер инцидента, единые статусы и хронологию, но разные доступные действия.
|
||||||
|
|
||||||
|
### 6.3. Заявка на изменение занятия
|
||||||
|
|
||||||
|
Нужна единая модель статусов: `Черновик → Отправлена → Проверяется → Требует уточнения / Одобрена / Отклонена → Применена`. Технические статусы API можно сохранить, но UI должен переводить их в понятный язык и показывать следующее действие.
|
||||||
|
|
||||||
|
## 7. Масштабирование интерфейса
|
||||||
|
|
||||||
|
### 7.1. Реестры
|
||||||
|
|
||||||
|
Для групп, пользователей, аудиторий, дисциплин, заявок и правил необходимо:
|
||||||
|
|
||||||
|
- серверная пагинация или cursor pagination;
|
||||||
|
- серверные поиск, фильтрация и сортировка;
|
||||||
|
- размер страницы 25/50/100;
|
||||||
|
- строка поиска с задержкой 250–400 мс и отменой предыдущего запроса;
|
||||||
|
- sticky-заголовок таблицы;
|
||||||
|
- сохранение фильтров в URL;
|
||||||
|
- выбор строк и массовая панель действий;
|
||||||
|
- счётчик «Показано N из M»;
|
||||||
|
- настраиваемые столбцы для сложных реестров;
|
||||||
|
- виртуализация только там, где пагинация мешает задаче, например в большой визуальной матрице.
|
||||||
|
|
||||||
|
### 7.2. Поиск сущностей
|
||||||
|
|
||||||
|
Селекты с большими наборами следует заменить асинхронным комбобоксом:
|
||||||
|
|
||||||
|
- поиск начинается после 2 символов;
|
||||||
|
- результат показывает различающие атрибуты;
|
||||||
|
- группы: название, курс, специальность, форма, кафедра;
|
||||||
|
- преподаватели: ФИО, должность, кафедры;
|
||||||
|
- аудитории: название, корпус, этаж, вместимость, оборудование;
|
||||||
|
- графики: учебный год, специальность, профиль, форма, число курсов;
|
||||||
|
- выбранное значение остаётся видимым чипом;
|
||||||
|
- клавиатура и экранный диктор поддерживаются полностью.
|
||||||
|
|
||||||
|
### 7.3. Календарные графики
|
||||||
|
|
||||||
|
При десятках похожих графиков требуются:
|
||||||
|
|
||||||
|
- пользовательское название или короткий alias графика;
|
||||||
|
- поиск и фасетные фильтры по году, специальности, профилю и форме;
|
||||||
|
- клонирование графика из прошлого года с предварительным просмотром сдвига дат;
|
||||||
|
- массовое создание на основе шаблона;
|
||||||
|
- массовое назначение выбранным совместимым группам;
|
||||||
|
- индикатор использования: сколько групп назначено;
|
||||||
|
- сравнение двух графиков и подсветка различий;
|
||||||
|
- защита от потери несохранённой сетки;
|
||||||
|
- автосохранение черновика сетки локально или на сервере;
|
||||||
|
- undo/redo для заполнения диапазона и изменения ячеек;
|
||||||
|
- проверка до сохранения с переходом к ошибочной дате;
|
||||||
|
- закреплённые заголовки курса, месяца и легенды при прокрутке.
|
||||||
|
|
||||||
|
### 7.4. Конструктор расписания
|
||||||
|
|
||||||
|
- master-detail-компоновка: слева фильтруемый список правил, справа редактор выбранного правила;
|
||||||
|
- создание из шаблона и дублирование правила;
|
||||||
|
- массовое назначение групп, преподавателя, слотов и диапазонов недель;
|
||||||
|
- фильтры по заполненности, конфликтам, кафедре, курсу и дисциплине;
|
||||||
|
- визуальная сетка загружает только выбранный набор групп;
|
||||||
|
- конфликт объясняется человеческим языком и содержит ссылку на конфликтующее правило;
|
||||||
|
- перед удалением показывается количество затронутых занятий;
|
||||||
|
- публикация заблокирована до прохождения обязательных проверок.
|
||||||
|
|
||||||
|
## 8. Целевая информационная архитектура
|
||||||
|
|
||||||
|
### Администратор
|
||||||
|
|
||||||
|
- Обзор и задачи
|
||||||
|
- Подготовка учебного года
|
||||||
|
- Пользователи и доступ
|
||||||
|
- Структура и справочники
|
||||||
|
- Расписание
|
||||||
|
- Оперативные изменения
|
||||||
|
- Отчёты и аудит
|
||||||
|
- Настройки
|
||||||
|
|
||||||
|
### Учебный отдел
|
||||||
|
|
||||||
|
- Обзор и очередь
|
||||||
|
- Подготовка семестра
|
||||||
|
- Конструктор расписания
|
||||||
|
- Проверка и публикация
|
||||||
|
- Просмотр расписаний
|
||||||
|
- Изменения и замены
|
||||||
|
- Ресурсы и загруженность
|
||||||
|
|
||||||
|
### Кафедра
|
||||||
|
|
||||||
|
- Обзор
|
||||||
|
- Подготовка семестра
|
||||||
|
- Дисциплины
|
||||||
|
- Преподаватели и нагрузка
|
||||||
|
- Расписание кафедры
|
||||||
|
- Заявки и согласования
|
||||||
|
|
||||||
|
Названия меню должны описывать рабочие задачи. Технические сущности остаются внутри экранов и документации, но не обязаны быть верхним уровнем навигации.
|
||||||
|
|
||||||
|
## 9. Общие компоненты и паттерны
|
||||||
|
|
||||||
|
Необходимо сформировать единый набор компонентов для Vanilla JS:
|
||||||
|
|
||||||
|
- асинхронный combobox;
|
||||||
|
- панель фильтров с чипами активных условий и кнопкой «Сбросить»;
|
||||||
|
- таблица с серверной пагинацией, сортировкой, выбором строк и массовыми действиями;
|
||||||
|
- боковая панель просмотра/редактирования;
|
||||||
|
- диалог подтверждения с описанием последствий;
|
||||||
|
- toast для краткого результата и inline-сообщение для ошибки поля/раздела;
|
||||||
|
- skeleton-состояние вместо скачка пустой таблицы;
|
||||||
|
- стандартизированные empty/error/permission states;
|
||||||
|
- status badge с текстом и иконкой;
|
||||||
|
- breadcrumb или ссылка «Назад к результатам» с восстановлением позиции;
|
||||||
|
- индикатор сохранения и защита несохранённых изменений;
|
||||||
|
- timeline для заявок и аудита;
|
||||||
|
- центр уведомлений и счётчики очередей.
|
||||||
|
|
||||||
|
Системные `confirm()` и `prompt()` следует постепенно заменить доступными проектными диалогами. Сообщение об успешном действии должно называть объект и результат: «График назначен 18 группам», а не просто «Успешно».
|
||||||
|
|
||||||
|
## 10. Состояния интерфейса
|
||||||
|
|
||||||
|
Каждый рабочий экран должен проектироваться минимум для следующих состояний:
|
||||||
|
|
||||||
|
1. первоначальная загрузка;
|
||||||
|
2. данные загружены;
|
||||||
|
3. пустой справочник;
|
||||||
|
4. фильтр не дал результатов;
|
||||||
|
5. частичная ошибка;
|
||||||
|
6. полная ошибка с повтором;
|
||||||
|
7. нет прав;
|
||||||
|
8. объект изменён другим пользователем;
|
||||||
|
9. есть несохранённые изменения;
|
||||||
|
10. действие выполняется;
|
||||||
|
11. действие успешно завершено;
|
||||||
|
12. действие завершено частично, например при массовом импорте.
|
||||||
|
|
||||||
|
Для конкурентного редактирования версий, календарей и правил желательно использовать optimistic locking и понятный диалог: кто и когда изменил объект, можно ли обновить данные или сохранить копию.
|
||||||
|
|
||||||
|
## 11. Уведомления
|
||||||
|
|
||||||
|
Минимально необходимы уведомления внутри приложения:
|
||||||
|
|
||||||
|
- новая заявка для ответственной роли;
|
||||||
|
- решение по заявке;
|
||||||
|
- приближение дедлайна пожеланий;
|
||||||
|
- публикация новой версии;
|
||||||
|
- перенос, отмена или смена аудитории;
|
||||||
|
- конфликт или ошибка массовой операции.
|
||||||
|
|
||||||
|
Уведомление должно вести к конкретному объекту, иметь статус прочтения и не раскрывать данные другой кафедры. Email/web-push можно добавить позднее, используя те же события.
|
||||||
|
|
||||||
|
## 12. Доступность и адаптивность
|
||||||
|
|
||||||
|
- обеспечить WCAG 2.2 AA как целевой уровень;
|
||||||
|
- проверить контраст обеих тем;
|
||||||
|
- добавить `aria-sort` таблицам и объявление количества результатов;
|
||||||
|
- реализовать корректные combobox/listbox-роли для поиска;
|
||||||
|
- возвращать фокус после закрытия модального окна;
|
||||||
|
- закрывать диалоги по `Escape`, удерживать фокус внутри открытого диалога;
|
||||||
|
- не использовать цвет как единственный признак конфликта, статуса или чётности;
|
||||||
|
- увеличить интерактивные зоны минимум до 44×44 px на мобильных экранах;
|
||||||
|
- для расписаний на телефоне показывать один день/список занятий, а не горизонтально сжатую таблицу;
|
||||||
|
- уважать `prefers-reduced-motion`;
|
||||||
|
- сделать печатные стили для расписаний и отчётов.
|
||||||
|
|
||||||
|
## 13. Приоритетный roadmap
|
||||||
|
|
||||||
|
### Этап 0. Проверка гипотез и базовые метрики — 1–2 недели
|
||||||
|
|
||||||
|
- провести 5–7 интервью с учебным отделом, 3–5 с кафедрами, 5 с преподавателями и 5 со студентами;
|
||||||
|
- наблюдать выполнение реальных задач без подсказок;
|
||||||
|
- замерить время, число кликов, ошибки и точки возврата;
|
||||||
|
- зафиксировать объёмы: группы, графики, правила, преподаватели, аудитории, заявки;
|
||||||
|
- построить карту основных процессов и согласовать владельцев статусов.
|
||||||
|
|
||||||
|
**Результат:** подтверждённый список top-10 проблем и исходные значения метрик.
|
||||||
|
|
||||||
|
### Этап 1. Быстрые улучшения frontend — 2–4 недели
|
||||||
|
|
||||||
|
- сохранять в URL и восстанавливать фильтры, вкладки, семестр и выбранный объект;
|
||||||
|
- добавить поиск, сортировку и sticky-заголовки в текущие таблицы;
|
||||||
|
- заменить опасные `confirm/prompt` проектными диалогами;
|
||||||
|
- унифицировать загрузку, пустые состояния и ошибки;
|
||||||
|
- добавить защиту несохранённых изменений в календарь и конструктор;
|
||||||
|
- улучшить первый выбор группы студента;
|
||||||
|
- добавить режим «Сегодня» преподавателю и студенту;
|
||||||
|
- добавить ссылки «Скопировать» и печатные стили просмотра расписания.
|
||||||
|
|
||||||
|
**Результат:** заметное сокращение ежедневного трения без крупной переделки backend.
|
||||||
|
|
||||||
|
### Этап 2. Масштабные реестры и поиск — 4–8 недель
|
||||||
|
|
||||||
|
- добавить API пагинации, поиска, сортировки и фасетных фильтров;
|
||||||
|
- внедрить общий компонент серверной таблицы;
|
||||||
|
- внедрить асинхронный combobox;
|
||||||
|
- добавить массовые операции для групп, графиков и привязок;
|
||||||
|
- добавить предварительный просмотр и отчёт массовых операций;
|
||||||
|
- обеспечить стабильные deep links на сущности.
|
||||||
|
|
||||||
|
**Результат:** интерфейс остаётся работоспособным на полном объёме данных университета.
|
||||||
|
|
||||||
|
### Этап 3. Интерфейс сквозных задач — 6–10 недель
|
||||||
|
|
||||||
|
- локальные каскады учебного года, семестра и версии в профильных вкладках;
|
||||||
|
- чек-лист готовности семестра;
|
||||||
|
- ролевые дашборды с очередями действий;
|
||||||
|
- единая очередь инцидентов и согласований;
|
||||||
|
- timeline заявок;
|
||||||
|
- связанный путь «конструктор → качество → сравнение → публикация»;
|
||||||
|
- центр уведомлений.
|
||||||
|
|
||||||
|
**Результат:** пользователь проходит бизнес-процесс, не собирая его вручную из вкладок.
|
||||||
|
|
||||||
|
### Этап 4. Продвинутые инструменты — после стабилизации
|
||||||
|
|
||||||
|
- шаблоны и клонирование календарных графиков;
|
||||||
|
- импорт из Excel с сопоставлением колонок;
|
||||||
|
- сравнение расписаний и поиск общего свободного времени;
|
||||||
|
- экспорт PDF/Excel/ICS и календарная подписка;
|
||||||
|
- оптимистические блокировки и совместное редактирование;
|
||||||
|
- командная палитра и горячие клавиши для опытных пользователей;
|
||||||
|
- персональные сохранённые представления.
|
||||||
|
|
||||||
|
## 14. Приоритеты по модели RICE без числовой фиксации
|
||||||
|
|
||||||
|
| Инициатива | Охват | Влияние | Сложность | Приоритет |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Серверный поиск и пагинация | Все внутренние роли | Очень высокое | Высокая | P0 |
|
||||||
|
| Локальные каскады года/семестра/версии | Администратор, учебный отдел, кафедра | Очень высокое | Средняя | P0 |
|
||||||
|
| Защита несохранённых изменений | Учебный отдел | Очень высокое | Средняя | P0 |
|
||||||
|
| Асинхронные комбобоксы | Все роли | Высокое | Средняя | P0 |
|
||||||
|
| URL-состояние и глубокие ссылки | Все роли | Высокое | Низкая/средняя | P0 |
|
||||||
|
| Массовое назначение графиков группам | Администратор, учебный отдел | Очень высокое | Средняя/высокая | P0 |
|
||||||
|
| Ролевые очереди задач | Внутренние роли | Высокое | Средняя | P1 |
|
||||||
|
| Единый контур публикации | Учебный отдел | Высокое | Средняя | P1 |
|
||||||
|
| Уведомления | Все роли | Высокое | Высокая | P1 |
|
||||||
|
| Импорт с предварительной проверкой | Администратор, кафедра | Высокое | Высокая | P1 |
|
||||||
|
| «Сегодня» и ближайшая пара | Преподаватель, студент | Высокое | Низкая | P1 |
|
||||||
|
| PDF/ICS/ссылки | Просмотр, преподаватель, студент | Среднее | Средняя | P2 |
|
||||||
|
| Командная палитра | Опытные внутренние пользователи | Среднее | Средняя | P2 |
|
||||||
|
|
||||||
|
## 15. Изменения backend, необходимые для полноценного UX
|
||||||
|
|
||||||
|
Frontend-улучшений недостаточно для больших объёмов. Понадобятся:
|
||||||
|
|
||||||
|
- единый контракт пагинации и сортировки для реестров;
|
||||||
|
- полнотекстовый или индексированный поиск по нормализованным полям;
|
||||||
|
- batch-endpoints с атомарным и частичным режимами;
|
||||||
|
- dry-run для импорта, назначения и публикации;
|
||||||
|
- endpoint готовности семестра с кодами проблем и deep-link metadata;
|
||||||
|
- endpoint глобального поиска в рамках прав пользователя;
|
||||||
|
- события и хранилище уведомлений;
|
||||||
|
- optimistic locking/version field для редактируемых сущностей;
|
||||||
|
- агрегированные endpoints для дашбордов, чтобы не собирать их множеством клиентских запросов;
|
||||||
|
- экспортные задания для тяжёлых PDF/Excel;
|
||||||
|
- аудит массовых операций с автором, причиной и набором затронутых объектов.
|
||||||
|
|
||||||
|
Все контракты должны сохранять мультитенантную и ролевую изоляцию. Сервер обязан повторно применять ограничения роли независимо от фильтров frontend.
|
||||||
|
|
||||||
|
## 16. UX-метрики
|
||||||
|
|
||||||
|
### Операционные
|
||||||
|
|
||||||
|
- медианное время создания и назначения календарного графика;
|
||||||
|
- время поиска конкретной группы, преподавателя и аудитории;
|
||||||
|
- время от регистрации отсутствия до применения всех решений;
|
||||||
|
- время от создания черновика до публикации;
|
||||||
|
- число ручных действий на 10 групп или 10 правил;
|
||||||
|
- процент массовых операций с частичными ошибками.
|
||||||
|
|
||||||
|
### Качественные
|
||||||
|
|
||||||
|
- success rate ключевого сценария без помощи;
|
||||||
|
- число возвратов и повторных сохранений;
|
||||||
|
- доля ошибок «не тот семестр / не та версия / не та кафедра»;
|
||||||
|
- доля пользователей, понимающих следующий шаг;
|
||||||
|
- SUS или UMUX-Lite по каждой роли;
|
||||||
|
- CSAT после обработки заявки или публикации.
|
||||||
|
|
||||||
|
### Технические UX-метрики
|
||||||
|
|
||||||
|
- p75 времени появления полезного содержимого;
|
||||||
|
- p75 ответа поиска;
|
||||||
|
- размер DOM больших таблиц и сеток;
|
||||||
|
- частота ошибок API по экрану и действию;
|
||||||
|
- доля прерванных загрузок и повторных запросов;
|
||||||
|
- Core Web Vitals для публичных/конечных кабинетов.
|
||||||
|
|
||||||
|
Целевые значения следует зафиксировать после этапа 0. Без исходных измерений произвольные проценты улучшения будут недостоверны.
|
||||||
|
|
||||||
|
## 17. Критерии готовности ключевых улучшений
|
||||||
|
|
||||||
|
### Реестр больших данных готов, если
|
||||||
|
|
||||||
|
- 10 000 записей не загружаются целиком в браузер;
|
||||||
|
- поиск возвращает результат за приемлемое для проекта целевое время;
|
||||||
|
- фильтры и страница восстанавливаются после возврата;
|
||||||
|
- массовая операция показывает область действия до подтверждения;
|
||||||
|
- частичные ошибки перечислены по объектам и доступны для выгрузки;
|
||||||
|
- управление возможно с клавиатуры.
|
||||||
|
|
||||||
|
### Календарный график готов, если
|
||||||
|
|
||||||
|
- пользователь находит нужный график без просмотра полного списка;
|
||||||
|
- может создать его из шаблона и назначить нескольким совместимым группам;
|
||||||
|
- видит несохранённые изменения;
|
||||||
|
- может отменить последнее редактирование;
|
||||||
|
- ошибка указывает точный курс и дату;
|
||||||
|
- уход со страницы не приводит к молчаливой потере работы.
|
||||||
|
|
||||||
|
### Публикация готова, если
|
||||||
|
|
||||||
|
- текущий семестр и версия видны постоянно;
|
||||||
|
- до публикации показаны ошибки, предупреждения и diff;
|
||||||
|
- указано число затронутых групп, преподавателей и занятий;
|
||||||
|
- причина обязательна и попадает в аудит;
|
||||||
|
- пользователь получает однозначное подтверждение результата;
|
||||||
|
- конечные роли видят актуальность и изменения.
|
||||||
|
|
||||||
|
## 18. План пользовательского тестирования
|
||||||
|
|
||||||
|
Для каждого прототипа проводить короткие сценарные тесты:
|
||||||
|
|
||||||
|
| Роль | Проверочное задание |
|
||||||
|
|---|---|
|
||||||
|
| Администратор | Создать 20 групп импортом, исправить ошибки и назначить графики |
|
||||||
|
| Учебный отдел | Создать черновик, устранить конфликт, проверить качество и опубликовать |
|
||||||
|
| Учебный отдел | Обработать отсутствие с несколькими затронутыми занятиями |
|
||||||
|
| Кафедра | Добавить дисциплины и преподавателей, проверить готовность семестра |
|
||||||
|
| Просмотр | Найти расписание аудитории и отправить ссылку коллеге |
|
||||||
|
| Преподаватель | Указать недоступность и подать заявку на перенос |
|
||||||
|
| Студент | Выбрать группу и найти аудиторию следующей пары |
|
||||||
|
|
||||||
|
Фиксировать время, ошибки, вопросы пользователя, неверные ожидания и субъективную уверенность. Тестировать минимум на реальных объёмах, а не на 3–5 элементах.
|
||||||
|
|
||||||
|
## 19. Что не следует делать первым
|
||||||
|
|
||||||
|
- полностью менять визуальный стиль без исправления поиска, масштаба и процессов;
|
||||||
|
- объединять все роли в один универсальный экран;
|
||||||
|
- скрывать сложность только дополнительными модальными окнами;
|
||||||
|
- переносить всю фильтрацию на клиент;
|
||||||
|
- добавлять массовые действия без preview, отчёта и аудита;
|
||||||
|
- полагаться только на цвет для статусов;
|
||||||
|
- автоматически применять пожелания преподавателей без явного решения ответственной роли;
|
||||||
|
- внедрять drag-and-drop как единственный способ редактирования расписания.
|
||||||
|
|
||||||
|
## 20. Рекомендуемый первый пакет реализации
|
||||||
|
|
||||||
|
Первый практический пакет должен дать максимальный эффект без полной перестройки системы:
|
||||||
|
|
||||||
|
1. локальные каскады «Учебный год / Семестр / Версия» только в профильных вкладках;
|
||||||
|
2. URL-состояние для вкладок и фильтров;
|
||||||
|
3. асинхронный поиск групп, преподавателей, аудиторий и графиков;
|
||||||
|
4. серверная пагинация для групп, пользователей, дисциплин и заявок;
|
||||||
|
5. массовое назначение календарного графика выбранным группам с dry-run;
|
||||||
|
6. предупреждение о несохранённых изменениях календаря и конструктора;
|
||||||
|
7. проектные диалоги вместо `confirm/prompt`;
|
||||||
|
8. «Сегодня» и «Следующая пара» в кабинетах преподавателя и студента;
|
||||||
|
9. единые состояния загрузки, отсутствия данных и ошибки;
|
||||||
|
10. базовые события аналитики для измерения результата.
|
||||||
|
|
||||||
|
После этого пакета следует переходить к чек-листу готовности семестра, ролевым очередям и единому процессу публикации.
|
||||||
|
|
||||||
|
### 20.1. Статус реализации первого пакета — 19 августа 2026
|
||||||
|
|
||||||
|
| № | Статус | Что реализовано |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Выполнено, решение уточнено | Общая панель контекста удалена. В `schedule`, `schedule-versions` и `schedule-quality` используются локальные каскады учебного года, семестра и версии; в `schedule-view` — года и семестра, поскольку экран показывает только опубликованное расписание. |
|
||||||
|
| 2 | Выполнено | Вкладки, фильтры, сортировка, размер и номер страницы сохраняются в query string. Просмотр расписания сохраняет локальные год и семестр; преподаватель — вкладку и неделю, студент — группу и неделю. |
|
||||||
|
| 3 | Выполнено | Добавлены серверные endpoints и общий `AsyncCombobox` для групп, преподавателей, аудиторий и совместимых календарных графиков. Первый выбор группы студента и выбор преподавателя при регистрации отсутствия больше не загружают полный справочник. |
|
||||||
|
| 4 | Выполнено | Группы, пользователи, дисциплины и заявки преподавателей используют серверные поиск, фильтры, сортировку и пагинацию 25/50/100 с общим `PageResponse`. Для страницы дисциплин загружаются только её привязки преподавателей, а подгруппы запрашиваются только для открытой или выбранной в конструкторе группы. |
|
||||||
|
| 5 | Отложено по решению заказчика | Массовое назначение одного календарного графика нескольким совместимым группам и dry-run в этот пакет не входят. Текущее одиночное назначение сохранено. |
|
||||||
|
| 6 | Выполнено | Календарная сетка, привязки дисциплин и конструктор правил отслеживают несохранённые изменения, показывают индикатор на кнопке и предупреждают при уходе, смене графика или версии. |
|
||||||
|
| 7 | Выполнено | Нативные `confirm`, `prompt` и `alert` в административных и settings-модулях заменены единым доступным проектным диалогом. |
|
||||||
|
| 8 | Выполнено | В кабинетах преподавателя и студента появились блок «Сегодня» и карточка «Следующая пара» с поиском занятия в горизонте 14 дней. |
|
||||||
|
| 9 | Выполнено | Для серверных реестров внедрены общие loading/empty/error-состояния с повтором; для ближайших занятий применяется единый компонент состояния. |
|
||||||
|
| 10 | Выполнено | Добавлены прикладные UX-события: смена раздела, загрузка реестра/расписания, выбор группы, сохранение правила, сетки и привязок. В production события преобразуются в OpenTelemetry spans. |
|
||||||
|
|
||||||
|
Дополнительно закрыты три согласованных требования масштабирования: серверные
|
||||||
|
поиск/фильтрация/сортировка/пагинация реестров, асинхронные селекты больших справочников и
|
||||||
|
восстановление фильтров/страницы из URL. Ролевое ограничение кафедры повторно применяется
|
||||||
|
backend и не зависит от переданного frontend-фильтра.
|
||||||
|
|
||||||
|
Проверка реализации: `npm run check` выполняет синтаксический контроль новых модулей и все
|
||||||
|
18 frontend-наборов; отдельный тест закрепляет выбор сегодняшних занятий, текущей и следующей
|
||||||
|
пары. Production-сборка frontend и 339 backend-тестов через полный `mvn test` в Java 17
|
||||||
|
контейнере проходят успешно. Массовое назначение из пункта 5 остаётся единственным
|
||||||
|
сознательно незавершённым пунктом этого пакета.
|
||||||
|
|
||||||
|
## 21. Источники анализа в проекте
|
||||||
|
|
||||||
|
- `docs/BUSINESS_LOGIC.md` — роли и бизнес-процессы;
|
||||||
|
- `docs/FRONTEND.md` — маршруты и текущее поведение экранов;
|
||||||
|
- `docs/API.md` — возможности и ограничения API;
|
||||||
|
- `frontend/admin/js/role-capabilities.js` — фактическая матрица вкладок;
|
||||||
|
- `frontend/admin/views/` и `frontend/admin/js/views/` — административные сценарии;
|
||||||
|
- `frontend/teacher/` — кабинет преподавателя;
|
||||||
|
- `frontend/student/` — кабинет студента.
|
||||||
55
UX_SITE_CHECKLIST.md
Normal file
55
UX_SITE_CHECKLIST.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Чек-лист проверки UX-изменений на сайте
|
||||||
|
|
||||||
|
## Перед проверкой
|
||||||
|
|
||||||
|
1. Запустите проект: `docker compose up -d --build`.
|
||||||
|
2. Откройте [http://localhost:80](http://localhost:80) или production-сайт.
|
||||||
|
3. Для полной проверки нужны пользователи с ролями `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `TEACHER` и `STUDENT`.
|
||||||
|
4. Пагинацию можно полноценно проверить, если в справочнике больше 25 записей.
|
||||||
|
|
||||||
|
## Что проверить
|
||||||
|
|
||||||
|
| № | Роль и куда зайти | Что нажать | Что должно быть видно |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `ADMIN` → **Дашборд**, **Группы обучения** или **Пользователи** | Просто откройте вкладки | Под заголовком нет общей панели **КОНТЕКСТ**; она не занимает место в разделах, которым учебный период не нужен |
|
||||||
|
| 2 | `ADMIN` → **Группы обучения** | Введите текст в **Поиск**, выберите форму обучения и сортировку, затем обновите страницу | Фильтры остаются выбранными, а в адресной строке появляются параметры `groupQuery`, `groupEducationForm`, `groupSort`, `groupPage` |
|
||||||
|
| 3 | `ADMIN` → **Группы обучения**, **Пользователи**, **Дисциплины**, **Заявки** | Используйте поиск, фильтры, сортировку, **Назад / Далее** и список **На странице: 25 / 50 / 100** | Таблица обновляется без полной загрузки всех записей; показан диапазон вида «Показано 1–25 из …» |
|
||||||
|
| 4 | `ADMIN` или `EDUCATION_OFFICE` → **Просмотр расписаний** | Откройте вкладку, не нажимая на поле **Группа**; затем раскройте **Дополнительные фильтры**, выберите **Учебный год** и **Семестр** и нажмите **Показать** | При открытии список групп свёрнут; семестры ограничиваются выбранным годом, значения сохраняются в URL; экран показывает опубликованное расписание |
|
||||||
|
| 5 | `ADMIN` → **Группы обучения** → у группы кнопка **Календарь** | Выберите учебный год и начните печатать название графика | Совместимые календарные графики ищутся асинхронно; весь справочник заранее не загружается. Сохранять назначение для проверки не обязательно |
|
||||||
|
| 6 | `ADMIN` или `EDUCATION_OFFICE` → **Конструктор правил** | В верхней карточке по очереди выберите **Учебный год**, **Семестр**, **Версию расписания**; затем раскройте **Группы** и найдите преподавателя или аудиторию | Каждый селект ограничивает следующий; загружаются версии только выбранного семестра, а большие справочники ищутся через поисковые списки |
|
||||||
|
| 7 | `ADMIN` или `EDUCATION_OFFICE` → **Запросы преподавателей** → блок **Зарегистрировать отсутствие** | Начните вводить ФИО преподавателя из любой кафедры | Поиск находит преподавателя, а список результатов отображается поверх блока **Реестр инцидентов**, не прячется за ним |
|
||||||
|
| 8 | `ADMIN` → **Календарный график** → **Сетки** | Выберите график → **Загрузить сетку** → измените ячейку или примените **Заполнить диапазон** → нажмите другую вкладку слева | Кнопка меняется на **Сохранить сетку •**, затем появляется диалог **Несохранённые изменения** с действиями **Остаться** и **Покинуть без сохранения** |
|
||||||
|
| 9 | `ADMIN` → **Конструктор правил** | Сначала сразу перейдите в другой раздел, затем вернитесь, измените любое поле правила и снова попробуйте уйти | Без изменений переход выполняется без предупреждения; после ручного изменения появляется диалог **Несохранённые изменения** |
|
||||||
|
| 10 | `ADMIN` → **Группы обучения** | У активной группы нажмите **Архив**, затем в диалоге нажмите **Отмена** | Появляется проектный диалог **Архивировать группу?**, а не системное окно браузера; данные после отмены не меняются |
|
||||||
|
| 11 | `TEACHER` → **Расписание** | Просто откройте кабинет; затем нажмите **Сегодня** | Над недельным расписанием видны карточки **Сегодня** и **Следующая пара**; кнопка возвращает к текущей неделе |
|
||||||
|
| 12 | `STUDENT` → страница расписания | Начните вводить название группы, выберите её и нажмите **Сегодня** | Группа ищется асинхронно; появляются карточки **Сегодня** и **Следующая пара**; выбранная группа сохраняется после перезагрузки |
|
||||||
|
| 13 | `TEACHER` → **Мои пожелания** или **Заявки на изменение** | Переключите вкладку и обновите страницу | Открытая вкладка восстанавливается из URL; выбранная неделя также сохраняется |
|
||||||
|
| 14 | Любой серверный реестр, например `ADMIN` → **Дисциплины** | Введите заведомо отсутствующую строку, например `___нет_такой_записи___` | Появляется понятное пустое состояние **Дисциплины не найдены**, а не пустая таблица |
|
||||||
|
| 15 | `ADMIN` или `EDUCATION_OFFICE` → **Версии расписания**, затем **Анализ качества** | На обеих вкладках смените **Учебный год** и **Семестр**; в анализе дополнительно выберите **Версию** | В «Версиях» обновляются карточки публикации/черновиков выбранного семестра, в «Анализе качества» — список доступных версий |
|
||||||
|
|
||||||
|
## Дополнительные проверки
|
||||||
|
|
||||||
|
### Ошибка и повторная загрузка
|
||||||
|
|
||||||
|
1. Откройте DevTools → **Network** → включите **Offline**.
|
||||||
|
2. Обновите реестр групп, пользователей, дисциплин или заявок.
|
||||||
|
3. Должно появиться сообщение об ошибке и кнопка **Повторить**.
|
||||||
|
4. Верните **Online** и нажмите **Повторить** — данные должны загрузиться.
|
||||||
|
|
||||||
|
### UX-аналитика
|
||||||
|
|
||||||
|
До выполнения действий вставьте в консоль браузера:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('magistr:ux-event', event => console.log(event.detail));
|
||||||
|
```
|
||||||
|
|
||||||
|
После смены вкладки, фильтра или загрузки расписания в консоли должны появляться UX-события. На production они также преобразуются в OpenTelemetry spans.
|
||||||
|
|
||||||
|
### Ролевое ограничение кафедры
|
||||||
|
|
||||||
|
Войдите как `DEPARTMENT` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий.
|
||||||
|
|
||||||
|
## Что пока не проверяем
|
||||||
|
|
||||||
|
Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**.
|
||||||
@@ -8,6 +8,8 @@ import com.magistr.app.service.AcademicCalendarGridService;
|
|||||||
import com.magistr.app.service.AcademicStructureService;
|
import com.magistr.app.service.AcademicStructureService;
|
||||||
import com.magistr.app.service.ScheduleGeneratorService;
|
import com.magistr.app.service.ScheduleGeneratorService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -58,6 +62,46 @@ public class AcademicCalendarController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<AcademicCalendarDto> getCalendarsPage(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Long academicYearId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return PageResponse.from(calendarRepository.searchOptions(
|
||||||
|
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
|
||||||
|
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
|
||||||
|
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
|
||||||
|
.map(this::toCalendarDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/options")
|
||||||
|
public List<AcademicCalendarDto> searchCalendarOptions(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Long academicYearId,
|
||||||
|
@RequestParam(required = false) Long specialtyId,
|
||||||
|
@RequestParam(required = false) Long profileId,
|
||||||
|
@RequestParam(required = false) Long studyFormId,
|
||||||
|
@RequestParam(required = false) Long selectedId,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
List<AcademicCalendarDto> options = new ArrayList<>(calendarRepository.searchOptions(
|
||||||
|
normalizedQuery,
|
||||||
|
academicYearId,
|
||||||
|
specialtyId,
|
||||||
|
profileId,
|
||||||
|
studyFormId,
|
||||||
|
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("title").ascending())
|
||||||
|
).stream()
|
||||||
|
.map(this::toCalendarDto)
|
||||||
|
.toList());
|
||||||
|
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.id()))) {
|
||||||
|
calendarRepository.findByIdWithDetails(selectedId).map(this::toCalendarDto).ifPresent(options::add);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
|
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
|
||||||
return calendarRepository.findByIdWithDetails(id)
|
return calendarRepository.findByIdWithDetails(id)
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import com.magistr.app.model.Role;
|
|||||||
import com.magistr.app.repository.ClassroomRepository;
|
import com.magistr.app.repository.ClassroomRepository;
|
||||||
import com.magistr.app.repository.EquipmentRepository;
|
import com.magistr.app.repository.EquipmentRepository;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/classrooms")
|
@RequestMapping("/api/classrooms")
|
||||||
@@ -40,6 +44,26 @@ public class ClassroomController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/options")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
|
public List<ClassroomResponse> searchClassroomOptions(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Long selectedId,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
List<ClassroomResponse> options = new ArrayList<>(classroomRepository.searchActiveOptions(
|
||||||
|
normalizedQuery,
|
||||||
|
LifecycleEntity.STATUS_ARCHIVED,
|
||||||
|
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("name").ascending())
|
||||||
|
).stream()
|
||||||
|
.map(this::mapToResponse)
|
||||||
|
.toList());
|
||||||
|
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||||
|
classroomRepository.findById(selectedId).map(this::mapToResponse).ifPresent(options::add);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
|
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
|
||||||
if (request.getName() == null || request.getName().isBlank()) {
|
if (request.getName() == null || request.getName().isBlank()) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||||
import com.magistr.app.dto.CreateGroupRequest;
|
import com.magistr.app.dto.CreateGroupRequest;
|
||||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||||
import com.magistr.app.dto.GroupResponse;
|
import com.magistr.app.dto.GroupResponse;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
import com.magistr.app.model.AcademicCalendarSubject;
|
import com.magistr.app.model.AcademicCalendarSubject;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.Speciality;
|
import com.magistr.app.model.Speciality;
|
||||||
@@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -98,6 +108,118 @@ public class GroupController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
public PageResponse<GroupResponse> getGroupsPage(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) List<Long> educationFormId,
|
||||||
|
@RequestParam(required = false) Long departmentId,
|
||||||
|
@RequestParam(required = false) Integer yearStartStudy,
|
||||||
|
@RequestParam(defaultValue = "true") boolean includeArchived,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size,
|
||||||
|
@RequestParam(defaultValue = "name") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String direction) {
|
||||||
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
|
Specification<StudentGroup> specification = groupSpecification(
|
||||||
|
query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived
|
||||||
|
);
|
||||||
|
Page<StudentGroup> groups = groupRepository.findAll(
|
||||||
|
specification,
|
||||||
|
PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction))
|
||||||
|
);
|
||||||
|
return PageResponse.from(groups.map(this::mapToResponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/options")
|
||||||
|
public List<GroupResponse> searchGroupOptions(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Long departmentId,
|
||||||
|
@RequestParam(required = false) Long selectedId,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
|
List<GroupResponse> options = new ArrayList<>(groupRepository.findAll(
|
||||||
|
groupSpecification(query, null, effectiveDepartmentId, null, false),
|
||||||
|
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("name").ascending())
|
||||||
|
).stream()
|
||||||
|
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||||
|
.map(this::mapToResponse)
|
||||||
|
.toList());
|
||||||
|
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||||
|
groupRepository.findById(selectedId)
|
||||||
|
.filter(group -> effectiveDepartmentId == null
|
||||||
|
|| Objects.equals(group.getDepartmentId(), effectiveDepartmentId))
|
||||||
|
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||||
|
.map(this::mapToResponse)
|
||||||
|
.ifPresent(options::add);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||||
|
var currentUser = AuthContext.getCurrentUser();
|
||||||
|
return currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||||
|
? currentUser.departmentId()
|
||||||
|
: requestedDepartmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<StudentGroup> groupSpecification(String query,
|
||||||
|
List<Long> educationFormIds,
|
||||||
|
Long departmentId,
|
||||||
|
Integer yearStartStudy,
|
||||||
|
boolean includeArchived) {
|
||||||
|
return (root, criteriaQuery, builder) -> {
|
||||||
|
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||||
|
if (!includeArchived) {
|
||||||
|
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||||
|
}
|
||||||
|
if (educationFormIds != null && !educationFormIds.isEmpty()) {
|
||||||
|
predicates.add(root.get("educationForm").get("id").in(educationFormIds));
|
||||||
|
}
|
||||||
|
if (departmentId != null) {
|
||||||
|
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||||
|
}
|
||||||
|
if (yearStartStudy != null) {
|
||||||
|
predicates.add(builder.equal(root.get("yearStartStudy"), yearStartStudy));
|
||||||
|
}
|
||||||
|
String normalizedQuery = normalizeQuery(query);
|
||||||
|
if (!normalizedQuery.isEmpty()) {
|
||||||
|
String pattern = "%" + normalizedQuery + "%";
|
||||||
|
predicates.add(builder.or(
|
||||||
|
builder.like(builder.lower(root.get("name")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("speciality").get("specialityCode")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("speciality").get("specialityName")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("specialtyProfile").get("name")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("educationForm").get("name")), pattern)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Sort groupSort(String field, String direction) {
|
||||||
|
String property = switch (field) {
|
||||||
|
case "yearStartStudy" -> "yearStartStudy";
|
||||||
|
case "groupSize" -> "groupSize";
|
||||||
|
case "status" -> "status";
|
||||||
|
default -> "name";
|
||||||
|
};
|
||||||
|
return "desc".equalsIgnoreCase(direction)
|
||||||
|
? Sort.by(property).descending()
|
||||||
|
: Sort.by(property).ascending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizePage(int page) {
|
||||||
|
return Math.max(page, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizeSize(int size) {
|
||||||
|
return Math.max(1, Math.min(size, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeQuery(String query) {
|
||||||
|
return query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{departmentId}")
|
@GetMapping("/{departmentId}")
|
||||||
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.magistr.app.controller;
|
|||||||
|
|
||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.ScheduleRuleDto;
|
import com.magistr.app.dto.ScheduleRuleDto;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.service.ScheduleRuleConflictException;
|
import com.magistr.app.service.ScheduleRuleConflictException;
|
||||||
import com.magistr.app.service.ScheduleRuleService;
|
import com.magistr.app.service.ScheduleRuleService;
|
||||||
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
|
|||||||
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
public PageResponse<ScheduleRuleDto> getPage(
|
||||||
|
@RequestParam Long versionId,
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return scheduleRuleService.getPage(versionId, query, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
|
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
|
||||||
return ResponseEntity.ok(scheduleRuleService.getById(id));
|
return ResponseEntity.ok(scheduleRuleService.getById(id));
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import com.magistr.app.service.ScheduleGeneratorService;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
|
||||||
public class SubgroupController {
|
public class SubgroupController {
|
||||||
@@ -36,8 +39,16 @@ public class SubgroupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/subgroups")
|
@GetMapping("/api/subgroups")
|
||||||
public List<SubgroupDto> getAll() {
|
public List<SubgroupDto> getAll(@RequestParam(required = false) List<Long> groupId) {
|
||||||
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
|
if (groupId != null && groupId.size() > 100) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп");
|
||||||
|
}
|
||||||
|
List<Subgroup> selected = groupId == null
|
||||||
|
? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()
|
||||||
|
: groupId.isEmpty()
|
||||||
|
? List.of()
|
||||||
|
: subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList());
|
||||||
|
return selected.stream()
|
||||||
.map(this::toDto)
|
.map(this::toDto)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.CreateSubjectRequest;
|
import com.magistr.app.dto.CreateSubjectRequest;
|
||||||
import com.magistr.app.dto.SubjectResponse;
|
import com.magistr.app.dto.SubjectResponse;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
import com.magistr.app.model.LifecycleEntity;
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.Subject;
|
import com.magistr.app.model.Subject;
|
||||||
@@ -11,9 +13,14 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -53,6 +60,74 @@ public class SubjectController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
|
public PageResponse<SubjectResponse> getSubjectsPage(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Long departmentId,
|
||||||
|
@RequestParam(defaultValue = "false") boolean includeArchived,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size,
|
||||||
|
@RequestParam(defaultValue = "name") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String direction) {
|
||||||
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
|
var subjects = subjectRepository.findAll(
|
||||||
|
subjectSpecification(query, effectiveDepartmentId, includeArchived),
|
||||||
|
PageRequest.of(
|
||||||
|
Math.max(page, 0),
|
||||||
|
Math.max(1, Math.min(size, 100)),
|
||||||
|
subjectSort(sort, direction)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return PageResponse.from(subjects.map(this::toResponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||||
|
var currentUser = AuthContext.getCurrentUser();
|
||||||
|
return currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||||
|
? currentUser.departmentId()
|
||||||
|
: requestedDepartmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<Subject> subjectSpecification(String query,
|
||||||
|
Long departmentId,
|
||||||
|
boolean includeArchived) {
|
||||||
|
return (root, criteriaQuery, builder) -> {
|
||||||
|
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||||
|
if (!includeArchived) {
|
||||||
|
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||||
|
}
|
||||||
|
if (departmentId != null) {
|
||||||
|
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||||
|
}
|
||||||
|
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!normalizedQuery.isEmpty()) {
|
||||||
|
String pattern = "%" + normalizedQuery + "%";
|
||||||
|
predicates.add(builder.or(
|
||||||
|
builder.like(builder.lower(root.get("name")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("code")), pattern)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Sort subjectSort(String field, String direction) {
|
||||||
|
String property = switch (field) {
|
||||||
|
case "code" -> "code";
|
||||||
|
case "departmentId" -> "departmentId";
|
||||||
|
case "status" -> "status";
|
||||||
|
default -> "name";
|
||||||
|
};
|
||||||
|
return "desc".equalsIgnoreCase(direction)
|
||||||
|
? Sort.by(property).descending()
|
||||||
|
: Sort.by(property).ascending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubjectResponse toResponse(Subject subject) {
|
||||||
|
return new SubjectResponse(subject.getId(), subject.getName(), subject.getCode(), subject.getDepartmentId());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{departmentId}")
|
@GetMapping("/{departmentId}")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
|
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.magistr.app.config.auth.RequireRoles;
|
|||||||
import com.magistr.app.dto.ReviewTeacherCreationRequest;
|
import com.magistr.app.dto.ReviewTeacherCreationRequest;
|
||||||
import com.magistr.app.dto.TeacherCreationRequestResponse;
|
import com.magistr.app.dto.TeacherCreationRequestResponse;
|
||||||
import com.magistr.app.dto.UserResponse;
|
import com.magistr.app.dto.UserResponse;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
import com.magistr.app.model.*;
|
import com.magistr.app.model.*;
|
||||||
import com.magistr.app.repository.DepartmentRepository;
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||||
@@ -12,11 +13,16 @@ import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
|||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import com.magistr.app.service.BusinessTimeService;
|
import com.magistr.app.service.BusinessTimeService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -55,6 +61,63 @@ public class TeacherCreationRequestController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
public PageResponse<TeacherCreationRequestResponse> getRequestsPage(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) TeacherCreationRequestStatus status,
|
||||||
|
@RequestParam(required = false) Long departmentId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size,
|
||||||
|
@RequestParam(defaultValue = "createdAt") String sort,
|
||||||
|
@RequestParam(defaultValue = "desc") String direction) {
|
||||||
|
var requests = teacherCreationRequestRepository.findAll(
|
||||||
|
requestSpecification(query, status, departmentId),
|
||||||
|
PageRequest.of(
|
||||||
|
Math.max(page, 0),
|
||||||
|
Math.max(1, Math.min(size, 100)),
|
||||||
|
requestSort(sort, direction)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return PageResponse.from(requests.map(this::toResponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<TeacherCreationRequest> requestSpecification(String query,
|
||||||
|
TeacherCreationRequestStatus status,
|
||||||
|
Long departmentId) {
|
||||||
|
return (root, criteriaQuery, builder) -> {
|
||||||
|
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||||
|
if (status != null) {
|
||||||
|
predicates.add(builder.equal(root.get("status"), status));
|
||||||
|
}
|
||||||
|
if (departmentId != null) {
|
||||||
|
predicates.add(builder.equal(root.get("department").get("id"), departmentId));
|
||||||
|
}
|
||||||
|
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!normalizedQuery.isEmpty()) {
|
||||||
|
String pattern = "%" + normalizedQuery + "%";
|
||||||
|
predicates.add(builder.or(
|
||||||
|
builder.like(builder.lower(root.get("username")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("fullName")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("jobTitle")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("department").get("departmentName")), pattern)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Sort requestSort(String field, String direction) {
|
||||||
|
String property = switch (field) {
|
||||||
|
case "fullName" -> "fullName";
|
||||||
|
case "username" -> "username";
|
||||||
|
case "status" -> "status";
|
||||||
|
default -> "createdAt";
|
||||||
|
};
|
||||||
|
return "asc".equalsIgnoreCase(direction)
|
||||||
|
? Sort.by(property).ascending()
|
||||||
|
: Sort.by(property).descending();
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
@PostMapping("/{id}/approve")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
|
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ import com.magistr.app.service.BusinessTimeService;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/teacher-subjects")
|
@RequestMapping("/api/teacher-subjects")
|
||||||
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||||
@@ -53,15 +56,18 @@ public class TeacherSubjectController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<TeacherSubjectResponse> getAll() {
|
public List<TeacherSubjectResponse> getAll(@RequestParam(required = false) List<Long> subjectId) {
|
||||||
return teacherSubjectRepository.findAll().stream()
|
if (subjectId != null && subjectId.size() > 100) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин");
|
||||||
|
}
|
||||||
|
List<TeacherSubject> assignments = subjectId == null
|
||||||
|
? teacherSubjectRepository.findAll()
|
||||||
|
: subjectId.isEmpty()
|
||||||
|
? List.of()
|
||||||
|
: teacherSubjectRepository.findBySubjectIdInWithDetails(subjectId.stream().distinct().toList());
|
||||||
|
return assignments.stream()
|
||||||
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
|
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
|
||||||
.map(ts -> new TeacherSubjectResponse(
|
.map(this::toResponse)
|
||||||
ts.getUserId(),
|
|
||||||
ts.getUser().getUsername(),
|
|
||||||
ts.getSubjectId(),
|
|
||||||
ts.getSubject().getName()
|
|
||||||
))
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,4 +142,14 @@ public class TeacherSubjectController {
|
|||||||
)
|
)
|
||||||
&& departmentId.equals(subject.getDepartmentId());
|
&& departmentId.equals(subject.getDepartmentId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TeacherSubjectResponse toResponse(TeacherSubject teacherSubject) {
|
||||||
|
return new TeacherSubjectResponse(
|
||||||
|
teacherSubject.getUserId(),
|
||||||
|
teacherSubject.getUser().getUsername(),
|
||||||
|
teacherSubject.getUser().getFullName(),
|
||||||
|
teacherSubject.getSubjectId(),
|
||||||
|
teacherSubject.getSubject().getName()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.magistr.app.dto.CreateUserRequest;
|
|||||||
import com.magistr.app.dto.DepartmentTransferRequest;
|
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||||
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
|
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
|
||||||
import com.magistr.app.dto.UserResponse;
|
import com.magistr.app.dto.UserResponse;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
import com.magistr.app.model.Department;
|
import com.magistr.app.model.Department;
|
||||||
import com.magistr.app.model.LifecycleEntity;
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
@@ -22,11 +23,16 @@ import org.springframework.format.annotation.DateTimeFormat;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -80,6 +86,23 @@ public class UserController {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
public PageResponse<UserResponse> getUsersPage(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Role role,
|
||||||
|
@RequestParam(required = false) Long departmentId,
|
||||||
|
@RequestParam(defaultValue = "true") boolean includeArchived,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size,
|
||||||
|
@RequestParam(defaultValue = "fullName") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String direction) {
|
||||||
|
var users = userRepository.findAll(
|
||||||
|
userSpecification(query, role, departmentId, includeArchived),
|
||||||
|
PageRequest.of(normalizePage(page), normalizeSize(size), userSort(sort, direction))
|
||||||
|
);
|
||||||
|
return PageResponse.from(users.map(this::toUserResponse));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public List<UserResponse> getTeachers() {
|
public List<UserResponse> getTeachers() {
|
||||||
@@ -91,6 +114,92 @@ public class UserController {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/teachers/options")
|
||||||
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
|
public List<UserResponse> searchTeacherOptions(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Long departmentId,
|
||||||
|
@RequestParam(required = false) Long selectedId,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
var currentUser = AuthContext.getCurrentUser();
|
||||||
|
Long effectiveDepartmentId = currentUser != null && currentUser.role() == Role.DEPARTMENT
|
||||||
|
? currentUser.departmentId()
|
||||||
|
: departmentId;
|
||||||
|
LocalDate today = businessTime.today();
|
||||||
|
List<UserResponse> options = new ArrayList<>(userRepository.searchTeacherOptions(
|
||||||
|
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||||
|
Role.TEACHER,
|
||||||
|
LifecycleEntity.STATUS_ARCHIVED,
|
||||||
|
effectiveDepartmentId,
|
||||||
|
today,
|
||||||
|
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("fullName").ascending())
|
||||||
|
).stream()
|
||||||
|
.map(user -> effectiveDepartmentId == null
|
||||||
|
? toUserResponse(user)
|
||||||
|
: toUserResponse(user, effectiveDepartmentId))
|
||||||
|
.toList());
|
||||||
|
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
|
||||||
|
userRepository.findById(selectedId)
|
||||||
|
.filter(user -> user.getRole() == Role.TEACHER)
|
||||||
|
.filter(user -> !user.isArchivedRecord())
|
||||||
|
.filter(user -> effectiveDepartmentId == null
|
||||||
|
|| teacherDepartmentService.hasAssignmentAtDate(user.getId(), effectiveDepartmentId, today))
|
||||||
|
.map(user -> effectiveDepartmentId == null
|
||||||
|
? toUserResponse(user)
|
||||||
|
: toUserResponse(user, effectiveDepartmentId))
|
||||||
|
.ifPresent(options::add);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<User> userSpecification(String query,
|
||||||
|
Role role,
|
||||||
|
Long departmentId,
|
||||||
|
boolean includeArchived) {
|
||||||
|
return (root, criteriaQuery, builder) -> {
|
||||||
|
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
|
||||||
|
if (!includeArchived) {
|
||||||
|
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
|
||||||
|
}
|
||||||
|
if (role != null) {
|
||||||
|
predicates.add(builder.equal(root.get("role"), role));
|
||||||
|
}
|
||||||
|
if (departmentId != null) {
|
||||||
|
predicates.add(builder.equal(root.get("departmentId"), departmentId));
|
||||||
|
}
|
||||||
|
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!normalizedQuery.isEmpty()) {
|
||||||
|
String pattern = "%" + normalizedQuery + "%";
|
||||||
|
predicates.add(builder.or(
|
||||||
|
builder.like(builder.lower(root.get("username")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("fullName")), pattern),
|
||||||
|
builder.like(builder.lower(root.get("jobTitle")), pattern)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Sort userSort(String field, String direction) {
|
||||||
|
String property = switch (field) {
|
||||||
|
case "username" -> "username";
|
||||||
|
case "role" -> "role";
|
||||||
|
case "status" -> "status";
|
||||||
|
default -> "fullName";
|
||||||
|
};
|
||||||
|
return "desc".equalsIgnoreCase(direction)
|
||||||
|
? Sort.by(property).descending()
|
||||||
|
: Sort.by(property).ascending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizePage(int page) {
|
||||||
|
return Math.max(page, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizeSize(int size) {
|
||||||
|
return Math.max(1, Math.min(size, 100));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers/{departmentId}")
|
@GetMapping("/teachers/{departmentId}")
|
||||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||||
|
|||||||
27
backend/src/main/java/com/magistr/app/dto/PageResponse.java
Normal file
27
backend/src/main/java/com/magistr/app/dto/PageResponse.java
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record PageResponse<T>(
|
||||||
|
List<T> items,
|
||||||
|
int page,
|
||||||
|
int size,
|
||||||
|
long totalItems,
|
||||||
|
int totalPages,
|
||||||
|
boolean first,
|
||||||
|
boolean last
|
||||||
|
) {
|
||||||
|
public static <T> PageResponse<T> from(Page<T> page) {
|
||||||
|
return new PageResponse<>(
|
||||||
|
page.getContent(),
|
||||||
|
page.getNumber(),
|
||||||
|
page.getSize(),
|
||||||
|
page.getTotalElements(),
|
||||||
|
page.getTotalPages(),
|
||||||
|
page.isFirst(),
|
||||||
|
page.isLast()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ public class TeacherSubjectResponse {
|
|||||||
|
|
||||||
private Long userId;
|
private Long userId;
|
||||||
private String username;
|
private String username;
|
||||||
|
private String fullName;
|
||||||
private Long subjectId;
|
private Long subjectId;
|
||||||
private String subjectName;
|
private String subjectName;
|
||||||
|
|
||||||
public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
|
public TeacherSubjectResponse(Long userId, String username, String fullName, Long subjectId, String subjectName) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
|
this.fullName = fullName;
|
||||||
this.subjectId = subjectId;
|
this.subjectId = subjectId;
|
||||||
this.subjectName = subjectName;
|
this.subjectName = subjectName;
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,10 @@ public class TeacherSubjectResponse {
|
|||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getFullName() {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getSubjectId() {
|
public Long getSubjectId() {
|
||||||
return subjectId;
|
return subjectId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ package com.magistr.app.repository;
|
|||||||
import com.magistr.app.model.AcademicCalendar;
|
import com.magistr.app.model.AcademicCalendar;
|
||||||
import jakarta.persistence.LockModeType;
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long> {
|
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long>, JpaSpecificationExecutor<AcademicCalendar> {
|
||||||
|
|
||||||
long countByStudyFormId(Long studyFormId);
|
long countByStudyFormId(Long studyFormId);
|
||||||
|
|
||||||
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
|
|||||||
where calendar.id = :id
|
where calendar.id = :id
|
||||||
""")
|
""")
|
||||||
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
|
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
select calendar
|
||||||
|
from AcademicCalendar calendar
|
||||||
|
join fetch calendar.academicYear academicYear
|
||||||
|
join fetch calendar.speciality speciality
|
||||||
|
join fetch calendar.specialtyProfile specialtyProfile
|
||||||
|
join fetch calendar.studyForm studyForm
|
||||||
|
where (:query = ''
|
||||||
|
or lower(calendar.title) like concat('%', :query, '%')
|
||||||
|
or lower(academicYear.title) like concat('%', :query, '%')
|
||||||
|
or lower(speciality.specialityCode) like concat('%', :query, '%')
|
||||||
|
or lower(speciality.specialityName) like concat('%', :query, '%')
|
||||||
|
or lower(specialtyProfile.name) like concat('%', :query, '%')
|
||||||
|
or lower(studyForm.name) like concat('%', :query, '%'))
|
||||||
|
and (:academicYearId is null or academicYear.id = :academicYearId)
|
||||||
|
and (:specialtyId is null or speciality.id = :specialtyId)
|
||||||
|
and (:profileId is null or specialtyProfile.id = :profileId)
|
||||||
|
and (:studyFormId is null or studyForm.id = :studyFormId)
|
||||||
|
""",
|
||||||
|
countQuery = """
|
||||||
|
select count(calendar)
|
||||||
|
from AcademicCalendar calendar
|
||||||
|
join calendar.academicYear academicYear
|
||||||
|
join calendar.speciality speciality
|
||||||
|
join calendar.specialtyProfile specialtyProfile
|
||||||
|
join calendar.studyForm studyForm
|
||||||
|
where (:query = ''
|
||||||
|
or lower(calendar.title) like concat('%', :query, '%')
|
||||||
|
or lower(academicYear.title) like concat('%', :query, '%')
|
||||||
|
or lower(speciality.specialityCode) like concat('%', :query, '%')
|
||||||
|
or lower(speciality.specialityName) like concat('%', :query, '%')
|
||||||
|
or lower(specialtyProfile.name) like concat('%', :query, '%')
|
||||||
|
or lower(studyForm.name) like concat('%', :query, '%'))
|
||||||
|
and (:academicYearId is null or academicYear.id = :academicYearId)
|
||||||
|
and (:specialtyId is null or speciality.id = :specialtyId)
|
||||||
|
and (:profileId is null or specialtyProfile.id = :profileId)
|
||||||
|
and (:studyFormId is null or studyForm.id = :studyFormId)
|
||||||
|
""")
|
||||||
|
Page<AcademicCalendar> searchOptions(@Param("query") String query,
|
||||||
|
@Param("academicYearId") Long academicYearId,
|
||||||
|
@Param("specialtyId") Long specialtyId,
|
||||||
|
@Param("profileId") Long profileId,
|
||||||
|
@Param("studyFormId") Long studyFormId,
|
||||||
|
Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,29 @@ package com.magistr.app.repository;
|
|||||||
|
|
||||||
import com.magistr.app.model.Classroom;
|
import com.magistr.app.model.Classroom;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
|
public interface ClassroomRepository extends JpaRepository<Classroom, Long>, JpaSpecificationExecutor<Classroom> {
|
||||||
Optional<Classroom> findByName(String name);
|
Optional<Classroom> findByName(String name);
|
||||||
|
|
||||||
List<Classroom> findByStatusNot(String status);
|
List<Classroom> findByStatusNot(String status);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select classroom
|
||||||
|
from Classroom classroom
|
||||||
|
where classroom.status <> :archivedStatus
|
||||||
|
and (:query = ''
|
||||||
|
or lower(classroom.name) like concat('%', :query, '%')
|
||||||
|
or lower(coalesce(classroom.building, '')) like concat('%', :query, '%'))
|
||||||
|
""")
|
||||||
|
Page<Classroom> searchActiveOptions(@Param("query") String query,
|
||||||
|
@Param("archivedStatus") String archivedStatus,
|
||||||
|
Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.magistr.app.repository;
|
|||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import jakarta.persistence.LockModeType;
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -10,7 +11,7 @@ import org.springframework.data.repository.query.Param;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
public interface GroupRepository extends JpaRepository<StudentGroup, Long>, JpaSpecificationExecutor<StudentGroup> {
|
||||||
|
|
||||||
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.magistr.app.repository;
|
|||||||
import com.magistr.app.model.ScheduleRule;
|
import com.magistr.app.model.ScheduleRule;
|
||||||
import jakarta.persistence.LockModeType;
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -13,6 +15,23 @@ import java.util.Collection;
|
|||||||
|
|
||||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||||
|
|
||||||
|
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
|
||||||
|
@Query("""
|
||||||
|
select r.id from ScheduleRule r
|
||||||
|
join r.scheduleVersion version
|
||||||
|
join r.subject subject
|
||||||
|
where r.status <> 'ARCHIVED'
|
||||||
|
and version.id = :versionId
|
||||||
|
and (:query = '' or str(r.id) = :query
|
||||||
|
or lower(subject.name) like concat('%', :query, '%')
|
||||||
|
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
|
||||||
|
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
|
||||||
|
order by r.id desc
|
||||||
|
""")
|
||||||
|
Page<Long> searchPageIds(
|
||||||
|
@Param("versionId") Long versionId, @Param("query") String query,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
where r.id = :id
|
where r.id = :id
|
||||||
""")
|
""")
|
||||||
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select distinct r
|
||||||
|
from ScheduleRule r
|
||||||
|
left join fetch r.subject
|
||||||
|
left join fetch r.semester sem
|
||||||
|
left join fetch sem.academicYear
|
||||||
|
left join fetch r.scheduleVersion
|
||||||
|
left join fetch r.groups groups
|
||||||
|
left join fetch r.slots slots
|
||||||
|
left join fetch slots.timeSlot
|
||||||
|
left join fetch slots.subgroups slotSubgroups
|
||||||
|
left join fetch slotSubgroups.studentGroup
|
||||||
|
left join fetch slots.teacher
|
||||||
|
left join fetch slots.classroom
|
||||||
|
left join fetch slots.lessonType
|
||||||
|
where r.id in :ids
|
||||||
|
""")
|
||||||
|
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
|||||||
""")
|
""")
|
||||||
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
|
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select subgroup
|
||||||
|
from Subgroup subgroup
|
||||||
|
join fetch subgroup.studentGroup studentGroup
|
||||||
|
where studentGroup.id in :groupIds
|
||||||
|
and subgroup.status <> 'ARCHIVED'
|
||||||
|
order by studentGroup.name, subgroup.name
|
||||||
|
""")
|
||||||
|
List<Subgroup> findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List<Long> groupIds);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select subgroup
|
select subgroup
|
||||||
from Subgroup subgroup
|
from Subgroup subgroup
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ package com.magistr.app.repository;
|
|||||||
|
|
||||||
import com.magistr.app.model.Subject;
|
import com.magistr.app.model.Subject;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
public interface SubjectRepository extends JpaRepository<Subject, Long>, JpaSpecificationExecutor<Subject> {
|
||||||
Optional<Subject> findByNameIgnoreCase(String name);
|
Optional<Subject> findByNameIgnoreCase(String name);
|
||||||
|
|
||||||
List<Subject> findByDepartmentId(Long departmentId);
|
List<Subject> findByDepartmentId(Long departmentId);
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package com.magistr.app.repository;
|
|||||||
import com.magistr.app.model.TeacherCreationRequest;
|
import com.magistr.app.model.TeacherCreationRequest;
|
||||||
import com.magistr.app.model.TeacherCreationRequestStatus;
|
import com.magistr.app.model.TeacherCreationRequestStatus;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
|
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long>, JpaSpecificationExecutor<TeacherCreationRequest> {
|
||||||
|
|
||||||
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
|
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject,
|
|||||||
List<TeacherSubject> findByUserId(Long userId);
|
List<TeacherSubject> findByUserId(Long userId);
|
||||||
List<TeacherSubject> findBySubjectId(Long subjectId);
|
List<TeacherSubject> findBySubjectId(Long subjectId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select teacherSubject
|
||||||
|
from TeacherSubject teacherSubject
|
||||||
|
join fetch teacherSubject.user
|
||||||
|
join fetch teacherSubject.subject
|
||||||
|
where teacherSubject.subjectId in :subjectIds
|
||||||
|
order by teacherSubject.subject.name, teacherSubject.user.fullName
|
||||||
|
""")
|
||||||
|
List<TeacherSubject> findBySubjectIdInWithDetails(@Param("subjectIds") List<Long> subjectIds);
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
select subject_link.user_id
|
select subject_link.user_id
|
||||||
from teacher_subjects subject_link
|
from teacher_subjects subject_link
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import com.magistr.app.model.Role;
|
|||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
import jakarta.persistence.LockModeType;
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||||
|
|
||||||
Optional<User> findByUsername(String username);
|
Optional<User> findByUsername(String username);
|
||||||
|
|
||||||
@@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
|
|
||||||
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
|
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
select user
|
||||||
|
from User user
|
||||||
|
where user.role = :role
|
||||||
|
and user.status <> :archivedStatus
|
||||||
|
and (:query = ''
|
||||||
|
or lower(user.username) like concat('%', :query, '%')
|
||||||
|
or lower(user.fullName) like concat('%', :query, '%')
|
||||||
|
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
|
||||||
|
and (:departmentId is null or exists (
|
||||||
|
select assignment.id
|
||||||
|
from TeacherDepartmentAssignment assignment
|
||||||
|
where assignment.teacher.id = user.id
|
||||||
|
and assignment.department.id = :departmentId
|
||||||
|
and assignment.validFrom <= :date
|
||||||
|
and (assignment.validTo is null or assignment.validTo >= :date)
|
||||||
|
))
|
||||||
|
""",
|
||||||
|
countQuery = """
|
||||||
|
select count(user)
|
||||||
|
from User user
|
||||||
|
where user.role = :role
|
||||||
|
and user.status <> :archivedStatus
|
||||||
|
and (:query = ''
|
||||||
|
or lower(user.username) like concat('%', :query, '%')
|
||||||
|
or lower(user.fullName) like concat('%', :query, '%')
|
||||||
|
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
|
||||||
|
and (:departmentId is null or exists (
|
||||||
|
select assignment.id
|
||||||
|
from TeacherDepartmentAssignment assignment
|
||||||
|
where assignment.teacher.id = user.id
|
||||||
|
and assignment.department.id = :departmentId
|
||||||
|
and assignment.validFrom <= :date
|
||||||
|
and (assignment.validTo is null or assignment.validTo >= :date)
|
||||||
|
))
|
||||||
|
""")
|
||||||
|
Page<User> searchTeacherOptions(@Param("query") String query,
|
||||||
|
@Param("role") Role role,
|
||||||
|
@Param("archivedStatus") String archivedStatus,
|
||||||
|
@Param("departmentId") Long departmentId,
|
||||||
|
@Param("date") LocalDate date,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select user from User user where user.id = :id")
|
@Query("select user from User user where user.id = :id")
|
||||||
Optional<User> findByIdForUpdate(@Param("id") Long id);
|
Optional<User> findByIdForUpdate(@Param("id") Long id);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.magistr.app.model.ScheduleVersion;
|
|||||||
import com.magistr.app.model.ScheduleVersionHistory;
|
import com.magistr.app.model.ScheduleVersionHistory;
|
||||||
import com.magistr.app.model.ScheduleVersionStatus;
|
import com.magistr.app.model.ScheduleVersionStatus;
|
||||||
import com.magistr.app.model.Semester;
|
import com.magistr.app.model.Semester;
|
||||||
|
import com.magistr.app.model.SemesterType;
|
||||||
import com.magistr.app.repository.AcademicYearRepository;
|
import com.magistr.app.repository.AcademicYearRepository;
|
||||||
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
|
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
|
||||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||||
@@ -19,13 +20,20 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.NoSuchElementException;
|
import java.util.NoSuchElementException;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AcademicPeriodService {
|
public class AcademicPeriodService {
|
||||||
|
|
||||||
private static final String YEAR_CONFLICT_MESSAGE =
|
private static final String YEAR_CONFLICT_MESSAGE =
|
||||||
"Название или период учебного года конфликтует с существующим учебным годом";
|
"Учебный год или его период конфликтует с существующим учебным годом";
|
||||||
private static final String SEMESTER_CONFLICT_MESSAGE =
|
private static final String SEMESTER_CONFLICT_MESSAGE =
|
||||||
"Тип или период семестра конфликтует с существующим семестром учебного года";
|
"Тип или период семестра конфликтует с существующим семестром учебного года";
|
||||||
|
|
||||||
@@ -49,48 +57,53 @@ public class AcademicPeriodService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AcademicYear createYear(AcademicYearDto request) {
|
public AcademicYear createYear(AcademicYearDto request) {
|
||||||
validateYear(request);
|
YearPeriod period = resolveYearPeriod(request);
|
||||||
String title = request.title().trim();
|
List<SemesterDto> semesterRequests = validateRequestedSemesters(
|
||||||
|
request.semesters(), period.startDate(), period.endDate(), true
|
||||||
|
);
|
||||||
|
String title = academicYearTitle(period.startDate(), period.endDate());
|
||||||
if (academicYearRepository.existsByTitle(title)
|
if (academicYearRepository.existsByTitle(title)
|
||||||
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
|
|| academicYearRepository.existsOverlapping(period.startDate(), period.endDate())) {
|
||||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
AcademicYear year = new AcademicYear();
|
AcademicYear year = new AcademicYear();
|
||||||
applyYear(year, title, request.startDate(), request.endDate());
|
applyYear(year, title, period.startDate(), period.endDate());
|
||||||
AcademicYear saved = saveYear(year);
|
AcademicYear saved = saveYear(year);
|
||||||
|
for (SemesterDto semesterRequest : semesterRequests) {
|
||||||
|
Semester semester = new Semester();
|
||||||
|
semester.setAcademicYear(saved);
|
||||||
|
applySemester(semester, semesterRequest);
|
||||||
|
Semester savedSemester = saveSemester(semester);
|
||||||
|
createInitialPublishedSchedule(savedSemester);
|
||||||
|
}
|
||||||
clearScheduleCacheAfterCommit();
|
clearScheduleCacheAfterCommit();
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AcademicYear updateYear(Long id, AcademicYearDto request) {
|
public AcademicYear updateYear(Long id, AcademicYearDto request) {
|
||||||
validateYear(request);
|
YearPeriod period = resolveYearPeriod(request);
|
||||||
AcademicYear year = lockYear(id);
|
AcademicYear year = lockYear(id);
|
||||||
String title = request.title().trim();
|
String title = academicYearTitle(period.startDate(), period.endDate());
|
||||||
|
|
||||||
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|
||||||
|| academicYearRepository.existsOverlappingExcluding(
|
|| academicYearRepository.existsOverlappingExcluding(
|
||||||
id, request.startDate(), request.endDate())) {
|
id, period.startDate(), period.endDate())) {
|
||||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||||
}
|
}
|
||||||
boolean semesterOutsideNewRange = semesterRepository
|
|
||||||
.findByAcademicYearIdOrderByStartDateAsc(id)
|
|
||||||
.stream()
|
|
||||||
.anyMatch(semester -> !contains(
|
|
||||||
request.startDate(),
|
|
||||||
request.endDate(),
|
|
||||||
semester.getStartDate(),
|
|
||||||
semester.getEndDate()
|
|
||||||
));
|
|
||||||
if (semesterOutsideNewRange) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Новые границы учебного года не включают все его семестры"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyYear(year, title, request.startDate(), request.endDate());
|
List<Semester> existingSemesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(id);
|
||||||
|
List<SemesterDto> requestedSemesters = request.semesters() == null
|
||||||
|
? List.of()
|
||||||
|
: request.semesters();
|
||||||
|
List<SemesterDto> effectiveSemesterRequests = mergeSemesterRequests(
|
||||||
|
existingSemesters, requestedSemesters, period.startDate(), period.endDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
applyYear(year, title, period.startDate(), period.endDate());
|
||||||
AcademicYear saved = saveYear(year);
|
AcademicYear saved = saveYear(year);
|
||||||
|
synchronizeSemesters(saved, existingSemesters, effectiveSemesterRequests, requestedSemesters);
|
||||||
clearScheduleCacheAfterCommit();
|
clearScheduleCacheAfterCommit();
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
@@ -139,19 +152,15 @@ public class AcademicPeriodService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateYear(AcademicYearDto request) {
|
private YearPeriod resolveYearPeriod(AcademicYearDto request) {
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
throw new IllegalArgumentException("Данные учебного года обязательны");
|
throw new IllegalArgumentException("Данные учебного года обязательны");
|
||||||
}
|
}
|
||||||
if (request.title() == null || request.title().isBlank()) {
|
if (request.startDate() == null) {
|
||||||
throw new IllegalArgumentException("Название учебного года обязательно");
|
throw new IllegalArgumentException("Дата начала учебного года обязательна");
|
||||||
}
|
}
|
||||||
validateDateRange(
|
LocalDate endDate = LocalDate.of(request.startDate().getYear() + 1, 6, 30);
|
||||||
request.startDate(),
|
return new YearPeriod(request.startDate(), endDate);
|
||||||
request.endDate(),
|
|
||||||
"Даты начала и окончания учебного года обязательны",
|
|
||||||
"Дата окончания учебного года не может быть раньше даты начала"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateSemester(SemesterDto request) {
|
private void validateSemester(SemesterDto request) {
|
||||||
@@ -189,6 +198,110 @@ public class AcademicPeriodService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<SemesterDto> validateRequestedSemesters(List<SemesterDto> requests,
|
||||||
|
LocalDate yearStart,
|
||||||
|
LocalDate yearEnd,
|
||||||
|
boolean required) {
|
||||||
|
List<SemesterDto> semesters = requests == null ? List.of() : new ArrayList<>(requests);
|
||||||
|
if (required && semesters.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Учебный год нельзя создать без семестров");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<SemesterType> types = new HashSet<>();
|
||||||
|
Set<Long> ids = new HashSet<>();
|
||||||
|
for (SemesterDto semester : semesters) {
|
||||||
|
validateSemester(semester);
|
||||||
|
if (!types.add(semester.semesterType())) {
|
||||||
|
throw new ScheduleConflictException("Типы семестров учебного года не должны повторяться");
|
||||||
|
}
|
||||||
|
if (semester.id() != null && !ids.add(semester.id())) {
|
||||||
|
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
|
||||||
|
}
|
||||||
|
if (!contains(yearStart, yearEnd, semester.startDate(), semester.endDate())) {
|
||||||
|
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SemesterDto> sorted = new ArrayList<>(semesters);
|
||||||
|
sorted.sort(Comparator.comparing(SemesterDto::startDate));
|
||||||
|
for (int index = 1; index < sorted.size(); index++) {
|
||||||
|
SemesterDto previous = sorted.get(index - 1);
|
||||||
|
SemesterDto current = sorted.get(index);
|
||||||
|
if (!previous.endDate().isBefore(current.startDate())) {
|
||||||
|
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SemesterDto> mergeSemesterRequests(List<Semester> existingSemesters,
|
||||||
|
List<SemesterDto> requestedSemesters,
|
||||||
|
LocalDate yearStart,
|
||||||
|
LocalDate yearEnd) {
|
||||||
|
Map<Long, Semester> existingById = new HashMap<>();
|
||||||
|
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
|
||||||
|
|
||||||
|
Map<Long, SemesterDto> requestedById = new HashMap<>();
|
||||||
|
List<SemesterDto> newRequests = new ArrayList<>();
|
||||||
|
for (SemesterDto request : requestedSemesters) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new IllegalArgumentException("Данные семестра обязательны");
|
||||||
|
}
|
||||||
|
if (request.id() == null) {
|
||||||
|
newRequests.add(request);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!existingById.containsKey(request.id())) {
|
||||||
|
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
|
||||||
|
}
|
||||||
|
if (requestedById.put(request.id(), request) != null) {
|
||||||
|
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SemesterDto> effective = new ArrayList<>();
|
||||||
|
for (Semester semester : existingSemesters) {
|
||||||
|
effective.add(requestedById.getOrDefault(semester.getId(), toSemesterRequest(semester)));
|
||||||
|
}
|
||||||
|
effective.addAll(newRequests);
|
||||||
|
return validateRequestedSemesters(effective, yearStart, yearEnd, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void synchronizeSemesters(AcademicYear year,
|
||||||
|
List<Semester> existingSemesters,
|
||||||
|
List<SemesterDto> effectiveRequests,
|
||||||
|
List<SemesterDto> requestedSemesters) {
|
||||||
|
if (effectiveRequests.isEmpty() || requestedSemesters.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<Long, Semester> existingById = new HashMap<>();
|
||||||
|
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
|
||||||
|
for (SemesterDto request : requestedSemesters) {
|
||||||
|
Semester semester = request.id() == null ? new Semester() : existingById.get(request.id());
|
||||||
|
if (semester == null) {
|
||||||
|
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
|
||||||
|
}
|
||||||
|
boolean created = semester.getId() == null;
|
||||||
|
semester.setAcademicYear(year);
|
||||||
|
applySemester(semester, request);
|
||||||
|
Semester saved = saveSemester(semester);
|
||||||
|
if (created) {
|
||||||
|
createInitialPublishedSchedule(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SemesterDto toSemesterRequest(Semester semester) {
|
||||||
|
return new SemesterDto(
|
||||||
|
semester.getId(),
|
||||||
|
semester.getAcademicYear().getId(),
|
||||||
|
semester.getAcademicYear().getTitle(),
|
||||||
|
semester.getSemesterType(),
|
||||||
|
semester.getStartDate(),
|
||||||
|
semester.getEndDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean contains(LocalDate outerStart,
|
private boolean contains(LocalDate outerStart,
|
||||||
LocalDate outerEnd,
|
LocalDate outerEnd,
|
||||||
LocalDate innerStart,
|
LocalDate innerStart,
|
||||||
@@ -226,6 +339,10 @@ public class AcademicPeriodService {
|
|||||||
year.setEndDate(endDate);
|
year.setEndDate(endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String academicYearTitle(LocalDate startDate, LocalDate endDate) {
|
||||||
|
return startDate.getYear() + "-" + endDate.getYear();
|
||||||
|
}
|
||||||
|
|
||||||
private void applySemester(Semester semester, SemesterDto request) {
|
private void applySemester(Semester semester, SemesterDto request) {
|
||||||
semester.setSemesterType(request.semesterType());
|
semester.setSemesterType(request.semesterType());
|
||||||
semester.setStartDate(request.startDate());
|
semester.setStartDate(request.startDate());
|
||||||
@@ -283,4 +400,7 @@ public class AcademicPeriodService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record YearPeriod(LocalDate startDate, LocalDate endDate) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class ScheduleGeneratorService {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
|
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
|
||||||
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
||||||
private static final long MAX_RANGE_DAYS = 120;
|
private static final long MAX_RANGE_DAYS = 200;
|
||||||
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||||
|
|
||||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||||
@@ -328,7 +328,7 @@ public class ScheduleGeneratorService {
|
|||||||
}
|
}
|
||||||
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
||||||
if (inclusiveDays > MAX_RANGE_DAYS) {
|
if (inclusiveDays > MAX_RANGE_DAYS) {
|
||||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class ScheduleOverrideService {
|
public class ScheduleOverrideService {
|
||||||
|
|
||||||
|
private static final long MAX_RANGE_DAYS = 200;
|
||||||
|
|
||||||
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
|
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
|
||||||
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
|
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
|
||||||
|
|
||||||
@@ -706,8 +708,8 @@ public class ScheduleOverrideService {
|
|||||||
if (endDate.isBefore(startDate)) {
|
if (endDate.isBefore(startDate)) {
|
||||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
||||||
}
|
}
|
||||||
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) {
|
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
|
||||||
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней");
|
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 200 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import java.util.stream.Collectors;
|
|||||||
public class ScheduleQueryService {
|
public class ScheduleQueryService {
|
||||||
|
|
||||||
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
|
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
|
||||||
private static final long MAX_RANGE_DAYS = 120;
|
private static final long MAX_RANGE_DAYS = 200;
|
||||||
|
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
@@ -426,7 +426,7 @@ public class ScheduleQueryService {
|
|||||||
}
|
}
|
||||||
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
||||||
if (inclusiveDays > MAX_RANGE_DAYS) {
|
if (inclusiveDays > MAX_RANGE_DAYS) {
|
||||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.magistr.app.service;
|
package com.magistr.app.service;
|
||||||
|
|
||||||
import com.magistr.app.dto.ScheduleRuleDto;
|
import com.magistr.app.dto.ScheduleRuleDto;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||||
import com.magistr.app.config.auth.AuthContext;
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.model.Classroom;
|
import com.magistr.app.model.Classroom;
|
||||||
@@ -31,6 +32,7 @@ import com.magistr.app.repository.SubgroupRepository;
|
|||||||
import com.magistr.app.repository.TimeSlotRepository;
|
import com.magistr.app.repository.TimeSlotRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.NoSuchElementException;
|
import java.util.NoSuchElementException;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
|
|||||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<ScheduleRuleDto> getPage(Long versionId, String query, int page, int size) {
|
||||||
|
var ids = scheduleRuleRepository.searchPageIds(versionId,
|
||||||
|
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||||
|
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100))));
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return new PageResponse<>(List.of(), ids.getNumber(), ids.getSize(), ids.getTotalElements(),
|
||||||
|
ids.getTotalPages(), ids.isFirst(), ids.isLast());
|
||||||
|
}
|
||||||
|
var details = scheduleRuleRepository.findPageWithDetails(ids.getContent()).stream()
|
||||||
|
.collect(java.util.stream.Collectors.toMap(ScheduleRule::getId, this::toDto));
|
||||||
|
return PageResponse.from(ids.map(details::get));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||||
return getAll(semesterId, groupId, null);
|
return getAll(semesterId, groupId, null);
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO departments (name, code) VALUES
|
|
||||||
('Кафедра ИБ', 1),
|
|
||||||
('Кафедра ВТ', 2),
|
|
||||||
('Кафедра КТ', 3);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS specialties (
|
CREATE TABLE IF NOT EXISTS specialties (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
@@ -36,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO specialties (name, specialty_code) VALUES
|
|
||||||
('Информационная безопасность', '10.03.01'),
|
|
||||||
('Информатика и вычислительная техника', '09.03.01'),
|
|
||||||
('Программная инженерия', '09.03.04')
|
|
||||||
ON CONFLICT (specialty_code) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS specialty_profiles (
|
CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
|
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
|
||||||
@@ -57,11 +46,6 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
|
|||||||
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO specialty_profiles (specialty_id, name)
|
|
||||||
SELECT id, 'Без профиля'
|
|
||||||
FROM specialties
|
|
||||||
ON CONFLICT (specialty_id, name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Пользователи и роли
|
-- Пользователи и роли
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -83,15 +67,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
|
||||||
INSERT INTO users (username, password, role, full_name, job_title, department_id)
|
|
||||||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
|
|
||||||
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
|
|
||||||
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
|
|
||||||
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
|
|
||||||
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
|
||||||
ON CONFLICT (username) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -189,16 +164,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
|
|||||||
ON teacher_department_assignments(teacher_id, department_id)
|
ON teacher_department_assignments(teacher_id, department_id)
|
||||||
WHERE valid_to IS NULL;
|
WHERE valid_to IS NULL;
|
||||||
|
|
||||||
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
|
|
||||||
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
|
|
||||||
FROM users u
|
|
||||||
WHERE u.role = 'TEACHER'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM teacher_department_assignments existing
|
|
||||||
WHERE existing.teacher_id = u.id
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
|
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||||
@@ -243,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO education_forms (name) VALUES
|
|
||||||
('Бакалавриат'),
|
|
||||||
('Магистратура'),
|
|
||||||
('Специалитет')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Учебные группы
|
-- Учебные группы
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -272,19 +231,6 @@ CREATE TABLE IF NOT EXISTS student_groups (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Тестовая базовая группа для работы
|
|
||||||
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
|
|
||||||
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
|
|
||||||
FROM (VALUES
|
|
||||||
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
|
|
||||||
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
|
|
||||||
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
|
|
||||||
JOIN education_forms ef ON ef.name = data.education_form_name
|
|
||||||
JOIN departments dept ON dept.name = data.department_name
|
|
||||||
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
|
|
||||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -373,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION validate_subgroup_student_capacity();
|
EXECUTE FUNCTION validate_subgroup_student_capacity();
|
||||||
|
|
||||||
INSERT INTO subgroups (group_id, name, student_capacity)
|
|
||||||
SELECT
|
|
||||||
sg.id,
|
|
||||||
data.name,
|
|
||||||
CASE
|
|
||||||
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
|
|
||||||
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
|
|
||||||
END
|
|
||||||
FROM student_groups sg
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
('Подгруппа 1', 1),
|
|
||||||
('Подгруппа 2', 2)
|
|
||||||
) AS data(name, part_number)
|
|
||||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
|
||||||
ON CONFLICT (group_id, name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Справочники
|
-- Справочники
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -414,14 +344,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
|
|||||||
COMMENT ON INDEX uq_subjects_name_ci IS
|
COMMENT ON INDEX uq_subjects_name_ci IS
|
||||||
'Глобально уникальное название дисциплины без учёта регистра';
|
'Глобально уникальное название дисциплины без учёта регистра';
|
||||||
|
|
||||||
INSERT INTO subjects (name, department_id) VALUES
|
|
||||||
('Высшая математика', 1),
|
|
||||||
('Философия', 1),
|
|
||||||
('Информатика', 1),
|
|
||||||
('Базы данных', 1),
|
|
||||||
('Английский язык', 1)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS subject_comments (
|
CREATE TABLE IF NOT EXISTS subject_comments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||||
@@ -441,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
|
|||||||
duration_minutes INT DEFAULT 90
|
duration_minutes INT DEFAULT 90
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO lesson_types (name, color_code) VALUES
|
|
||||||
('Лекция', '#FF6B6B'),
|
|
||||||
('Практика', '#4ECDC4'),
|
|
||||||
('Лабораторная работа', '#45B7D1')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Оборудование
|
-- Оборудование
|
||||||
CREATE TABLE IF NOT EXISTS equipments (
|
CREATE TABLE IF NOT EXISTS equipments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -461,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO equipments (name) VALUES
|
|
||||||
('Проектор'),
|
|
||||||
('ПК'),
|
|
||||||
('Лаборатория'),
|
|
||||||
('Интерактивная доска'),
|
|
||||||
('Документ-камера'),
|
|
||||||
('Аудиосистема')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Аудитории
|
-- Аудитории
|
||||||
CREATE TABLE IF NOT EXISTS classrooms (
|
CREATE TABLE IF NOT EXISTS classrooms (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -488,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
|
||||||
('101 Ленинская', 120, 'Главный корпус', 1),
|
|
||||||
('202 IT Lab', 20, 'Корпус IT', 2),
|
|
||||||
('303 Обычная', 30, 'Главный корпус', 3)
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Привязка оборудования к аудиториям (Many-to-Many)
|
-- Привязка оборудования к аудиториям (Many-to-Many)
|
||||||
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||||
@@ -503,20 +404,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
|
|||||||
PRIMARY KEY (classroom_id, equipment_id)
|
PRIMARY KEY (classroom_id, equipment_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
|
||||||
SELECT c.id, e.id,
|
|
||||||
CASE
|
|
||||||
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
|
||||||
WHEN e.name = 'ПК' THEN 1
|
|
||||||
ELSE 1
|
|
||||||
END
|
|
||||||
FROM classrooms c, equipments e
|
|
||||||
WHERE
|
|
||||||
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
|
||||||
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
|
||||||
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
|
||||||
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
||||||
@@ -575,11 +462,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
|
|||||||
ON time_slot_scopes(day_of_week)
|
ON time_slot_scopes(day_of_week)
|
||||||
WHERE apply_mode = 'WEEKDAY';
|
WHERE apply_mode = 'WEEKDAY';
|
||||||
|
|
||||||
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
|
|
||||||
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
|
|
||||||
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS time_slots (
|
CREATE TABLE IF NOT EXISTS time_slots (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
||||||
@@ -597,21 +479,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
||||||
|
|
||||||
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
|
|
||||||
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
|
|
||||||
FROM (VALUES
|
|
||||||
(1, '08:00', '09:30', 90),
|
|
||||||
(2, '09:40', '11:10', 90),
|
|
||||||
(3, '11:40', '13:10', 90),
|
|
||||||
(4, '13:20', '14:50', 90),
|
|
||||||
(5, '15:00', '16:30', 90),
|
|
||||||
(6, '16:50', '18:20', 90),
|
|
||||||
(7, '18:30', '20:00', 90)
|
|
||||||
) AS data(order_number, start_time, end_time, duration_minutes)
|
|
||||||
CROSS JOIN time_slot_scopes scope
|
|
||||||
WHERE scope.code IN ('default', 'saturday')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
assignment_date DATE NOT NULL UNIQUE,
|
assignment_date DATE NOT NULL UNIQUE,
|
||||||
@@ -629,11 +496,6 @@ CREATE TABLE IF NOT EXISTS academic_years (
|
|||||||
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO academic_years (title, start_date, end_date) VALUES
|
|
||||||
('2024-2025', '2024-09-01', '2025-06-30'),
|
|
||||||
('2025-2026', '2025-09-01', '2026-06-30')
|
|
||||||
ON CONFLICT (title) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS semesters (
|
CREATE TABLE IF NOT EXISTS semesters (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||||||
@@ -645,16 +507,6 @@ CREATE TABLE IF NOT EXISTS semesters (
|
|||||||
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
|
||||||
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
|
|
||||||
FROM academic_years
|
|
||||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
|
||||||
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
|
|
||||||
FROM academic_years
|
|
||||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
||||||
@@ -667,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
|||||||
description TEXT
|
description TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
|
|
||||||
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
|
|
||||||
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
|
|
||||||
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
|
|
||||||
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
|
|
||||||
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
|
|
||||||
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
|
|
||||||
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
|
|
||||||
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
|
|
||||||
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
|
|
||||||
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
|
|
||||||
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
|
|
||||||
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
|
|
||||||
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendars (
|
CREATE TABLE IF NOT EXISTS academic_calendars (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
@@ -700,21 +536,6 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
|
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
|
||||||
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
|
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
|
||||||
|
|
||||||
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
|
|
||||||
SELECT
|
|
||||||
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
|
|
||||||
ay.id,
|
|
||||||
specialty.id,
|
|
||||||
profile.id,
|
|
||||||
study_form.id,
|
|
||||||
4
|
|
||||||
FROM academic_years ay
|
|
||||||
CROSS JOIN education_forms study_form
|
|
||||||
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
|
|
||||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
|
||||||
WHERE ay.title = '2025-2026'
|
|
||||||
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
|
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||||
@@ -754,24 +575,6 @@ AS $$
|
|||||||
) + 1
|
) + 1
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
INSERT INTO academic_calendar_periods (
|
|
||||||
calendar_id,
|
|
||||||
course_number,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
activity_type_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
calendar.id,
|
|
||||||
course.course_number,
|
|
||||||
ay.start_date,
|
|
||||||
ay.end_date,
|
|
||||||
activity.id
|
|
||||||
FROM academic_calendars calendar
|
|
||||||
JOIN academic_years ay ON ay.id = calendar.academic_year_id
|
|
||||||
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
|
|
||||||
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
|
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||||
@@ -800,21 +603,6 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
|
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
|
||||||
ON student_group_calendar_assignments(group_id, academic_year_id);
|
ON student_group_calendar_assignments(group_id, academic_year_id);
|
||||||
|
|
||||||
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
|
|
||||||
SELECT
|
|
||||||
sg.id,
|
|
||||||
ay.id,
|
|
||||||
calendar.id
|
|
||||||
FROM student_groups sg
|
|
||||||
JOIN academic_years ay ON ay.title = '2025-2026'
|
|
||||||
JOIN academic_calendars calendar
|
|
||||||
ON calendar.academic_year_id = ay.id
|
|
||||||
AND calendar.specialty_id = sg.specialty_id
|
|
||||||
AND calendar.specialty_profile_id = sg.specialty_profile_id
|
|
||||||
AND calendar.study_form_id = sg.education_form_id
|
|
||||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
|
||||||
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||||
@@ -991,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
||||||
|
|
||||||
CREATE TEMP TABLE tmp_default_rules AS
|
|
||||||
SELECT
|
|
||||||
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
|
|
||||||
rule_key,
|
|
||||||
subject_id,
|
|
||||||
semester_id,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
data.rule_key,
|
|
||||||
subj.id AS subject_id,
|
|
||||||
sem.id AS semester_id,
|
|
||||||
data.lecture_academic_hours,
|
|
||||||
data.laboratory_academic_hours,
|
|
||||||
data.practice_academic_hours,
|
|
||||||
data.lecture_start_week,
|
|
||||||
data.laboratory_start_week,
|
|
||||||
data.practice_start_week
|
|
||||||
FROM (VALUES
|
|
||||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
|
|
||||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
|
|
||||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
|
|
||||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
|
|
||||||
) AS data(
|
|
||||||
rule_key,
|
|
||||||
subject_name,
|
|
||||||
year_title,
|
|
||||||
semester_type,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
)
|
|
||||||
JOIN subjects subj ON subj.name = data.subject_name
|
|
||||||
JOIN academic_years ay ON ay.title = data.year_title
|
|
||||||
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
|
||||||
) prepared_rules;
|
|
||||||
|
|
||||||
INSERT INTO schedule_rules (
|
|
||||||
id,
|
|
||||||
subject_id,
|
|
||||||
semester_id,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
schedule_rule_id,
|
|
||||||
subject_id,
|
|
||||||
semester_id,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
FROM tmp_default_rules;
|
|
||||||
|
|
||||||
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
|
||||||
SELECT
|
|
||||||
rules.schedule_rule_id,
|
|
||||||
sg.id
|
|
||||||
FROM (VALUES
|
|
||||||
('stream_math', 'ИВТ-21-1'),
|
|
||||||
('stream_math', 'ИБ-41м'),
|
|
||||||
('ivt_informatics_lab', 'ИВТ-21-1'),
|
|
||||||
('ivt_databases_practice', 'ИВТ-21-1'),
|
|
||||||
('ib_philosophy', 'ИБ-41м')
|
|
||||||
) AS data(rule_key, group_name)
|
|
||||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
|
||||||
JOIN student_groups sg ON sg.name = data.group_name
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO schedule_rule_slots (
|
|
||||||
schedule_rule_id,
|
|
||||||
day_of_week,
|
|
||||||
parity,
|
|
||||||
time_slot_id,
|
|
||||||
teacher_id,
|
|
||||||
classroom_id,
|
|
||||||
lesson_type_id,
|
|
||||||
lesson_format
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
rules.schedule_rule_id,
|
|
||||||
data.day_of_week,
|
|
||||||
data.parity,
|
|
||||||
ts.id,
|
|
||||||
teacher.id,
|
|
||||||
classroom.id,
|
|
||||||
lt.id,
|
|
||||||
data.lesson_format
|
|
||||||
FROM (VALUES
|
|
||||||
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
|
|
||||||
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
|
|
||||||
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
|
|
||||||
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
|
||||||
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
|
||||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
|
||||||
JOIN time_slot_scopes scope ON scope.code = 'default'
|
|
||||||
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
|
|
||||||
JOIN users teacher ON teacher.username = data.teacher_username
|
|
||||||
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
|
||||||
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Функция обновления timestamp
|
-- Функция обновления timestamp
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -1371,7 +1044,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
cancel_violations,
|
cancel_violations,
|
||||||
move_violations,
|
move_violations,
|
||||||
replace_violations,
|
replace_violations,
|
||||||
@@ -1470,7 +1143,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
lecture_violations,
|
lecture_violations,
|
||||||
laboratory_violations,
|
laboratory_violations,
|
||||||
practice_violations
|
practice_violations
|
||||||
@@ -1511,7 +1184,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23505',
|
ERRCODE = '23505',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
duplicate_slot_groups
|
duplicate_slot_groups
|
||||||
);
|
);
|
||||||
END IF;
|
END IF;
|
||||||
@@ -1577,7 +1250,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
duration_violations,
|
duration_violations,
|
||||||
overlap_violations,
|
overlap_violations,
|
||||||
scope_violations
|
scope_violations
|
||||||
@@ -1733,7 +1406,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
academic_year_overlap_violations,
|
academic_year_overlap_violations,
|
||||||
semester_bounds_violations,
|
semester_bounds_violations,
|
||||||
semester_overlap_violations
|
semester_overlap_violations
|
||||||
@@ -1864,7 +1537,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
assignment_violations,
|
assignment_violations,
|
||||||
calendar_period_violations,
|
calendar_period_violations,
|
||||||
calendar_subject_violations
|
calendar_subject_violations
|
||||||
@@ -2430,33 +2103,9 @@ CREATE UNIQUE INDEX uq_schedule_versions_published_semester
|
|||||||
CREATE INDEX idx_schedule_versions_semester_history
|
CREATE INDEX idx_schedule_versions_semester_history
|
||||||
ON schedule_versions(semester_id, version_number DESC);
|
ON schedule_versions(semester_id, version_number DESC);
|
||||||
|
|
||||||
INSERT INTO schedule_versions (
|
|
||||||
semester_id,
|
|
||||||
version_number,
|
|
||||||
name,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
published_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
semester.id,
|
|
||||||
1,
|
|
||||||
'Опубликованная версия 1',
|
|
||||||
'PUBLISHED',
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM semesters semester;
|
|
||||||
|
|
||||||
ALTER TABLE schedule_rules
|
ALTER TABLE schedule_rules
|
||||||
ADD COLUMN schedule_version_id BIGINT;
|
ADD COLUMN schedule_version_id BIGINT;
|
||||||
|
|
||||||
UPDATE schedule_rules rule
|
|
||||||
SET schedule_version_id = version.id,
|
|
||||||
version_group_id = COALESCE(rule.version_group_id, rule.id)
|
|
||||||
FROM schedule_versions version
|
|
||||||
WHERE version.semester_id = rule.semester_id
|
|
||||||
AND version.status = 'PUBLISHED';
|
|
||||||
|
|
||||||
ALTER TABLE schedule_rules
|
ALTER TABLE schedule_rules
|
||||||
ALTER COLUMN schedule_version_id SET NOT NULL,
|
ALTER COLUMN schedule_version_id SET NOT NULL,
|
||||||
ADD CONSTRAINT fk_schedule_rules_version
|
ADD CONSTRAINT fk_schedule_rules_version
|
||||||
@@ -2487,14 +2136,6 @@ CREATE TABLE schedule_version_history (
|
|||||||
CREATE INDEX idx_schedule_version_history_version
|
CREATE INDEX idx_schedule_version_history_version
|
||||||
ON schedule_version_history(version_id, created_at DESC, id DESC);
|
ON schedule_version_history(version_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
|
||||||
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
|
|
||||||
FROM schedule_versions;
|
|
||||||
|
|
||||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
|
||||||
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
|
|
||||||
FROM schedule_versions;
|
|
||||||
|
|
||||||
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
|
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
|
||||||
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
|
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
|
||||||
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
|
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
INSERT INTO academic_calendar_periods (
|
|
||||||
calendar_id,
|
|
||||||
course_number,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
activity_type_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
calendar.id,
|
|
||||||
course.course_number,
|
|
||||||
academic_year.start_date,
|
|
||||||
academic_year.end_date,
|
|
||||||
theory_activity.id
|
|
||||||
FROM academic_calendars calendar
|
|
||||||
JOIN academic_years academic_year
|
|
||||||
ON academic_year.id = calendar.academic_year_id
|
|
||||||
JOIN academic_calendar_activity_types theory_activity
|
|
||||||
ON theory_activity.code = 'Т'
|
|
||||||
CROSS JOIN LATERAL GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM academic_calendar_periods existing_period
|
|
||||||
WHERE existing_period.calendar_id = calendar.id
|
|
||||||
);
|
|
||||||
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,30 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
class AcademicCalendarControllerTest {
|
class AcademicCalendarControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Страница графиков ограничивает размер и сохраняет фильтр учебного года")
|
||||||
|
void returnsBoundedCalendarPage() throws Exception {
|
||||||
|
AcademicCalendarRepository repository = mock(AcademicCalendarRepository.class);
|
||||||
|
var pageable = org.springframework.data.domain.PageRequest.of(0, 100,
|
||||||
|
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
|
||||||
|
.and(org.springframework.data.domain.Sort.by("title", "id")));
|
||||||
|
when(repository.searchOptions("профиль", 9L, null, null, null, pageable))
|
||||||
|
.thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(), pageable, 0));
|
||||||
|
var controller = new AcademicCalendarController(repository,
|
||||||
|
mock(AcademicCalendarPeriodRepository.class), mock(SubjectRepository.class),
|
||||||
|
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarGridService.class),
|
||||||
|
mock(ScheduleGeneratorService.class), mock(AcademicStructureService.class));
|
||||||
|
org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build()
|
||||||
|
.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.get("/api/admin/academic-calendars/page")
|
||||||
|
.param("query", " ПРОФИЛЬ ").param("academicYearId", "9")
|
||||||
|
.param("page", "-1").param("size", "1000"))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.size").value(100))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.page").value(0))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createCalendarReturnsCalendarCreatedByTransactionalService() {
|
void createCalendarReturnsCalendarCreatedByTransactionalService() {
|
||||||
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ class ScheduleRuleAdminControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Страница правил принимает фильтры и возвращает метаданные пагинации")
|
||||||
|
void returnsRulesPage() throws Exception {
|
||||||
|
when(service.getPage(7L, "Математика", 2, 25)).thenReturn(
|
||||||
|
new com.magistr.app.dto.PageResponse<>(List.of(), 2, 25, 50, 2, false, true));
|
||||||
|
mockMvc.perform(get("/api/admin/schedule-rules/page")
|
||||||
|
.param("versionId", "7").param("query", "Математика").param("page", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.items").isArray())
|
||||||
|
.andExpect(jsonPath("$.page").value(2))
|
||||||
|
.andExpect(jsonPath("$.size").value(25))
|
||||||
|
.andExpect(jsonPath("$.totalItems").value(50));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returnsBadRequestWithRussianValidationMessage() throws Exception {
|
void returnsBadRequestWithRussianValidationMessage() throws Exception {
|
||||||
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(
|
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -74,6 +75,52 @@ class TeacherSubjectControllerTest {
|
|||||||
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
|
assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsAssignmentsOnlyForRequestedSubjects() {
|
||||||
|
TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class);
|
||||||
|
UserRepository userRepository = mock(UserRepository.class);
|
||||||
|
SubjectRepository subjectRepository = mock(SubjectRepository.class);
|
||||||
|
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
|
||||||
|
User teacher = teacher();
|
||||||
|
Subject subject = new Subject();
|
||||||
|
subject.setId(20L);
|
||||||
|
subject.setName("Высшая математика");
|
||||||
|
TeacherSubject assignment = new TeacherSubject(teacher.getId(), subject.getId());
|
||||||
|
setTeacherSubjectDetails(assignment, teacher, subject);
|
||||||
|
when(teacherSubjectRepository.findBySubjectIdInWithDetails(List.of(subject.getId())))
|
||||||
|
.thenReturn(List.of(assignment));
|
||||||
|
TeacherSubjectController controller = new TeacherSubjectController(
|
||||||
|
teacherSubjectRepository,
|
||||||
|
userRepository,
|
||||||
|
subjectRepository,
|
||||||
|
teacherDepartmentService,
|
||||||
|
BUSINESS_TIME
|
||||||
|
);
|
||||||
|
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||||
|
|
||||||
|
var result = controller.getAll(List.of(subject.getId()));
|
||||||
|
|
||||||
|
assertThat(result).singleElement().satisfies(response -> {
|
||||||
|
assertThat(response.getSubjectId()).isEqualTo(subject.getId());
|
||||||
|
assertThat(response.getUserId()).isEqualTo(teacher.getId());
|
||||||
|
assertThat(response.getFullName()).isEqualTo(teacher.getFullName());
|
||||||
|
});
|
||||||
|
verify(teacherSubjectRepository).findBySubjectIdInWithDetails(List.of(subject.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setTeacherSubjectDetails(TeacherSubject assignment, User teacher, Subject subject) {
|
||||||
|
try {
|
||||||
|
var userField = TeacherSubject.class.getDeclaredField("user");
|
||||||
|
userField.setAccessible(true);
|
||||||
|
userField.set(assignment, teacher);
|
||||||
|
var subjectField = TeacherSubject.class.getDeclaredField("subject");
|
||||||
|
subjectField.setAccessible(true);
|
||||||
|
subjectField.set(assignment, subject);
|
||||||
|
} catch (ReflectiveOperationException exception) {
|
||||||
|
throw new AssertionError("Не удалось подготовить тестовую привязку", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private User teacher() {
|
private User teacher() {
|
||||||
User teacher = new User();
|
User teacher = new User();
|
||||||
teacher.setId(10L);
|
teacher.setId(10L);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import java.sql.SQLException;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@DisplayName("Миграция начальных периодов календарного графика")
|
@DisplayName("Миграция тестовых данных и начальных периодов календарного графика")
|
||||||
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
@@ -33,6 +33,7 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
long emptyCalendarId;
|
long emptyCalendarId;
|
||||||
long configuredCalendarId;
|
long configuredCalendarId;
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
|
prepareCustomCalendarPrerequisites(connection);
|
||||||
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
|
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
|
||||||
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
|
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
|
||||||
insertConfiguredPeriod(connection, configuredCalendarId);
|
insertConfiguredPeriod(connection, configuredCalendarId);
|
||||||
@@ -52,6 +53,51 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("V1 создаёт только схему, а V2 загружает полный тестовый набор")
|
||||||
|
void migrationSeparatesSchemaAndTestData() throws SQLException {
|
||||||
|
Flyway baseline = flyway("1");
|
||||||
|
baseline.clean();
|
||||||
|
baseline.migrate();
|
||||||
|
|
||||||
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
|
assertThat(queryLong(connection, """
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM departments)
|
||||||
|
+ (SELECT COUNT(*) FROM users)
|
||||||
|
+ (SELECT COUNT(*) FROM student_groups)
|
||||||
|
+ (SELECT COUNT(*) FROM subjects)
|
||||||
|
+ (SELECT COUNT(*) FROM academic_years)
|
||||||
|
+ (SELECT COUNT(*) FROM schedule_rules)
|
||||||
|
"""))
|
||||||
|
.as("После V1 доменные таблицы должны быть пустыми")
|
||||||
|
.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
Flyway allMigrations = flyway(null);
|
||||||
|
allMigrations.migrate();
|
||||||
|
|
||||||
|
assertThat(allMigrations.info().current()).isNotNull();
|
||||||
|
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
|
|
||||||
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM departments")).isEqualTo(41);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM specialties")).isEqualTo(42);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM users WHERE role = 'TEACHER'"))
|
||||||
|
.isEqualTo(151);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_groups")).isEqualTo(202);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subgroups")).isEqualTo(404);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subjects")).isEqualTo(229);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM classrooms")).isEqualTo(80);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendars")).isEqualTo(132);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendar_periods")).isEqualTo(576);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_group_calendar_assignments"))
|
||||||
|
.isEqualTo(202);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rules")).isEqualTo(610);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rule_slots")).isEqualTo(610);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Flyway flyway(String targetVersion) {
|
private Flyway flyway(String targetVersion) {
|
||||||
var configuration = Flyway.configure()
|
var configuration = Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
@@ -75,10 +121,12 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
study_form_id,
|
study_form_id,
|
||||||
course_count
|
course_count
|
||||||
)
|
)
|
||||||
SELECT ?, academic_year_id, specialty_id, specialty_profile_id, study_form_id, ?
|
SELECT ?, academic_year.id, specialty.id, profile.id, study_form.id, ?
|
||||||
FROM academic_calendars
|
FROM academic_years academic_year
|
||||||
ORDER BY id
|
JOIN specialties specialty ON specialty.specialty_code = '99.99.99'
|
||||||
LIMIT 1
|
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
|
RETURNING id
|
||||||
""")) {
|
""")) {
|
||||||
statement.setString(1, title);
|
statement.setString(1, title);
|
||||||
@@ -90,6 +138,40 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void prepareCustomCalendarPrerequisites(Connection connection) throws SQLException {
|
||||||
|
try (var statement = connection.createStatement()) {
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO departments (name, code)
|
||||||
|
VALUES ('Тестовая кафедра для V2', 9999)
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO specialties (name, specialty_code)
|
||||||
|
VALUES ('Тестовое направление для V2', '99.99.99')
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO specialty_profiles (specialty_id, name)
|
||||||
|
SELECT id, 'Тестовый профиль для V2'
|
||||||
|
FROM specialties
|
||||||
|
WHERE specialty_code = '99.99.99'
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO education_forms (name)
|
||||||
|
VALUES ('Тестовая форма')
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO academic_years (title, start_date, end_date)
|
||||||
|
VALUES ('2030-2031', DATE '2030-09-01', DATE '2031-06-30')
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO academic_calendar_activity_types (
|
||||||
|
code, name, allow_schedule, color_code, display_order
|
||||||
|
) VALUES
|
||||||
|
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10),
|
||||||
|
('К', 'Каникулы', FALSE, '#38bdf8', 30)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
|
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
|
||||||
try (PreparedStatement statement = connection.prepareStatement("""
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
INSERT INTO academic_calendar_periods (
|
INSERT INTO academic_calendar_periods (
|
||||||
@@ -140,6 +222,14 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long queryLong(Connection connection, String sql) throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql);
|
||||||
|
ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
assertThat(resultSet.next()).isTrue();
|
||||||
|
return resultSet.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private record PeriodSummary(int count,
|
private record PeriodSummary(int count,
|
||||||
int minCourse,
|
int minCourse,
|
||||||
int maxCourse,
|
int maxCourse,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
assertThat(flyway.info().current()).isNotNull();
|
assertThat(flyway.info().current()).isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
long groupId = queryLong(
|
long groupId = queryLong(
|
||||||
@@ -81,6 +81,7 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
"23514",
|
"23514",
|
||||||
"сохранённые назначения календарных графиков станут несовместимыми"
|
"сохранённые назначения календарных графиков станут несовместимыми"
|
||||||
);
|
);
|
||||||
|
deleteOtherCalendarAssignments(connection, calendarId, assignmentId);
|
||||||
assertSqlState(
|
assertSqlState(
|
||||||
() -> updateInt(
|
() -> updateInt(
|
||||||
connection,
|
connection,
|
||||||
@@ -386,7 +387,7 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
@@ -541,6 +542,20 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void deleteOtherCalendarAssignments(Connection connection,
|
||||||
|
long calendarId,
|
||||||
|
long preservedAssignmentId) throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
|
DELETE FROM student_group_calendar_assignments
|
||||||
|
WHERE calendar_id = ?
|
||||||
|
AND id <> ?
|
||||||
|
""")) {
|
||||||
|
statement.setLong(1, calendarId);
|
||||||
|
statement.setLong(2, preservedAssignmentId);
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
|
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
statement.setLong(1, value);
|
statement.setLong(1, value);
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ class ScheduleOverrideMigrationIntegrationTest {
|
|||||||
.as("После полной миграции должна существовать текущая версия")
|
.as("После полной миграции должна существовать текущая версия")
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion())
|
assertThat(flyway.info().current().getVersion().getVersion())
|
||||||
.as("Последней успешно применённой миграцией должна быть V1")
|
.as("Тестовые данные должны быть загружены миграцией V2")
|
||||||
.isEqualTo("1");
|
.isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
SeedIds seedIds = loadSeedIds(connection);
|
SeedIds seedIds = loadSeedIds(connection);
|
||||||
@@ -149,7 +149,7 @@ class ScheduleOverrideMigrationIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
.as("После полной миграции должна существовать текущая версия")
|
.as("После полной миграции должна существовать текущая версия")
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion())
|
assertThat(flyway.info().current().getVersion().getVersion())
|
||||||
.as("Последней успешно применённой миграцией должна быть V1")
|
.as("Тестовые данные должны быть загружены миграцией V2")
|
||||||
.isEqualTo("1");
|
.isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
SeedIds seedIds = loadSeedIds(connection);
|
SeedIds seedIds = loadSeedIds(connection);
|
||||||
@@ -82,7 +82,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class SubjectOwnershipInvariantIntegrationTest {
|
|||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
flyway.clean();
|
flyway.clean();
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class TeacherDepartmentInvariantIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ class TeacherPreferencesMigrationIntegrationTest {
|
|||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
flyway.clean();
|
flyway.clean();
|
||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
assertThat(queryLong(connection, """
|
assertThat(queryLong(connection, """
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
|
|||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
assertThat(flyway.info().current()).isNotNull();
|
assertThat(flyway.info().current()).isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
long manualScopeId = insertManualScope(connection, "migration_checks");
|
long manualScopeId = insertManualScope(connection, "migration_checks");
|
||||||
@@ -171,7 +171,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Flyway baselineFlyway() {
|
private Flyway baselineFlyway() {
|
||||||
return flyway("1");
|
return flyway("2");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Flyway flyway(String targetVersion) {
|
private Flyway flyway(String targetVersion) {
|
||||||
|
|||||||
@@ -73,18 +73,38 @@ class AcademicPeriodServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createsNonOverlappingYearWithTrimmedTitle() {
|
void createsYearWithAutomaticTitleEndDateAndSemesters() {
|
||||||
AcademicYear saved = service.createYear(yearRequest(
|
AcademicYear saved = service.createYear(yearRequest(
|
||||||
" 2026-2027 ",
|
"Произвольное название",
|
||||||
YEAR_START,
|
YEAR_START,
|
||||||
YEAR_END
|
YEAR_END.minusMonths(1)
|
||||||
));
|
));
|
||||||
|
|
||||||
assertThat(saved.getTitle()).isEqualTo("2026-2027");
|
assertThat(saved.getTitle()).isEqualTo("2026-2027");
|
||||||
assertThat(saved.getStartDate()).isEqualTo(YEAR_START);
|
assertThat(saved.getStartDate()).isEqualTo(YEAR_START);
|
||||||
|
assertThat(saved.getEndDate()).isEqualTo(YEAR_END);
|
||||||
|
verify(semesterRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(Semester.class));
|
||||||
|
verify(scheduleVersionRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(ScheduleVersion.class));
|
||||||
verify(scheduleGeneratorService).clearCache();
|
verify(scheduleGeneratorService).clearCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsYearWithoutSemesters() {
|
||||||
|
AcademicYearDto request = new AcademicYearDto(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
YEAR_START,
|
||||||
|
null,
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.createYear(request))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessage("Учебный год нельзя создать без семестров");
|
||||||
|
|
||||||
|
verify(academicYearRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsDuplicateTitleOrOverlappingYear() {
|
void rejectsDuplicateTitleOrOverlappingYear() {
|
||||||
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
|
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
|
||||||
@@ -95,7 +115,7 @@ class AcademicPeriodServiceTest {
|
|||||||
YEAR_END
|
YEAR_END
|
||||||
)))
|
)))
|
||||||
.isInstanceOf(ScheduleConflictException.class)
|
.isInstanceOf(ScheduleConflictException.class)
|
||||||
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом");
|
.hasMessage("Учебный год или его период конфликтует с существующим учебным годом");
|
||||||
|
|
||||||
verify(academicYearRepository, never()).saveAndFlush(any());
|
verify(academicYearRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
@@ -112,17 +132,49 @@ class AcademicPeriodServiceTest {
|
|||||||
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
|
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
|
||||||
.thenReturn(List.of(autumn));
|
.thenReturn(List.of(autumn));
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest(
|
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequestWithoutSemesters(YEAR_START.plusDays(1))))
|
||||||
"2026-2027",
|
|
||||||
YEAR_START.plusDays(1),
|
|
||||||
YEAR_END
|
|
||||||
)))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessage("Новые границы учебного года не включают все его семестры");
|
.hasMessage("Семестр должен полностью находиться в границах учебного года");
|
||||||
|
|
||||||
verify(academicYearRepository, never()).saveAndFlush(any());
|
verify(academicYearRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatesYearAndItsSemestersTogether() {
|
||||||
|
Semester autumn = semester(
|
||||||
|
SEMESTER_ID,
|
||||||
|
SemesterType.autumn,
|
||||||
|
YEAR_START,
|
||||||
|
LocalDate.of(2027, 1, 31)
|
||||||
|
);
|
||||||
|
Semester spring = semester(
|
||||||
|
SEMESTER_ID + 1,
|
||||||
|
SemesterType.spring,
|
||||||
|
LocalDate.of(2027, 2, 1),
|
||||||
|
YEAR_END
|
||||||
|
);
|
||||||
|
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||||
|
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
|
||||||
|
.thenReturn(List.of(autumn, spring));
|
||||||
|
|
||||||
|
AcademicYear saved = service.updateYear(YEAR_ID, new AcademicYearDto(
|
||||||
|
YEAR_ID,
|
||||||
|
null,
|
||||||
|
YEAR_START,
|
||||||
|
null,
|
||||||
|
List.of(
|
||||||
|
semesterRequest(SEMESTER_ID, SemesterType.autumn, YEAR_START, LocalDate.of(2027, 1, 25)),
|
||||||
|
semesterRequest(SEMESTER_ID + 1, SemesterType.spring, LocalDate.of(2027, 1, 26), YEAR_END)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
assertThat(saved.getTitle()).isEqualTo("2026-2027");
|
||||||
|
assertThat(autumn.getEndDate()).isEqualTo(LocalDate.of(2027, 1, 25));
|
||||||
|
assertThat(spring.getStartDate()).isEqualTo(LocalDate.of(2027, 1, 26));
|
||||||
|
verify(semesterRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(Semester.class));
|
||||||
|
verify(scheduleVersionRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsSemesterOutsideAcademicYear() {
|
void rejectsSemesterOutsideAcademicYear() {
|
||||||
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
|
||||||
@@ -218,19 +270,33 @@ class AcademicPeriodServiceTest {
|
|||||||
YEAR_END
|
YEAR_END
|
||||||
)))
|
)))
|
||||||
.isInstanceOf(ScheduleConflictException.class)
|
.isInstanceOf(ScheduleConflictException.class)
|
||||||
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом")
|
.hasMessage("Учебный год или его период конфликтует с существующим учебным годом")
|
||||||
.hasNoCause();
|
.hasNoCause();
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) {
|
private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) {
|
||||||
return new AcademicYearDto(null, title, startDate, endDate, List.of());
|
return new AcademicYearDto(null, title, startDate, endDate, List.of(
|
||||||
|
semesterRequest(SemesterType.autumn, startDate, LocalDate.of(2027, 1, 31)),
|
||||||
|
semesterRequest(SemesterType.spring, LocalDate.of(2027, 2, 1), YEAR_END)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AcademicYearDto yearRequestWithoutSemesters(LocalDate startDate) {
|
||||||
|
return new AcademicYearDto(null, null, startDate, null, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
private SemesterDto semesterRequest(SemesterType semesterType,
|
private SemesterDto semesterRequest(SemesterType semesterType,
|
||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
LocalDate endDate) {
|
LocalDate endDate) {
|
||||||
|
return semesterRequest(null, semesterType, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SemesterDto semesterRequest(Long id,
|
||||||
|
SemesterType semesterType,
|
||||||
|
LocalDate startDate,
|
||||||
|
LocalDate endDate) {
|
||||||
return new SemesterDto(
|
return new SemesterDto(
|
||||||
null,
|
id,
|
||||||
YEAR_ID,
|
YEAR_ID,
|
||||||
"2026-2027",
|
"2026-2027",
|
||||||
semesterType,
|
semesterType,
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ class ScheduleGenerationQueryCountTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Генератор отклоняет 121 включительную дату")
|
@DisplayName("Генератор отклоняет 201 включительную дату")
|
||||||
void rejects121InclusiveDates() {
|
void rejects201InclusiveDates() {
|
||||||
ScheduleGeneratorService generator = new ScheduleGeneratorService(
|
ScheduleGeneratorService generator = new ScheduleGeneratorService(
|
||||||
mock(ScheduleRuleRepository.class),
|
mock(ScheduleRuleRepository.class),
|
||||||
mock(GroupRepository.class),
|
mock(GroupRepository.class),
|
||||||
@@ -161,10 +161,10 @@ class ScheduleGenerationQueryCountTest {
|
|||||||
assertThatThrownBy(() -> generator.buildScheduleForGroups(
|
assertThatThrownBy(() -> generator.buildScheduleForGroups(
|
||||||
List.of(),
|
List.of(),
|
||||||
startDate,
|
startDate,
|
||||||
startDate.plusDays(120)
|
startDate.plusDays(200)
|
||||||
))
|
))
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessage("Диапазон расписания не может превышать 120 дней");
|
.hasMessage("Диапазон расписания не может превышать 200 дней");
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcademicYear academicYear() {
|
private AcademicYear academicYear() {
|
||||||
|
|||||||
@@ -181,6 +181,15 @@ class ScheduleOverrideServiceTest {
|
|||||||
verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE);
|
verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsRegistryRangeLongerThanTwoHundredDays() {
|
||||||
|
assertThatThrownBy(() -> service.getAll(DATE, DATE.plusDays(200)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessage("Диапазон точечных изменений не может превышать 200 дней");
|
||||||
|
|
||||||
|
verify(scheduleOverrideRepository, never()).findForRegistry(any(LocalDate.class), any(LocalDate.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void availabilityContainsOnlyStudyDatesOfSourceSemester() {
|
void availabilityContainsOnlyStudyDatesOfSourceSemester() {
|
||||||
Semester semester = configureRuleForTarget();
|
Semester semester = configureRuleForTarget();
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ class ScheduleQueryServiceTest {
|
|||||||
groupLifecycleService
|
groupLifecycleService
|
||||||
);
|
);
|
||||||
LocalDate startDate = LocalDate.of(2026, 1, 1);
|
LocalDate startDate = LocalDate.of(2026, 1, 1);
|
||||||
LocalDate allowedEndDate = startDate.plusDays(119);
|
LocalDate allowedEndDate = startDate.plusDays(199);
|
||||||
LocalDate rejectedEndDate = startDate.plusDays(120);
|
LocalDate rejectedEndDate = startDate.plusDays(200);
|
||||||
when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate))
|
when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate))
|
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate))
|
||||||
@@ -105,7 +105,7 @@ class ScheduleQueryServiceTest {
|
|||||||
null, null, null, null,
|
null, null, null, null,
|
||||||
startDate, rejectedEndDate))
|
startDate, rejectedEndDate))
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessage("Диапазон расписания не может превышать 120 дней");
|
.hasMessage("Диапазон расписания не может превышать 200 дней");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import static org.mockito.Mockito.verify;
|
|||||||
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
||||||
properties = {
|
properties = {
|
||||||
"spring.jpa.open-in-view=false",
|
"spring.jpa.open-in-view=false",
|
||||||
|
"spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true",
|
||||||
"management.endpoint.health.validate-group-membership=false"
|
"management.endpoint.health.validate-group-membership=false"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -86,6 +87,48 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
|||||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Страницы правил не пересекаются, поиск работает вне первой страницы")
|
||||||
|
void pagesRulesAndSearchesAcrossWholeVersion() {
|
||||||
|
Long versionId = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT schedule_version_id FROM schedule_rules
|
||||||
|
WHERE status <> 'ARCHIVED' GROUP BY schedule_version_id
|
||||||
|
ORDER BY COUNT(*) DESC LIMIT 1
|
||||||
|
""", Long.class);
|
||||||
|
var first = service.getPage(versionId, "", 0, 25);
|
||||||
|
var second = service.getPage(versionId, "", 1, 25);
|
||||||
|
assertThat(first.items()).hasSize(25);
|
||||||
|
assertThat(second.items()).hasSize(25);
|
||||||
|
assertThat(first.totalItems()).isGreaterThan(50);
|
||||||
|
assertThat(first.items()).extracting(ScheduleRuleDto::id)
|
||||||
|
.doesNotContainAnyElementsOf(second.items().stream().map(ScheduleRuleDto::id).toList());
|
||||||
|
var rule = second.items().get(0);
|
||||||
|
assertThat(rule.slots()).isNotEmpty();
|
||||||
|
assertThat(service.getPage(versionId, rule.id().toString(), 0, 25).items())
|
||||||
|
.extracting(ScheduleRuleDto::id).contains(rule.id());
|
||||||
|
assertThat(service.getPage(versionId, rule.subjectName().toUpperCase(java.util.Locale.ROOT), 0, 25).items())
|
||||||
|
.isNotEmpty().allMatch(item -> item.subjectName().equals(rule.subjectName()));
|
||||||
|
assertThat(service.getPage(versionId, rule.groupNames().get(0), 0, 25).items())
|
||||||
|
.isNotEmpty().allMatch(item -> item.groupNames().contains(rule.groupNames().get(0)));
|
||||||
|
assertThat(service.getPage(versionId, "НетТакойДисциплины", 0, 25).totalItems()).isZero();
|
||||||
|
assertThat(service.getPage(versionId, "", 100000, 25).items()).isEmpty();
|
||||||
|
assertThat(service.getPage(versionId, "", -1, 1000).size()).isEqualTo(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.magistr.app.repository.AcademicCalendarRepository calendarRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Календарные графики ищутся по году с ограничением страницы в БД")
|
||||||
|
void pagesCalendarsForFutureYears() {
|
||||||
|
var page = calendarRepository.searchOptions("2027-2028", null, null, null, null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(0, 25,
|
||||||
|
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
|
||||||
|
.and(org.springframework.data.domain.Sort.by("title", "id"))));
|
||||||
|
assertThat(page.getTotalElements()).isEqualTo(44);
|
||||||
|
assertThat(page.getContent()).hasSize(25).allMatch(calendar -> calendar.getAcademicYear().getTitle().equals("2027-2028"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
|
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
|
||||||
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
||||||
|
|||||||
@@ -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` установлено только для `Т`.
|
||||||
|
|
||||||
## Правило генерации расписания
|
## Правило генерации расписания
|
||||||
|
|
||||||
|
|||||||
124
docs/API.md
124
docs/API.md
@@ -230,10 +230,39 @@ Refresh-токен ротируется при каждом успешном о
|
|||||||
|
|
||||||
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
|
`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
|
||||||
|
|
||||||
|
### `GET /api/users/page`
|
||||||
|
|
||||||
|
Пагинированный реестр пользователей для администратора. Параметры: `query`, `role`,
|
||||||
|
`departmentId`, `includeArchived`, `page`, `size`, `sort`, `direction`. Размер страницы
|
||||||
|
ограничен диапазоном `1..100`; поддерживаемые поля сортировки — `fullName`, `username`,
|
||||||
|
`role`, `status`.
|
||||||
|
|
||||||
|
Пагинированные endpoints используют общий ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [],
|
||||||
|
"page": 0,
|
||||||
|
"size": 25,
|
||||||
|
"totalItems": 0,
|
||||||
|
"totalPages": 0,
|
||||||
|
"first": true,
|
||||||
|
"last": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `GET /api/users/teachers`
|
### `GET /api/users/teachers`
|
||||||
|
|
||||||
Список только преподавателей (роль `TEACHER`).
|
Список только преподавателей (роль `TEACHER`).
|
||||||
|
|
||||||
|
### `GET /api/users/teachers/options`
|
||||||
|
|
||||||
|
Серверный поиск преподавателей для асинхронных списков. Параметры: `query`,
|
||||||
|
`departmentId`, `selectedId`, `limit` (`1..50`). Поиск выполняется по логину, ФИО и
|
||||||
|
должности; архивные записи исключаются. Для роли `DEPARTMENT` backend принудительно
|
||||||
|
использует кафедру текущего пользователя и учитывает действующие основные и дополнительные
|
||||||
|
назначения из `teacher_department_assignments`. `selectedId` не обходит это ограничение.
|
||||||
|
|
||||||
### `GET /api/users/teachers/{departmentId}`
|
### `GET /api/users/teachers/{departmentId}`
|
||||||
|
|
||||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
|
||||||
@@ -309,6 +338,7 @@ Refresh-токен ротируется при каждом успешном о
|
|||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
|
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
|
||||||
|
| `GET` | `/api/teacher-requests/page?query=&status=PENDING&page=0&size=25` | Пагинированный поиск заявок по логину, ФИО, должности и кафедре |
|
||||||
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
|
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
|
||||||
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
|
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
|
||||||
|
|
||||||
@@ -365,10 +395,9 @@ Refresh-токен ротируется при каждом успешном о
|
|||||||
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
||||||
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
||||||
|
|
||||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120
|
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 200
|
||||||
календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля
|
календарных дат с учётом обеих границ: 200 дат разрешены, 201 дата отклоняется. Если у
|
||||||
невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и
|
группы нет назначения календарного графика на учебный год даты,
|
||||||
отклоняется. Если у группы нет назначения календарного графика на учебный год даты,
|
|
||||||
расписание для неё возвращается пустым списком. Время пары берётся из базового слота
|
расписание для неё возвращается пустым списком. Время пары берётся из базового слота
|
||||||
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
|
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
|
||||||
`/api/admin/time-slots`.
|
`/api/admin/time-slots`.
|
||||||
@@ -508,12 +537,30 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
**Тело учебного года:**
|
**Тело учебного года:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "2026-2027",
|
|
||||||
"startDate": "2026-09-01",
|
"startDate": "2026-09-01",
|
||||||
|
"semesters": [
|
||||||
|
{
|
||||||
|
"semesterType": "autumn",
|
||||||
|
"startDate": "2026-09-01",
|
||||||
|
"endDate": "2027-01-31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"semesterType": "spring",
|
||||||
|
"startDate": "2027-02-01",
|
||||||
"endDate": "2027-06-30"
|
"endDate": "2027-06-30"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Название и дата окончания учебного года вычисляются backend автоматически по дате начала:
|
||||||
|
для `2026-09-01` будут сохранены название `2026-2027` и окончание `2027-06-30`.
|
||||||
|
В административной форме пользователь вводит только год `2026`; frontend преобразует его
|
||||||
|
в `startDate: 2026-09-01` и сразу показывает вычисленный год окончания `2027`.
|
||||||
|
При создании требуется хотя бы один семестр; учебный год и все переданные семестры
|
||||||
|
сохраняются одной транзакцией. Поля `title` и `endDate`, переданные старым клиентом,
|
||||||
|
не влияют на вычисленные значения.
|
||||||
|
|
||||||
**Тело семестра:**
|
**Тело семестра:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -526,10 +573,11 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного
|
Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного
|
||||||
года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий
|
года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий
|
||||||
период разрешено начать на следующий календарный день после окончания предыдущего.
|
период разрешено начать на следующий календарный день после окончания предыдущего.
|
||||||
Повторное название года, повторный тип семестра или пересечение возвращаются как
|
Повторный тип семестра или пересечение возвращаются как
|
||||||
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
|
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
|
||||||
При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
|
При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
|
||||||
Создание семестра также создаёт для него пустую версию 1 со статусом `PUBLISHED` и названием
|
Создание учебного года или отдельного семестра также создаёт для каждого нового семестра
|
||||||
|
пустую версию 1 со статусом `PUBLISHED` и названием
|
||||||
`Основное расписание`, поэтому конструктор сразу может работать в обычном режиме.
|
`Основное расписание`, поэтому конструктор сразу может работать в обычном режиме.
|
||||||
|
|
||||||
**Тело создания/обновления кода активности:**
|
**Тело создания/обновления кода активности:**
|
||||||
@@ -549,6 +597,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
|
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
|
||||||
|
| `GET` | `/api/admin/academic-calendars/page` | Страница графиков: `query`, `academicYearId`, `page`, `size` |
|
||||||
|
| `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса |
|
||||||
| `GET` | `/api/admin/academic-calendars/{id}` | Один график |
|
| `GET` | `/api/admin/academic-calendars/{id}` | Один график |
|
||||||
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
||||||
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
|
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
|
||||||
@@ -575,6 +625,18 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
|
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
|
||||||
Отдельного пользовательского названия у календарного графика нет.
|
Отдельного пользовательского названия у календарного графика нет.
|
||||||
|
|
||||||
|
`GET /api/admin/academic-calendars/page` возвращает `PageResponse` с полями
|
||||||
|
`items`, `page`, `size`, `totalItems`, `totalPages`, `first`, `last`.
|
||||||
|
Нумерация страниц начинается с 0, размер по умолчанию 25, сервер ограничивает его диапазоном 1–100.
|
||||||
|
Поиск `query` без учёта регистра охватывает название графика, учебный год, код и название
|
||||||
|
специальности, профиль и форму обучения. Сортировка: начало учебного года по убыванию,
|
||||||
|
затем название и ID по возрастанию.
|
||||||
|
|
||||||
|
`GET /api/admin/academic-calendars/options` принимает `query`, `academicYearId`,
|
||||||
|
`specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск
|
||||||
|
выполняется по составному названию, учебному году, коду/названию специальности, профилю и
|
||||||
|
форме обучения. Фасетные параметры позволяют не показывать группе несовместимые графики.
|
||||||
|
|
||||||
`POST /api/admin/academic-calendars` создаёт график и начальную сетку одной транзакцией.
|
`POST /api/admin/academic-calendars` создаёт график и начальную сетку одной транзакцией.
|
||||||
Для каждого курса из диапазона `1..courseCount` backend сохраняет один период с кодом `Т`
|
Для каждого курса из диапазона `1..courseCount` backend сохраняет один период с кодом `Т`
|
||||||
на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после
|
на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после
|
||||||
@@ -664,11 +726,18 @@ CRUD доступен по:
|
|||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
||||||
|
| `GET` | `/api/admin/schedule-rules/page` | Страница правил: обязательный `versionId`, параметры `query`, `page`, `size` |
|
||||||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||||||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||||
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
|
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
|
||||||
|
|
||||||
|
`GET /api/admin/schedule-rules/page` возвращает тот же формат `PageResponse`;
|
||||||
|
`page` по умолчанию 0, `size` — 25 (ограничение 1–100). Правила выбранной версии,
|
||||||
|
кроме архивных, сортируются по ID по убыванию. `query` ищет по названию дисциплины,
|
||||||
|
названию группы или точному ID. Сначала БД выбирает страницу ID, затем загружаются
|
||||||
|
группы и слоты только этих правил; пагинация не выполняется в памяти над полной коллекцией.
|
||||||
|
|
||||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||||
|
|
||||||
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
|
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
|
||||||
@@ -760,6 +829,9 @@ ID существующих слотов сохраняются; удалени
|
|||||||
| `timeSlotId` | Временной слот |
|
| `timeSlotId` | Временной слот |
|
||||||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||||||
|
|
||||||
|
Диапазон включает обе границы и не может превышать 200 календарных дней. Этого достаточно,
|
||||||
|
чтобы запросить осенний или весенний семестр целиком одним обращением.
|
||||||
|
|
||||||
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
|
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
|
||||||
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
|
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
|
||||||
назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится
|
назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится
|
||||||
@@ -806,7 +878,7 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
|
|||||||
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила |
|
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила |
|
||||||
|
|
||||||
`startDate` и `endDate` у списка передаются только парой, включительно; максимальный
|
`startDate` и `endDate` у списка передаются только парой, включительно; максимальный
|
||||||
диапазон — 120 дней. Ответ реестра содержит исходные дату, время, преподавателя,
|
диапазон — 200 дней. Ответ реестра содержит исходные дату, время, преподавателя,
|
||||||
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
|
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
|
||||||
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
|
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
|
||||||
|
|
||||||
@@ -1284,6 +1356,21 @@ payload обрабатываются один раз, используется
|
|||||||
|
|
||||||
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
|
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
|
||||||
|
|
||||||
|
### `GET /api/groups/page`
|
||||||
|
|
||||||
|
Пагинированный реестр групп. Параметры: `query`, повторяемый `educationFormId`,
|
||||||
|
`departmentId`, `yearStartStudy`, `includeArchived`, `page`, `size`, `sort`, `direction`.
|
||||||
|
Поддерживаемые поля сортировки: `name`, `yearStartStudy`, `groupSize`, `status`. Поиск
|
||||||
|
проверяет название группы, код и название специальности, профиль и форму обучения. Для
|
||||||
|
роли `DEPARTMENT` кафедра всегда берётся из JWT-контекста независимо от переданного фильтра.
|
||||||
|
|
||||||
|
### `GET /api/groups/options`
|
||||||
|
|
||||||
|
Асинхронный поиск активных групп. Параметры: `query`, `departmentId`, `selectedId`, `limit`
|
||||||
|
(`1..50`). Завершившие обучение и архивные группы исключаются. Для роли `DEPARTMENT`
|
||||||
|
действует то же принудительное ограничение своей кафедрой, включая восстановление выбранного
|
||||||
|
`selectedId`.
|
||||||
|
|
||||||
**Ответ:**
|
**Ответ:**
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
@@ -1387,7 +1474,7 @@ legacy-alias для старых клиентов и исторически со
|
|||||||
|
|
||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/subgroups` | Список всех подгрупп |
|
| `GET` | `/api/subgroups?groupId=1&groupId=2` | Подгруппы выбранных групп; без параметра — совместимый список всех подгрупп, не более 100 ID групп за запрос |
|
||||||
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
|
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
|
||||||
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
|
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
|
||||||
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
|
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
|
||||||
@@ -1465,6 +1552,12 @@ legacy-alias для старых клиентов и исторически со
|
|||||||
|
|
||||||
Список аудиторий с привязанным оборудованием.
|
Список аудиторий с привязанным оборудованием.
|
||||||
|
|
||||||
|
### `GET /api/classrooms/options`
|
||||||
|
|
||||||
|
Асинхронный поиск доступных неархивных аудиторий по названию и корпусу. Параметры:
|
||||||
|
`query`, `selectedId`, `limit` (`1..50`). Выбранная запись добавляется в результат, если
|
||||||
|
она не попала в первую страницу поиска.
|
||||||
|
|
||||||
**Ответ:**
|
**Ответ:**
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
@@ -1518,6 +1611,12 @@ legacy-alias для старых клиентов и исторически со
|
|||||||
|
|
||||||
Список всех дисциплин.
|
Список всех дисциплин.
|
||||||
|
|
||||||
|
### `GET /api/subjects/page`
|
||||||
|
|
||||||
|
Пагинированный реестр дисциплин. Параметры: `query`, `departmentId`, `includeArchived`,
|
||||||
|
`page`, `size`, `sort`, `direction`; сортировка разрешена по `name`, `code`,
|
||||||
|
`departmentId`, `status`. Роль `DEPARTMENT` всегда ограничивается своей кафедрой на backend.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "Физика",
|
"name": "Физика",
|
||||||
@@ -1599,14 +1698,17 @@ legacy-alias для старых клиентов и исторически со
|
|||||||
|
|
||||||
### `GET /api/teacher-subjects`
|
### `GET /api/teacher-subjects`
|
||||||
|
|
||||||
Список всех привязок.
|
Список привязок. Повторяемый параметр `subjectId` ограничивает результат указанными
|
||||||
|
дисциплинами; за один запрос принимается не более 100 ID. Без параметра сохраняется
|
||||||
|
совместимый режим получения всех доступных текущей роли привязок.
|
||||||
|
|
||||||
**Ответ:**
|
**Ответ:**
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"userId": 2,
|
"userId": 2,
|
||||||
"userName": "Тестовый преподаватель",
|
"username": "teacher2",
|
||||||
|
"fullName": "Тестовый преподаватель",
|
||||||
"subjectId": 1,
|
"subjectId": 1,
|
||||||
"subjectName": "Высшая математика"
|
"subjectName": "Высшая математика"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
|
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
|
||||||
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
|
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
|
||||||
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
|
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
|
||||||
графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет
|
графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции
|
||||||
такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или
|
`V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам
|
||||||
полностью настроенные сетки она не изменяет.
|
без единого периода; частично или полностью настроенные сетки не изменяются.
|
||||||
|
|
||||||
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
|
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
|
||||||
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
|
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
|
||||||
@@ -211,9 +211,10 @@ constraint. При чтении API разворачивает периоды о
|
|||||||
|
|
||||||
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
|
||||||
|
|
||||||
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными
|
Диапазон расписания включает обе переданные границы и ограничен ровно 200 календарными
|
||||||
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат
|
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 200 дат
|
||||||
разрешены, 121 дата отклоняется до генерации.
|
разрешены, 201 дата отклоняется до генерации. Один запрос поэтому покрывает учебный
|
||||||
|
семестр целиком.
|
||||||
|
|
||||||
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
|
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
|
||||||
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
|
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
|
||||||
|
|||||||
@@ -953,8 +953,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
|||||||
|
|
||||||
Пара `semester_id + version_number` уникальна. Частичный индекс
|
Пара `semester_id + version_number` уникальна. Частичный индекс
|
||||||
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
|
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
|
||||||
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и
|
семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
|
||||||
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после
|
привязывает к ней тестовые правила. Для семестров, создаваемых через API после
|
||||||
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
|
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
|
||||||
`Основное расписание` и две записи истории: создание и публикацию.
|
`Основное расписание` и две записи истории: создание и публикацию.
|
||||||
|
|
||||||
@@ -1177,10 +1177,15 @@ CHECK фиксирует допустимую форму каждого типа
|
|||||||
### Правила работы
|
### Правила работы
|
||||||
|
|
||||||
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
||||||
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
|
2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
|
||||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
|
триггеры и ограничения изменяются только в `V1__init.sql`.
|
||||||
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
|
||||||
5. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
|
ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
|
||||||
|
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
|
||||||
|
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
|
||||||
|
tenant-БД. Сброс выполняется только по прямой команде владельца.
|
||||||
|
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||||||
|
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
|
||||||
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
|
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
|
||||||
пустой tenant-схеме
|
пустой tenant-схеме
|
||||||
|
|
||||||
@@ -1188,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__backfill_default_theory_periods.sql` | Добавляет периоды `Т` на весь учебный год для каждого курса только в полностью пустых календарных графиках; уже настроенные графики не изменяет |
|
| `V2__test_data.sql` | Единый тестовый seed: прежние демонстрационные записи V1, справочники и полный набор связанных нагрузочных данных. Здесь же после создания графиков сохранена логика прежней V2: период `Т` на весь год добавляется каждому курсу только полностью пустого графика |
|
||||||
|
|
||||||
### Этап разработки
|
### Этап разработки
|
||||||
|
|
||||||
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями
|
Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
|
||||||
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
|
владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
|
||||||
проекта объединены в baseline `V1`. После фиксации baseline новые изменения оформляются
|
в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
|
||||||
отдельными инкрементальными миграциями; первой стала `V2__backfill_default_theory_periods.sql`.
|
tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
|
||||||
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
|
||||||
непосредственно в V1.
|
`V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
|
||||||
Существующая tenant-БД с уже применённой V1 получает V2 без изменения контрольной суммы
|
Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый tenant, включая
|
||||||
baseline. Для развёртывания V1 с нуля по-прежнему требуется пустая tenant-схема.
|
production. Это сознательное ограничение текущего режима разработки; перед production-релизом
|
||||||
|
V2 необходимо сделать условной или исключить из production locations.
|
||||||
|
|
||||||
|
### Объём тестового набора V2
|
||||||
|
|
||||||
|
На чистой схеме V2 создаёт детерминированный связанный набор:
|
||||||
|
|
||||||
|
| Сущности | Количество |
|
||||||
|
|----------|-----------:|
|
||||||
|
| Кафедры / специальности / профили | 41 / 42 / 42 |
|
||||||
|
| Пользователи / преподаватели | 155 / 151 |
|
||||||
|
| Группы / подгруппы | 202 / 404 |
|
||||||
|
| Дисциплины / связи преподаватель–дисциплина / разрешённые типы занятий | 229 / 844 / 2532 |
|
||||||
|
| Аудитории / связи с оборудованием | 80 / 174 |
|
||||||
|
| Календарные графики / периоды / дисциплины графиков / назначения групп | 132 / 576 / 3456 / 202 |
|
||||||
|
| Версии / события истории | 8 / 16 |
|
||||||
|
| Правила / связи с группами / слоты / лабораторные подгруппы | 610 / 611 / 610 / 202 |
|
||||||
|
| Пожелания / отсутствия / комментарии к дисциплинам | 60 / 20 / 40 |
|
||||||
|
|
||||||
|
Учебные годы: 2024–2025, 2025–2026, 2026–2027 и 2027–2028. Для трёх последних
|
||||||
|
создаётся одинаковый набор из 44 графиков направлений с периодами теоретического обучения
|
||||||
|
и дисциплинами по семестрам. Назначения существующих групп сохраняются на 2025–2026 год.
|
||||||
|
|
||||||
|
Из 202 групп две (`ИВТ-21-1`, `ИБ-41м`) перенесены из прежней V1, ещё 200 созданы по
|
||||||
|
шифрам направлений ЮЗГУ. Источниками названий служат публичные страницы структуры,
|
||||||
|
приёмной кампании и перечней дисциплин ЮЗГУ; ФИО 150 массовых преподавателей синтетические
|
||||||
|
и имеют непубликуемый случайный пароль. Доступны для ручного входа только документированные
|
||||||
|
исторические demo-аккаунты.
|
||||||
|
|
||||||
### Полный сброс БД (локально)
|
### Полный сброс БД (локально)
|
||||||
|
|
||||||
|
|||||||
@@ -251,18 +251,18 @@ public class AbsenceController {
|
|||||||
|
|
||||||
### Правила
|
### Правила
|
||||||
|
|
||||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
|
1. До отдельного решения владельца вся постоянная схема изменяется только в `V1__init.sql`
|
||||||
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
|
2. `V2__test_data.sql` предназначена исключительно для тестовых и демонстрационных данных;
|
||||||
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
|
постоянный DDL в ней запрещён
|
||||||
4. После добавления — перезапустите backend для применения
|
3. Не создавайте `V3` и следующие миграции, пока владелец не отменит режим пересобираемого baseline
|
||||||
|
4. Любое изменение V1/V2 требует полного сброса затронутых tenant-БД из-за checksum mismatch
|
||||||
|
5. Не сбрасывайте tenant-БД без прямой команды владельца
|
||||||
|
|
||||||
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
|
`V1__init.sql` содержит только структуру: таблицы, функции, триггеры, индексы, ограничения
|
||||||
|
и комментарии. `V2__test_data.sql` содержит все прежние seed-записи, большой связанный набор
|
||||||
`V1__init.sql` зафиксирован как baseline: по прямому решению владельца в него объединено
|
и заполнение периодов `Т` для полностью пустых календарных графиков. Такое разделение позволяет
|
||||||
содержимое прежних разработческих V2–V7, а также таблицы отсутствий, решений по заменам,
|
отдельно проверить schema-only V1, но обе миграции по-прежнему автоматически применяются ко
|
||||||
пожеланий и заявок преподавателей. Новые изменения существующих tenant-БД оформляются только
|
всем tenant-БД.
|
||||||
отдельными инкрементальными миграциями. Текущая `V2__backfill_default_theory_periods.sql`
|
|
||||||
заполняет кодом `Т` полностью пустые календарные графики и не изменяет V1.
|
|
||||||
|
|
||||||
### Применение
|
### Применение
|
||||||
|
|
||||||
@@ -270,6 +270,10 @@ public class AbsenceController {
|
|||||||
# Локально — сброс и повтор всех миграций
|
# Локально — сброс и повтор всех миграций
|
||||||
docker compose down -v && docker compose up -d
|
docker compose down -v && docker compose up -d
|
||||||
|
|
||||||
|
# Интеграционная проверка V1/V2 на PostgreSQL через Testcontainers
|
||||||
|
cd backend
|
||||||
|
mvn -Dtest='com.magistr.app.migration.*IntegrationTest' test
|
||||||
|
|
||||||
# Продакшн — применить к существующим тенантам
|
# Продакшн — применить к существующим тенантам
|
||||||
kubectl rollout restart deployment backend -n magistr
|
kubectl rollout restart deployment backend -n magistr
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД.
|
Новая модель является базовой схемой проекта и создаётся в
|
||||||
|
`backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по
|
||||||
|
типам занятий уже включены в schema-only baseline, а все демонстрационные записи загружаются
|
||||||
|
отдельно из `V2__test_data.sql`. Применение рассчитано на полный сброс БД.
|
||||||
|
|
||||||
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
||||||
|
|
||||||
## База данных
|
## База данных
|
||||||
|
|
||||||
`V1__init.sql` создаёт и заполняет:
|
`V1__init.sql` создаёт структуру следующих таблиц, а `V2__test_data.sql` заполняет их
|
||||||
|
связанным тестовым набором:
|
||||||
|
|
||||||
| Таблица | Назначение |
|
| Таблица | Назначение |
|
||||||
|---------|------------|
|
|---------|------------|
|
||||||
|
|||||||
379
docs/FRONTEND.md
379
docs/FRONTEND.md
File diff suppressed because one or more lines are too long
@@ -95,10 +95,11 @@ Backend собирается через multi-stage сборку Maven:
|
|||||||
```dockerfile
|
```dockerfile
|
||||||
FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets
|
FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
RUN npm run build:vendor
|
RUN npm run build
|
||||||
|
|
||||||
FROM httpd:alpine3.23@sha256:4a15e9c...
|
FROM httpd:alpine3.23@sha256:4a15e9c...
|
||||||
RUN apk upgrade --no-cache
|
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 --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
|
||||||
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
|
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
|
||||||
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
|
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
|
||||||
@@ -106,8 +107,10 @@ COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
|
|||||||
|
|
||||||
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
|
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
|
||||||
tag и manifest digest. Первый этап frontend собирает зафиксированный
|
tag и manifest digest. Первый этап frontend собирает зафиксированный
|
||||||
OpenTelemetry bundle; второй раздаёт только runtime-
|
OpenTelemetry bundle и production-bundle React-страницы входа; второй раздаёт только runtime-
|
||||||
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
|
файлы, без `node_modules`, тестов и build-исходников. В корень образа также копируются
|
||||||
|
`ui-foundation.css`, `schedule-overview.css` и `schedule-overview.js`: они необходимы
|
||||||
|
для общего шрифта, компонентов и обзора занятий в личных кабинетах. Apache подключает `mod_headers`,
|
||||||
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
|
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
|
||||||
маршрутизировался в tenant `default`. Перед копированием runtime-файлов устанавливаются
|
маршрутизировался в tenant `default`. Перед копированием runtime-файлов устанавливаются
|
||||||
доступные исправления Alpine через `apk upgrade --no-cache`. Сервер
|
доступные исправления Alpine через `apk upgrade --no-cache`. Сервер
|
||||||
|
|||||||
675
docs/REACT_MIGRATION.md
Normal file
675
docs/REACT_MIGRATION.md
Normal 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
|
||||||
|
```
|
||||||
101
docs/REACT_MIGRATION_RESULT.md
Normal file
101
docs/REACT_MIGRATION_RESULT.md
Normal 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
|
||||||
|
```
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| Компонент | Технология |
|
| Компонент | Технология |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| **Backend** | Java 17, Spring Boot 3.5.16 |
|
| **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 миграции) |
|
| **База данных** | PostgreSQL (через Flyway миграции) |
|
||||||
| **Контейнеризация** | Docker, Docker Compose |
|
| **Контейнеризация** | Docker, Docker Compose |
|
||||||
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |
|
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |
|
||||||
@@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик
|
|||||||
| `admin` | `admin` | Администратор |
|
| `admin` | `admin` | Администратор |
|
||||||
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
|
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
|
||||||
|
|
||||||
|
Эти и остальные прежние демонстрационные записи загружаются миграцией
|
||||||
|
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
|
||||||
|
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
|
||||||
|
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
|
||||||
|
|
||||||
### Полезные команды
|
### Полезные команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
2. Для группы находится назначенный календарный учебный график.
|
2. Для группы находится назначенный календарный учебный график.
|
||||||
3. По году начала обучения вычисляется текущий курс группы.
|
3. По году начала обучения вычисляется текущий курс группы.
|
||||||
4. По курсу и дате находится код активности.
|
4. По курсу и дате находится код активности.
|
||||||
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`.
|
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в тестовых данных
|
||||||
|
`V2__test_data.sql` это `Т`.
|
||||||
6. Для разрешённого дня выбираются активные правила и слоты.
|
6. Для разрешённого дня выбираются активные правила и слоты.
|
||||||
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
## Проверочный сценарий
|
## Проверочный сценарий
|
||||||
|
|
||||||
1. Полностью сбросить локальную БД.
|
1. Полностью сбросить локальную БД.
|
||||||
2. Запустить приложение и применить `V1__init.sql`.
|
2. Запустить приложение и применить schema-only `V1__init.sql`, затем тестовый набор
|
||||||
|
`V2__test_data.sql`.
|
||||||
3. Создать профиль специальности.
|
3. Создать профиль специальности.
|
||||||
4. Создать календарный график и заполнить дневную сетку.
|
4. Создать календарный график и заполнить дневную сетку.
|
||||||
5. Назначить график группе.
|
5. Назначить график группе.
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ FROM node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
COPY auth-session.js telemetry.js schedule-overview.js ./
|
||||||
|
COPY admin/js/ ./admin/js/
|
||||||
|
COPY react/ ./react/
|
||||||
COPY telemetry/ ./telemetry/
|
COPY telemetry/ ./telemetry/
|
||||||
COPY scripts/build-vendor.mjs ./scripts/build-vendor.mjs
|
COPY scripts/build-react.mjs scripts/build-vendor.mjs ./scripts/
|
||||||
RUN npm run build:vendor
|
RUN npm run build
|
||||||
|
|
||||||
FROM httpd:alpine3.23@sha256:4a15e9c73f25334bc03cfb3c692c9adfc103bb46ca89cee1f0b9a5fcbc7b21f6
|
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 edu-office/ /usr/local/apache2/htdocs/edu-office/
|
||||||
COPY student/ /usr/local/apache2/htdocs/student/
|
COPY student/ /usr/local/apache2/htdocs/student/
|
||||||
COPY teacher/ /usr/local/apache2/htdocs/teacher/
|
COPY teacher/ /usr/local/apache2/htdocs/teacher/
|
||||||
COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
|
COPY index.html 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 --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
|
||||||
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
|
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
|
||||||
|
COPY react-cache.conf /usr/local/apache2/conf/extra/magistr-react-cache.conf
|
||||||
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
|
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
|
||||||
|
|
||||||
# Сервер раздаёт same-origin ресурсы и проксирует API во внутреннюю сеть Compose.
|
# Сервер раздаёт same-origin ресурсы и проксирует API во внутреннюю сеть Compose.
|
||||||
RUN sed -i 's/^#LoadModule headers_module/LoadModule headers_module/' /usr/local/apache2/conf/httpd.conf \
|
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_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 \
|
&& 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/
|
&& chown -R www-data:www-data /usr/local/apache2/htdocs/
|
||||||
|
|||||||
@@ -11,6 +11,22 @@
|
|||||||
|
|
||||||
.workload-filter-row {
|
.workload-filter-row {
|
||||||
flex-wrap: wrap;
|
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 {
|
.workload-display-control {
|
||||||
@@ -630,7 +646,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schedule-view-extra-grid {
|
.schedule-view-extra-grid {
|
||||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,13 @@
|
|||||||
align-content: flex-start;
|
align-content: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
|
height: 180px;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
padding-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-actions {
|
.dashboard-actions {
|
||||||
@@ -689,6 +696,58 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-initial-state {
|
||||||
|
display: flex;
|
||||||
|
min-height: 220px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-initial-content {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-initial-state[hidden],
|
||||||
|
.schedule-initial-content.is-loading {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-initial-content .card,
|
||||||
|
#schedule-table tbody tr {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#schedule-table {
|
||||||
|
min-width: 1180px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#schedule-table .schedule-rule-col-id { width: 5%; }
|
||||||
|
#schedule-table .schedule-rule-col-subject { width: 16%; }
|
||||||
|
#schedule-table .schedule-rule-col-semester { width: 13%; }
|
||||||
|
#schedule-table .schedule-rule-col-groups { width: 16%; }
|
||||||
|
#schedule-table .schedule-rule-col-load { width: 14%; }
|
||||||
|
#schedule-table .schedule-rule-col-slots { width: 20%; }
|
||||||
|
#schedule-table .schedule-rule-col-actions { width: 16%; }
|
||||||
|
|
||||||
|
#schedule-table th,
|
||||||
|
#schedule-table td {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#schedule-table thead th {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-group-select {
|
.schedule-group-select {
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
@@ -703,6 +762,23 @@
|
|||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-group-search {
|
||||||
|
width: calc(100% - 1rem);
|
||||||
|
min-height: 38px;
|
||||||
|
margin: 0.5rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-group-search:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: 3px solid var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-hours-panel {
|
.schedule-hours-panel {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -1014,9 +1090,9 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(10, 10, 15, 0.97);
|
background: rgba(10, 10, 15, 0.97);
|
||||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
|
||||||
top: 50%;
|
top: 0;
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translate(-50%, -50%);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-visual-context-menu[hidden] {
|
.schedule-visual-context-menu[hidden] {
|
||||||
@@ -1325,6 +1401,152 @@
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.academic-year-builder-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-builder-description {
|
||||||
|
margin: 0.28rem 0 0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-period-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#academic-year-end[readonly] {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: color-mix(in srgb, var(--bg-input) 82%, var(--accent) 5%);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-field-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-builder {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--bg-input) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-builder-head,
|
||||||
|
.academic-year-semester-card-title,
|
||||||
|
.academic-year-form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-builder-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-builder-head h3,
|
||||||
|
.academic-year-semester-builder-head p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-builder-head h3 {
|
||||||
|
font-size: 0.96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-builder-head p {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-required-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-card-title {
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-dot-autumn {
|
||||||
|
color: #f59e0b;
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-dot-spring {
|
||||||
|
color: #22c55e;
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-dates {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-form-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.academic-year-semester-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.academic-year-period-grid,
|
||||||
|
.academic-year-semester-dates {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-builder-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-form-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-inline-table {
|
.schedule-inline-table {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -2148,6 +2370,20 @@
|
|||||||
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition);
|
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-activity-color-10b981 { --activity-color: #10b981; }
|
||||||
|
.calendar-activity-color-f59e0b { --activity-color: #f59e0b; }
|
||||||
|
.calendar-activity-color-38bdf8 { --activity-color: #38bdf8; }
|
||||||
|
.calendar-activity-color-a78bfa { --activity-color: #a78bfa; }
|
||||||
|
.calendar-activity-color-fb7185 { --activity-color: #fb7185; }
|
||||||
|
.calendar-activity-color-f97316 { --activity-color: #f97316; }
|
||||||
|
.calendar-activity-color-22c55e { --activity-color: #22c55e; }
|
||||||
|
.calendar-activity-color-ef4444 { --activity-color: #ef4444; }
|
||||||
|
.calendar-activity-color-dc2626 { --activity-color: #dc2626; }
|
||||||
|
.calendar-activity-color-eab308 { --activity-color: #eab308; }
|
||||||
|
.calendar-activity-color-06b6d4 { --activity-color: #06b6d4; }
|
||||||
|
.calendar-activity-color-64748b { --activity-color: #64748b; }
|
||||||
|
.calendar-activity-color-475569 { --activity-color: #475569; }
|
||||||
|
|
||||||
.calendar-day:hover,
|
.calendar-day:hover,
|
||||||
.calendar-day:focus-visible {
|
.calendar-day:focus-visible {
|
||||||
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card));
|
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card));
|
||||||
@@ -2311,7 +2547,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-total-chip {
|
.calendar-total-chip {
|
||||||
--chip-color: var(--accent);
|
--chip-color: var(--activity-color, var(--accent));
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -3760,3 +3996,441 @@ tbody tr:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
.registry-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registry-toolbar .form-group {
|
||||||
|
min-width: min(260px, 100%);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registry-search {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-actions,
|
||||||
|
.pagination-size {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-summary,
|
||||||
|
.pagination-page,
|
||||||
|
.pagination-size span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-size select {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.25rem 1.75rem 0.25rem 0.55rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
margin-right: 0.55rem;
|
||||||
|
vertical-align: -2px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: state-spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes state-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.view-state-error {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-state-error .btn {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-control {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-control input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.45rem;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-clear:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.async-combobox-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1200;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(10, 10, 15, 0.96);
|
||||||
|
backdrop-filter: blur(20px) saturate(135%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(135%);
|
||||||
|
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .async-combobox-menu {
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-option {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-option:hover,
|
||||||
|
.async-combobox-option:focus,
|
||||||
|
.async-combobox-option[aria-selected="true"] {
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-option small,
|
||||||
|
.async-combobox-state {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-state {
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.async-combobox-state.is-error { color: var(--danger, #dc2626); }
|
||||||
|
|
||||||
|
.project-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 4000;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dialog {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dialog header,
|
||||||
|
.project-dialog footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dialog header h2 { margin: 0; }
|
||||||
|
.project-dialog footer { justify-content: flex-end; margin-top: 1rem; }
|
||||||
|
.project-dialog-field { display: grid; gap: 0.4rem; margin-top: 1rem; }
|
||||||
|
.project-dialog-field textarea { width: 100%; resize: vertical; }
|
||||||
|
.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ===== Êëàññû ýòàïà 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;
|
||||||
|
}
|
||||||
@@ -265,6 +265,20 @@
|
|||||||
margin: 0.25rem 0.5rem;
|
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 ===== */
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
.quality-hero h2 {
|
.quality-hero h2 {
|
||||||
max-width: 790px;
|
max-width: 790px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: clamp(2rem, 4.5vw, 4.4rem);
|
font-size: clamp(2rem, 4.5vw, 4.4rem);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.045em;
|
letter-spacing: -0.045em;
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
|
|
||||||
.quality-score-inner strong {
|
.quality-score-inner strong {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: 3.2rem;
|
font-size: 3.2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 0.95;
|
line-height: 0.95;
|
||||||
@@ -271,7 +271,7 @@
|
|||||||
.quality-score-copy h3 {
|
.quality-score-copy h3 {
|
||||||
margin: 0.55rem 0 0;
|
margin: 0.55rem 0 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: clamp(1.6rem, 3vw, 2.5rem);
|
font-size: clamp(1.6rem, 3vw, 2.5rem);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -338,7 +338,7 @@
|
|||||||
|
|
||||||
.quality-metric-index {
|
.quality-metric-index {
|
||||||
color: color-mix(in srgb, var(--text-secondary) 65%, transparent);
|
color: color-mix(in srgb, var(--text-secondary) 65%, transparent);
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
.quality-recommendations h3 {
|
.quality-recommendations h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -537,7 +537,7 @@
|
|||||||
|
|
||||||
.quality-penalty {
|
.quality-penalty {
|
||||||
color: var(--quality-red);
|
color: var(--quality-red);
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +583,7 @@
|
|||||||
background: color-mix(in srgb, var(--quality-cyan) 10%, transparent);
|
background: color-mix(in srgb, var(--quality-cyan) 10%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--quality-cyan) 35%, transparent);
|
border: 1px solid color-mix(in srgb, var(--quality-cyan) 35%, transparent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
right: -1.5rem;
|
right: -1.5rem;
|
||||||
bottom: 1.2rem;
|
bottom: 1.2rem;
|
||||||
color: rgba(255, 255, 255, .055);
|
color: rgba(255, 255, 255, .055);
|
||||||
font-family: Georgia, serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: clamp(4rem, 10vw, 9rem);
|
font-size: clamp(4rem, 10vw, 9rem);
|
||||||
letter-spacing: -.08em;
|
letter-spacing: -.08em;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
.version-hero h2 {
|
.version-hero h2 {
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: clamp(2rem, 4.5vw, 4.5rem);
|
font-size: clamp(2rem, 4.5vw, 4.5rem);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -.05em;
|
letter-spacing: -.05em;
|
||||||
@@ -79,6 +79,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.version-hero-controls label {
|
.version-hero-controls label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: .35rem;
|
||||||
color: rgba(255, 255, 255, .62);
|
color: rgba(255, 255, 255, .62);
|
||||||
font-size: .7rem;
|
font-size: .7rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -98,6 +100,12 @@
|
|||||||
|
|
||||||
.version-hero-controls option { color: #152536; background: #f7f4ed; }
|
.version-hero-controls option { color: #152536; background: #f7f4ed; }
|
||||||
|
|
||||||
|
.version-hero-period-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: .65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.version-status {
|
.version-status {
|
||||||
min-height: 2.8rem;
|
min-height: 2.8rem;
|
||||||
padding: .8rem 1rem;
|
padding: .8rem 1rem;
|
||||||
@@ -128,11 +136,15 @@
|
|||||||
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
|
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
|
||||||
border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border));
|
border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border));
|
||||||
}
|
}
|
||||||
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
|
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.55rem; }
|
||||||
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
|
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
|
||||||
.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .55rem; align-items: end; }
|
.version-constructor-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; align-items: end; }
|
||||||
.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
.version-constructor-field { min-width: 0; }
|
||||||
|
.version-constructor-field-wide { grid-column: 1 / -1; }
|
||||||
|
.version-constructor-controls label { display: block; margin-bottom: .35rem; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); }
|
.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); }
|
||||||
|
.version-constructor-field select { width: 100%; }
|
||||||
|
.version-constructor-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: .55rem; }
|
||||||
|
|
||||||
.version-current {
|
.version-current {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -207,7 +219,7 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-section-head > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 2rem; }
|
.version-section-head > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 2rem; }
|
||||||
.version-draft-list { display: grid; gap: .8rem; }
|
.version-draft-list { display: grid; gap: .8rem; }
|
||||||
|
|
||||||
.version-card {
|
.version-card {
|
||||||
@@ -225,7 +237,7 @@
|
|||||||
right: .5rem;
|
right: .5rem;
|
||||||
top: -.8rem;
|
top: -.8rem;
|
||||||
color: color-mix(in srgb, var(--version-copper) 12%, transparent);
|
color: color-mix(in srgb, var(--version-copper) 12%, transparent);
|
||||||
font-family: Georgia, serif;
|
font-family: var(--ui-font-display);
|
||||||
font-size: 5rem;
|
font-size: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +252,7 @@
|
|||||||
place-content: center;
|
place-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.version-empty > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 4rem; }
|
.version-empty > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 4rem; }
|
||||||
.version-empty p { max-width: 360px; }
|
.version-empty p { max-width: 360px; }
|
||||||
|
|
||||||
.version-diff-score {
|
.version-diff-score {
|
||||||
@@ -250,7 +262,7 @@
|
|||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
.version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; }
|
.version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; }
|
||||||
.version-diff-score strong { display: block; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.75rem; }
|
.version-diff-score strong { display: block; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.75rem; }
|
||||||
.version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; }
|
.version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; }
|
||||||
|
|
||||||
.version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; }
|
.version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; }
|
||||||
@@ -280,7 +292,7 @@
|
|||||||
}
|
}
|
||||||
.version-modal[hidden] { display: none; }
|
.version-modal[hidden] { display: none; }
|
||||||
.version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
|
.version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
|
||||||
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 2rem; }
|
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 2rem; }
|
||||||
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; }
|
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; }
|
||||||
.version-modal-card form { display: grid; gap: .65rem; }
|
.version-modal-card form { display: grid; gap: .65rem; }
|
||||||
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }
|
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }
|
||||||
@@ -304,5 +316,7 @@
|
|||||||
.version-diff-score { grid-template-columns: 1fr; }
|
.version-diff-score { grid-template-columns: 1fr; }
|
||||||
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
|
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
|
||||||
.version-constructor-controls { grid-template-columns: 1fr; }
|
.version-constructor-controls { grid-template-columns: 1fr; }
|
||||||
.version-constructor-controls label { grid-column: auto; }
|
.version-constructor-field-wide { grid-column: auto; }
|
||||||
|
.version-constructor-actions { grid-column: auto; flex-direction: column; }
|
||||||
|
.version-hero-period-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,15 @@
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.absence-create-card {
|
||||||
|
z-index: 20;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.absence-create-card:focus-within {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
.absence-create-card .card-header-row > div:first-child,
|
.absence-create-card .card-header-row > div:first-child,
|
||||||
.absence-registry-head > div:first-child {
|
.absence-registry-head > div:first-child {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -178,6 +187,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.absence-registry-title {
|
.absence-registry-title {
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
@@ -211,21 +221,31 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 0 0.65rem;
|
padding: 0.25rem 0.65rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.4;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.69rem;
|
font-size: 0.69rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.absence-status.is-pending { color: #f59e0b; }
|
.absence-status.is-pending { color: #f59e0b; }
|
||||||
.absence-status.is-approved { color: #fb7185; }
|
.absence-status.is-approved { color: #34d399; }
|
||||||
.absence-status.is-resolved { color: #34d399; }
|
.absence-status.is-resolved { color: #34d399; }
|
||||||
.absence-status.is-rejected,
|
.absence-status.is-rejected,
|
||||||
.absence-status.is-cancelled { color: var(--text-secondary); }
|
.absence-status.is-cancelled { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
[data-theme="light"] .absence-status.is-pending { color: #92400e; }
|
||||||
|
[data-theme="light"] .absence-status.is-approved,
|
||||||
|
[data-theme="light"] .absence-status.is-resolved { color: #047857; }
|
||||||
|
.absence-status.is-rejected { color: #fb7185; }
|
||||||
|
[data-theme="light"] .absence-status.is-rejected { color: #be123c; }
|
||||||
|
|
||||||
.absence-registry-buttons {
|
.absence-registry-buttons {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -472,7 +492,7 @@ body.absence-wizard-open {
|
|||||||
|
|
||||||
.teacher-dialogue-grid {
|
.teacher-dialogue-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,13 +546,64 @@ body.absence-wizard-open {
|
|||||||
|
|
||||||
.teacher-dialogue-toolbar {
|
.teacher-dialogue-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
gap: .55rem;
|
gap: .55rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teacher-dialogue-toolbar select {
|
.teacher-dialogue-toolbar > select,
|
||||||
|
.teacher-dialogue-toolbar .custom-select-wrapper {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar > select {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.65rem 2.15rem 0.65rem 0.8rem;
|
||||||
|
border: 1px solid color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar .custom-select-trigger {
|
||||||
|
min-height: 44px;
|
||||||
|
border-color: color-mix(in srgb, #f59e0b 24%, var(--bg-card-border));
|
||||||
|
border-radius: 11px;
|
||||||
|
background: color-mix(in srgb, var(--bg-input) 88%, #f59e0b 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar .custom-select-trigger:hover,
|
||||||
|
.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger {
|
||||||
|
border-color: color-mix(in srgb, #f59e0b 62%, var(--bg-card-border));
|
||||||
|
background: color-mix(in srgb, var(--bg-hover) 90%, #f59e0b 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger,
|
||||||
|
.teacher-dialogue-toolbar .custom-select-trigger:focus {
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, #f59e0b 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar .custom-select-menu {
|
||||||
|
border-color: color-mix(in srgb, #f59e0b 25%, var(--bg-card-border));
|
||||||
|
background: rgba(10, 10, 15, 0.96);
|
||||||
|
backdrop-filter: blur(20px) saturate(135%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(135%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar .custom-select-item:hover:not(.disabled) {
|
||||||
|
background: color-mix(in srgb, #f59e0b 10%, var(--bg-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar .custom-select-item.selected {
|
||||||
|
background: color-mix(in srgb, #f59e0b 18%, var(--bg-input));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .teacher-dialogue-toolbar .custom-select-menu {
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.15), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teacher-dialogue-list {
|
.teacher-dialogue-list {
|
||||||
@@ -544,7 +615,7 @@ body.absence-wizard-open {
|
|||||||
|
|
||||||
.teacher-dialogue-item {
|
.teacher-dialogue-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 7px minmax(0, 1fr) minmax(180px, .42fr);
|
grid-template-columns: 7px minmax(0, 1fr);
|
||||||
gap: .85rem;
|
gap: .85rem;
|
||||||
padding: .85rem;
|
padding: .85rem;
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
@@ -553,6 +624,7 @@ body.absence-wizard-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.teacher-dialogue-mark {
|
.teacher-dialogue-mark {
|
||||||
|
grid-row: 1 / 3;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background: #94a3b8;
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
@@ -571,6 +643,11 @@ body.absence-wizard-open {
|
|||||||
font-size: .68rem;
|
font-size: .68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-copy {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.teacher-dialogue-copy h3 {
|
.teacher-dialogue-copy h3 {
|
||||||
margin: .22rem 0;
|
margin: .22rem 0;
|
||||||
font-size: .92rem;
|
font-size: .92rem;
|
||||||
@@ -608,17 +685,32 @@ body.absence-wizard-open {
|
|||||||
.teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; }
|
.teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; }
|
||||||
|
|
||||||
.teacher-dialogue-action {
|
.teacher-dialogue-action {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: stretch;
|
||||||
gap: .55rem;
|
gap: .55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teacher-dialogue-action > div {
|
.teacher-dialogue-action > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: .35rem;
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-action > div > .btn {
|
||||||
|
flex: 1 1 120px;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding-block: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar > .btn {
|
||||||
|
align-self: center;
|
||||||
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teacher-dialogue-comment {
|
.teacher-dialogue-comment {
|
||||||
@@ -669,6 +761,16 @@ body.absence-wizard-open {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-toolbar .custom-select-wrapper,
|
||||||
|
.teacher-dialogue-toolbar > select,
|
||||||
|
.teacher-dialogue-toolbar > .btn {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.absence-command-stats {
|
.absence-command-stats {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
@@ -731,6 +833,6 @@ body.absence-wizard-open {
|
|||||||
|
|
||||||
.teacher-dialogue-action {
|
.teacher-dialogue-action {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,246 +15,13 @@
|
|||||||
<link rel="stylesheet" href="css/teacher-absences.css">
|
<link rel="stylesheet" href="css/teacher-absences.css">
|
||||||
<link rel="stylesheet" href="css/schedule-quality.css">
|
<link rel="stylesheet" href="css/schedule-quality.css">
|
||||||
<link rel="stylesheet" href="css/schedule-versions.css">
|
<link rel="stylesheet" href="css/schedule-versions.css">
|
||||||
|
<link rel="stylesheet" href="../ui-foundation.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Sidebar -->
|
<div id="admin-root"></div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<a href="#" class="nav-item" data-tab="teacher-requests">
|
<script type="module" src="/react/admin.js"></script>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
51
frontend/admin/js/academic-period.js
Normal file
51
frontend/admin/js/academic-period.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { preferredScheduleSemester } from './schedule-period.js';
|
||||||
|
|
||||||
|
export function academicYearsFromSemesters(semesters = []) {
|
||||||
|
const years = new Map();
|
||||||
|
semesters.forEach(semester => {
|
||||||
|
const id = String(semester?.academicYearId ?? '');
|
||||||
|
if (!id || years.has(id)) return;
|
||||||
|
years.set(id, {
|
||||||
|
id,
|
||||||
|
title: semester.academicYearTitle || `Учебный год ${id}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [...years.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function semestersForAcademicYear(semesters = [], academicYearId = '') {
|
||||||
|
return semesters.filter(semester =>
|
||||||
|
String(semester?.academicYearId ?? '') === String(academicYearId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAcademicPeriod(semesters = [], options = {}) {
|
||||||
|
if (!semesters.length) return { academicYearId: '', semesterId: '' };
|
||||||
|
|
||||||
|
const requestedSemester = semesters.find(semester =>
|
||||||
|
String(semester.id) === String(options.semesterId || '')
|
||||||
|
);
|
||||||
|
const availableYearIds = new Set(academicYearsFromSemesters(semesters).map(year => year.id));
|
||||||
|
const requestedYearId = availableYearIds.has(String(options.academicYearId || ''))
|
||||||
|
? String(options.academicYearId)
|
||||||
|
: '';
|
||||||
|
const preferred = preferredScheduleSemester(semesters, options.date || isoToday()) || semesters[0];
|
||||||
|
const academicYearId = requestedSemester
|
||||||
|
? String(requestedSemester.academicYearId)
|
||||||
|
: (requestedYearId || String(preferred?.academicYearId || semesters[0].academicYearId));
|
||||||
|
const yearSemesters = semestersForAcademicYear(semesters, academicYearId);
|
||||||
|
const semester = requestedSemester && String(requestedSemester.academicYearId) === academicYearId
|
||||||
|
? requestedSemester
|
||||||
|
: (preferredScheduleSemester(yearSemesters, options.date || isoToday()) || yearSemesters[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
academicYearId,
|
||||||
|
semesterId: String(semester?.id || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoToday() {
|
||||||
|
const date = new Date();
|
||||||
|
const offset = date.getTimezoneOffset() * 60_000;
|
||||||
|
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
@@ -135,7 +135,8 @@ export async function logout() {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: async (url) => {
|
get: async (url) => {
|
||||||
const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
const isCachable = !url.includes('/page') && !url.includes('/options')
|
||||||
|
&& CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
||||||
if (isCachable) {
|
if (isCachable) {
|
||||||
if (!cache.has(url)) {
|
if (!cache.has(url)) {
|
||||||
cache.set(url, await apiFetch(url, 'GET'));
|
cache.set(url, await apiFetch(url, 'GET'));
|
||||||
|
|||||||
103
frontend/admin/js/dialog.js
Normal file
103
frontend/admin/js/dialog.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
let activeDialog = null;
|
||||||
|
|
||||||
|
function ensureDialogRoot() {
|
||||||
|
let root = document.getElementById('project-dialog-root');
|
||||||
|
if (!root) {
|
||||||
|
root = document.createElement('div');
|
||||||
|
root.id = 'project-dialog-root';
|
||||||
|
document.body.appendChild(root);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showDialog({ title, message, confirmLabel = 'Подтвердить', cancelLabel = 'Отмена', danger = false, input = null }) {
|
||||||
|
activeDialog?.close(false);
|
||||||
|
const root = ensureDialogRoot();
|
||||||
|
const previousFocus = document.activeElement;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="project-dialog-backdrop">
|
||||||
|
<section class="project-dialog" role="dialog" aria-modal="true" aria-labelledby="project-dialog-title" aria-describedby="project-dialog-message">
|
||||||
|
<header>
|
||||||
|
<h2 id="project-dialog-title">${escapeText(title || 'Подтвердите действие')}</h2>
|
||||||
|
<button type="button" class="btn btn-icon-md btn-ghost" data-dialog-cancel aria-label="Закрыть">×</button>
|
||||||
|
</header>
|
||||||
|
<p id="project-dialog-message">${escapeText(message || '')}</p>
|
||||||
|
${input ? `<label class="project-dialog-field"><span>${escapeText(input.label || 'Комментарий')}</span><textarea data-dialog-input rows="4" maxlength="${Number(input.maxlength || 2000)}" placeholder="${escapeText(input.placeholder || '')}">${escapeText(input.value || '')}</textarea></label>` : ''}
|
||||||
|
<div class="project-dialog-error" data-dialog-error role="alert"></div>
|
||||||
|
<footer>
|
||||||
|
${cancelLabel ? `<button type="button" class="btn btn-md btn-secondary" data-dialog-cancel>${escapeText(cancelLabel)}</button>` : ''}
|
||||||
|
<button type="button" class="btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}" data-dialog-confirm>${escapeText(confirmLabel)}</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const backdrop = root.firstElementChild;
|
||||||
|
const dialog = backdrop.querySelector('.project-dialog');
|
||||||
|
const inputElement = dialog.querySelector('[data-dialog-input]');
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const close = result => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
root.innerHTML = '';
|
||||||
|
activeDialog = null;
|
||||||
|
previousFocus?.focus?.();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
activeDialog = { close };
|
||||||
|
const cancel = () => close(input ? null : false);
|
||||||
|
const confirm = () => {
|
||||||
|
if (input?.required && !inputElement?.value.trim()) {
|
||||||
|
dialog.querySelector('[data-dialog-error]').textContent = input.requiredMessage || 'Заполните поле';
|
||||||
|
inputElement?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close(input ? inputElement.value.trim() : true);
|
||||||
|
};
|
||||||
|
const handleKeydown = event => {
|
||||||
|
if (event.key === 'Escape') cancel();
|
||||||
|
if (event.key === 'Tab') trapFocus(event, dialog);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
dialog.querySelectorAll('[data-dialog-cancel]').forEach(button => button.addEventListener('click', cancel));
|
||||||
|
dialog.querySelector('[data-dialog-confirm]').addEventListener('click', confirm);
|
||||||
|
backdrop.addEventListener('click', event => {
|
||||||
|
if (event.target === backdrop) cancel();
|
||||||
|
});
|
||||||
|
window.setTimeout(() => (inputElement || dialog.querySelector('[data-dialog-confirm]')).focus(), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmAction(options) {
|
||||||
|
return showDialog(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptAction(options) {
|
||||||
|
return showDialog({ ...options, input: options.input || { label: 'Комментарий' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertAction(options) {
|
||||||
|
return showDialog({ ...options, cancelLabel: null, confirmLabel: options.confirmLabel || 'Понятно' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trapFocus(event, root) {
|
||||||
|
const focusable = [...root.querySelectorAll('button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')];
|
||||||
|
if (!focusable.length) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable.at(-1);
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
36
frontend/admin/js/dirty-state.js
Normal file
36
frontend/admin/js/dirty-state.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { confirmAction } from './dialog.js';
|
||||||
|
|
||||||
|
const scopes = new Map();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', event => {
|
||||||
|
if (![...scopes.values()].some(scope => scope.dirty)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDirtyScope(name, message = 'Есть несохранённые изменения. Покинуть страницу и потерять их?') {
|
||||||
|
const scope = { name, message, dirty: false };
|
||||||
|
scopes.set(name, scope);
|
||||||
|
return {
|
||||||
|
mark() { scope.dirty = true; document.dispatchEvent(new CustomEvent('magistr:dirty-change')); },
|
||||||
|
clear() { scope.dirty = false; document.dispatchEvent(new CustomEvent('magistr:dirty-change')); },
|
||||||
|
isDirty() { return scope.dirty; },
|
||||||
|
dispose() { scopes.delete(name); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmAbandonDirtyChanges() {
|
||||||
|
const dirty = [...scopes.values()].filter(scope => scope.dirty);
|
||||||
|
if (!dirty.length) return true;
|
||||||
|
const confirmed = await confirmAction({
|
||||||
|
title: 'Несохранённые изменения',
|
||||||
|
message: dirty[0].message,
|
||||||
|
confirmLabel: 'Покинуть без сохранения',
|
||||||
|
cancelLabel: 'Остаться',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (confirmed) dirty.forEach(scope => { scope.dirty = false; });
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
34
frontend/admin/js/url-state.js
Normal file
34
frontend/admin/js/url-state.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export function readUrlState(defaults = {}) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return Object.fromEntries(Object.entries(defaults).map(([key, fallback]) => [
|
||||||
|
key,
|
||||||
|
params.has(key) ? params.get(key) : fallback
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeUrlState(changes, { replace = true } = {}) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
Object.entries(changes).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === '' || value === false) {
|
||||||
|
url.searchParams.delete(key);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
url.searchParams.delete(key);
|
||||||
|
value.forEach(item => url.searchParams.append(key, String(item)));
|
||||||
|
} else {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.history[replace ? 'replaceState' : 'pushState']({}, '', `${url.pathname}${url.search}${url.hash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUrlValues(key) {
|
||||||
|
return new URLSearchParams(window.location.search).getAll(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce(callback, delay = 320) {
|
||||||
|
let timer = null;
|
||||||
|
return (...args) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => callback(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
24
frontend/admin/js/view-state.js
Normal file
24
frontend/admin/js/view-state.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { escapeHtml } from './utils.js';
|
||||||
|
|
||||||
|
export function renderTableLoading(tbody, columns, message = 'Загрузка данных...') {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row"><span class="state-spinner" aria-hidden="true"></span>${escapeHtml(message)}</td></tr>`;
|
||||||
|
tbody.setAttribute('aria-busy', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTableEmpty(tbody, columns, message = 'Данных пока нет') {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row view-state-empty">${escapeHtml(message)}</td></tr>`;
|
||||||
|
tbody.setAttribute('aria-busy', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTableError(tbody, columns, message, retry) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="${columns}" class="loading-row view-state-error">
|
||||||
|
<span>${escapeHtml(message || 'Не удалось загрузить данные')}</span>
|
||||||
|
${retry ? '<button type="button" class="btn btn-sm btn-secondary" data-view-retry>Повторить</button>' : ''}
|
||||||
|
</td></tr>`;
|
||||||
|
tbody.setAttribute('aria-busy', 'false');
|
||||||
|
tbody.querySelector('[data-view-retry]')?.addEventListener('click', retry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markTableReady(tbody) {
|
||||||
|
tbody.setAttribute('aria-busy', 'false');
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
import { api, getSession } from '../api.js';
|
|
||||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
|
||||||
import { defaultSchedulePeriod } from '../schedule-period.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 selectDefaultWorkloadPeriod(startInput, endInput);
|
|
||||||
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 selectDefaultWorkloadPeriod(startInput, endInput) {
|
|
||||||
try {
|
|
||||||
const semesters = await api.get('/api/schedule/semesters');
|
|
||||||
const period = defaultSchedulePeriod(
|
|
||||||
semesters,
|
|
||||||
formatLocalDate(new Date())
|
|
||||||
);
|
|
||||||
startInput.value = period.startDate;
|
|
||||||
endInput.value = period.endDate;
|
|
||||||
} catch (_error) {
|
|
||||||
// Текущий диапазон остаётся безопасным fallback, если справочник семестров недоступен.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDepartments(role, userDepartmentId) {
|
|
||||||
const departments = await api.get('/api/departments');
|
|
||||||
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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 }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('-');
|
|
||||||
}
|
|
||||||
@@ -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] || 'Версия';
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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} решений`;
|
|
||||||
}
|
|
||||||
@@ -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, 'Не удалось загрузить заявки преподавателей');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
/* ===== Корневой контейнер ===== */
|
||||||
|
|
||||||
|
/* body — flex-контейнер: без явного растяжения корневой div сжимается
|
||||||
|
до max-content, и узкие страницы не занимают всю ширину монитора.
|
||||||
|
Растягиваем корень ровно на ширину окна (flex-basis 0), а не под контент. */
|
||||||
|
#settings-root {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Sidebar ===== */
|
/* ===== Sidebar ===== */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -360,6 +360,46 @@ body {
|
|||||||
padding: 1.25rem !important;
|
padding: 1.25rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Карточка статуса текущего подключения к БД (замена inline-стилей legacy) */
|
||||||
|
.db-status-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-status-grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-status-grid > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-status-grid span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-status-grid code {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Широкое поле формы и выравнивание кнопок действий в строке формы */
|
||||||
|
.form-group-wide {
|
||||||
|
flex: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row-actions {
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.scope-badge {
|
.scope-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -8,98 +8,13 @@
|
|||||||
<link rel="stylesheet" href="../css/components.css">
|
<link rel="stylesheet" href="../css/components.css">
|
||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
|
<link rel="stylesheet" href="../../ui-foundation.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Боковая панель -->
|
<div id="settings-root"></div>
|
||||||
<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>Настройки</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="general">
|
|
||||||
<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>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="time-slots">
|
|
||||||
<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="9" />
|
|
||||||
<polyline points="12 7 12 12 16 14" />
|
|
||||||
<path d="M4 4l3 3" />
|
|
||||||
<path d="M20 4l-3 3" />
|
|
||||||
</svg>
|
|
||||||
<span>Временные слоты</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="edu-forms">
|
|
||||||
<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 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
|
||||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
|
||||||
<line x1="9" y1="7" x2="17" y2="7" />
|
|
||||||
<line x1="9" y1="11" x2="15" y2="11" />
|
|
||||||
</svg>
|
|
||||||
<span>Формы обучения</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="database">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
|
||||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
|
||||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
|
||||||
</svg>
|
|
||||||
<span>База данных</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<a href="/admin/" class="btn-back">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="19" y1="12" x2="5" y2="12" />
|
|
||||||
<polyline points="12 19 5 12 12 5" />
|
|
||||||
</svg>
|
|
||||||
<span>Назад в панель</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Затемнение боковой панели на мобильных -->
|
<script type="module" src="/react/settings.js"></script>
|
||||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
|
||||||
|
|
||||||
<!-- Основная область -->
|
|
||||||
<main class="main">
|
|
||||||
<header class="topbar">
|
|
||||||
<h1 id="page-title">Загрузка...</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="content" id="app-content">
|
|
||||||
<!-- Контент загружается через JS -->
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/theme-toggle.js"></script>
|
|
||||||
<script type="module" src="js/main.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
// Основной модуль страницы настроек
|
|
||||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
|
||||||
import { renderStatusMessage } from '../../js/utils.js';
|
|
||||||
import { SETTINGS_ROLES, capabilitiesForRole } from '../../js/role-capabilities.js';
|
|
||||||
|
|
||||||
import { initializeSession } from '../../js/api.js';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const session = await initializeSession();
|
|
||||||
if (!session || !SETTINGS_ROLES.includes(session.role)) {
|
|
||||||
window.location.replace('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const role = session.role;
|
|
||||||
|
|
||||||
// Глобальная инициализация кастомных списков
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
initAllCustomDropdowns(document.body);
|
|
||||||
startDropdownAutoObserver();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Настройка вкладок
|
|
||||||
const ROUTES = {
|
|
||||||
general: { title: 'Общие настройки', file: 'views/general.html' },
|
|
||||||
'time-slots': {
|
|
||||||
title: 'Временные слоты',
|
|
||||||
file: 'views/time-slots.html',
|
|
||||||
init: async () => {
|
|
||||||
const module = await import('./views/time-slots.js');
|
|
||||||
await module.initTimeSlotsSettings();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
title: 'База данных',
|
|
||||||
file: 'views/database.html',
|
|
||||||
init: async () => {
|
|
||||||
const module = await import('./views/database.js');
|
|
||||||
await module.initDatabase();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'edu-forms': {
|
|
||||||
title: 'Формы обучения',
|
|
||||||
file: 'views/edu-forms.html',
|
|
||||||
init: async () => {
|
|
||||||
const module = await import('./views/edu-forms.js');
|
|
||||||
await module.initEduForms();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleCapabilities = capabilitiesForRole(role);
|
|
||||||
const allowedTabs = new Set(roleCapabilities.settingsTabs);
|
|
||||||
const defaultTab = roleCapabilities.defaultSettingsTab;
|
|
||||||
|
|
||||||
let currentTab = null;
|
|
||||||
|
|
||||||
// DOM-элементы
|
|
||||||
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 main = document.querySelector('.main');
|
|
||||||
|
|
||||||
// Состояние боковой панели из localStorage
|
|
||||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
|
||||||
sidebar.classList.add('collapsed');
|
|
||||||
main.classList.add('sidebar-collapsed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сворачивание боковой панели
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Навигация
|
|
||||||
navItems.forEach(item => {
|
|
||||||
item.hidden = !allowedTabs.has(item.dataset.tab);
|
|
||||||
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;
|
|
||||||
|
|
||||||
navItems.forEach(n => n.classList.remove('active'));
|
|
||||||
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
|
|
||||||
pageTitle.textContent = ROUTES[tab].title;
|
|
||||||
|
|
||||||
try {
|
|
||||||
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
|
|
||||||
const response = await fetch(ROUTES[tab].file);
|
|
||||||
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
|
|
||||||
|
|
||||||
const html = await response.text();
|
|
||||||
appContent.innerHTML = html;
|
|
||||||
initAllCustomDropdowns(appContent);
|
|
||||||
if (ROUTES[tab].init) {
|
|
||||||
await ROUTES[tab].init();
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTab = tab;
|
|
||||||
} catch (e) {
|
|
||||||
renderStatusMessage(appContent, 'Не удалось загрузить раздел настроек');
|
|
||||||
console.error('Не удалось загрузить раздел настроек');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрываем мобильное меню после перехода
|
|
||||||
sidebar.classList.remove('open');
|
|
||||||
sidebarOverlay.classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вкладка по умолчанию
|
|
||||||
switchTab(defaultTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
await bootstrap();
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { api } from '../../../js/api.js';
|
|
||||||
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
|
|
||||||
|
|
||||||
export async function initDatabase() {
|
|
||||||
const tenantsTbody = document.getElementById('tenants-tbody');
|
|
||||||
const addTenantForm = document.getElementById('add-tenant-form');
|
|
||||||
const statusInfo = document.getElementById('db-status-info');
|
|
||||||
const btnTest = document.getElementById('btn-test-connection');
|
|
||||||
|
|
||||||
// === Загрузка статуса текущего подключения ===
|
|
||||||
async function loadStatus() {
|
|
||||||
try {
|
|
||||||
const data = await api.get('/api/database/status');
|
|
||||||
const statusBadge = data.connected
|
|
||||||
? '<span class="badge badge-available">Доступно</span>'
|
|
||||||
: '<span class="badge badge-unavailable">Недоступно</span>';
|
|
||||||
|
|
||||||
statusInfo.innerHTML = `
|
|
||||||
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
|
|
||||||
<div>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">Тенант:</span>
|
|
||||||
<strong>${escapeHtml(data.tenant || '—')}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">Название:</span>
|
|
||||||
<strong>${escapeHtml(data.name || '—')}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">Статус:</span>
|
|
||||||
${statusBadge}
|
|
||||||
</div>
|
|
||||||
${data.url ? `<div>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">URL:</span>
|
|
||||||
<code style="font-size: 0.85rem;">${escapeHtml(data.url)}</code>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (e) {
|
|
||||||
renderStatusMessage(statusInfo, 'Не удалось загрузить статус подключения');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Загрузка списка тенантов ===
|
|
||||||
async function loadTenants() {
|
|
||||||
try {
|
|
||||||
const tenants = await api.get('/api/database/tenants');
|
|
||||||
renderTenantsTable(tenants);
|
|
||||||
} catch (e) {
|
|
||||||
renderTableMessage(tenantsTbody, 6, 'Не удалось загрузить список тенантов');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTenantsTable(tenants) {
|
|
||||||
if (!tenants || !tenants.length) {
|
|
||||||
tenantsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет подключённых тенантов</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantsTbody.innerHTML = tenants.map(t => {
|
|
||||||
const statusBadge = t.connected
|
|
||||||
? '<span class="badge badge-available">Доступно</span>'
|
|
||||||
: '<span class="badge badge-unavailable">Недоступно</span>';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(t.name || '—')}</td>
|
|
||||||
<td><code>${escapeHtml(t.domain)}</code></td>
|
|
||||||
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
|
|
||||||
<td>${escapeHtml(t.username || '—')}</td>
|
|
||||||
<td>${statusBadge}</td>
|
|
||||||
<td><button class="btn btn-sm btn-danger btn-delete-tenant" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Тест подключения ===
|
|
||||||
btnTest.addEventListener('click', async () => {
|
|
||||||
hideAlert('add-tenant-alert');
|
|
||||||
const url = document.getElementById('tenant-url').value.trim();
|
|
||||||
const username = document.getElementById('tenant-username').value.trim();
|
|
||||||
const password = document.getElementById('tenant-password').value;
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
showAlert('add-tenant-alert', 'Введите JDBC URL', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
btnTest.textContent = '...';
|
|
||||||
btnTest.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.post('/api/database/test', { url, username, password });
|
|
||||||
if (result.success) {
|
|
||||||
showAlert('add-tenant-alert', '✓ Подключение успешно!', 'success');
|
|
||||||
} else {
|
|
||||||
showAlert('add-tenant-alert', `✗ ${result.message}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('add-tenant-alert', 'Не удалось проверить подключение', 'error');
|
|
||||||
} finally {
|
|
||||||
btnTest.textContent = 'Тест';
|
|
||||||
btnTest.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Добавление тенанта ===
|
|
||||||
addTenantForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
hideAlert('add-tenant-alert');
|
|
||||||
|
|
||||||
const name = document.getElementById('tenant-name').value.trim();
|
|
||||||
const domain = document.getElementById('tenant-domain').value.trim().toLowerCase();
|
|
||||||
const url = document.getElementById('tenant-url').value.trim();
|
|
||||||
const username = document.getElementById('tenant-username').value.trim();
|
|
||||||
const password = document.getElementById('tenant-password').value;
|
|
||||||
|
|
||||||
if (!name || !domain || !url) {
|
|
||||||
showAlert('add-tenant-alert', 'Заполните все обязательные поля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
|
|
||||||
if (result.success) {
|
|
||||||
showAlert('add-tenant-alert', `Тенант «${domain}» добавлен`, 'success');
|
|
||||||
addTenantForm.reset();
|
|
||||||
loadTenants();
|
|
||||||
loadStatus();
|
|
||||||
} else {
|
|
||||||
showAlert('add-tenant-alert', result.message, 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('add-tenant-alert', 'Не удалось добавить тенант', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Удаление тенанта ===
|
|
||||||
tenantsTbody.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('.btn-delete-tenant');
|
|
||||||
if (!btn) return;
|
|
||||||
|
|
||||||
const domain = btn.dataset.domain;
|
|
||||||
if (!confirm(`Удалить тенант "${domain}"? Пул соединений будет закрыт.`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/database/tenants/${domain}`);
|
|
||||||
loadTenants();
|
|
||||||
loadStatus();
|
|
||||||
} catch (e) {
|
|
||||||
alert('Не удалось удалить тенант');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Init ===
|
|
||||||
loadStatus();
|
|
||||||
loadTenants();
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { api } from '../../../js/api.js';
|
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
|
|
||||||
|
|
||||||
export let allEducationForms = [];
|
|
||||||
|
|
||||||
export async function fetchEducationForms() {
|
|
||||||
try {
|
|
||||||
allEducationForms = await api.get('/api/education-forms');
|
|
||||||
return allEducationForms;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Не удалось загрузить формы обучения');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initEduForms() {
|
|
||||||
const efTbody = document.getElementById('ef-tbody');
|
|
||||||
const createEfForm = document.getElementById('create-ef-form');
|
|
||||||
|
|
||||||
async function loadEF() {
|
|
||||||
try {
|
|
||||||
const forms = await fetchEducationForms();
|
|
||||||
renderEfTable(forms);
|
|
||||||
} catch (e) {
|
|
||||||
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEfTable(forms) {
|
|
||||||
if (!forms || !forms.length) {
|
|
||||||
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
efTbody.innerHTML = forms.map(ef => `
|
|
||||||
<tr>
|
|
||||||
<td>${ef.id}</td>
|
|
||||||
<td>${escapeHtml(ef.name)}</td>
|
|
||||||
<td><button class="btn btn-sm btn-danger btn-delete-edu-form" data-id="${ef.id}">Удалить</button></td>
|
|
||||||
</tr>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
createEfForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
hideAlert('create-ef-alert');
|
|
||||||
const name = document.getElementById('new-ef-name').value.trim();
|
|
||||||
if (!name) { showAlert('create-ef-alert', 'Введите название', 'error'); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.post('/api/education-forms', { name });
|
|
||||||
showAlert('create-ef-alert', `Форма "${escapeHtml(data.name)}" создана`, 'success');
|
|
||||||
createEfForm.reset();
|
|
||||||
loadEF();
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('create-ef-alert', e.message || 'Ошибка создания', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
efTbody.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('.btn-delete-edu-form');
|
|
||||||
if (!btn) return;
|
|
||||||
if (!confirm('Удалить форму обучения?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/education-forms/' + btn.dataset.id);
|
|
||||||
loadEF();
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message || 'Ошибка удаления');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadEF();
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { api } from '../../../js/api.js';
|
|
||||||
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
|
|
||||||
|
|
||||||
const APPLY_MODE_LABELS = {
|
|
||||||
DEFAULT: 'применяется по умолчанию',
|
|
||||||
WEEKDAY: 'применяется автоматически',
|
|
||||||
MANUAL: 'применяется вручную'
|
|
||||||
};
|
|
||||||
|
|
||||||
const DAY_SHORT_LABELS = {
|
|
||||||
1: 'Пн',
|
|
||||||
2: 'Вт',
|
|
||||||
3: 'Ср',
|
|
||||||
4: 'Чт',
|
|
||||||
5: 'Пт',
|
|
||||||
6: 'Сб',
|
|
||||||
7: 'Вс'
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function initTimeSlotsSettings() {
|
|
||||||
const scopeSelect = document.getElementById('time-slot-scope-select');
|
|
||||||
const scopeSummary = document.getElementById('time-slot-scope-summary');
|
|
||||||
const scopeOpenCreateButton = document.getElementById('time-slot-scope-open-create');
|
|
||||||
const scopeModal = document.getElementById('time-slot-scope-modal');
|
|
||||||
const scopeModalCloseButton = document.getElementById('time-slot-scope-modal-close');
|
|
||||||
const scopeModalCancelButton = document.getElementById('time-slot-scope-modal-cancel');
|
|
||||||
const scopeForm = document.getElementById('time-slot-scope-form');
|
|
||||||
const scopeNameInput = document.getElementById('time-slot-scope-name');
|
|
||||||
const scopeDeleteButton = document.getElementById('time-slot-scope-delete');
|
|
||||||
|
|
||||||
const form = document.getElementById('time-slot-form');
|
|
||||||
const title = document.getElementById('time-slot-form-title');
|
|
||||||
const idInput = document.getElementById('time-slot-id');
|
|
||||||
const orderInput = document.getElementById('time-slot-order');
|
|
||||||
const startInput = document.getElementById('time-slot-start');
|
|
||||||
const endInput = document.getElementById('time-slot-end');
|
|
||||||
const durationInput = document.getElementById('time-slot-duration');
|
|
||||||
const resetButton = document.getElementById('time-slot-reset');
|
|
||||||
const refreshButton = document.getElementById('time-slots-refresh');
|
|
||||||
const tbody = document.getElementById('time-slots-tbody');
|
|
||||||
|
|
||||||
let scopes = [];
|
|
||||||
let slots = [];
|
|
||||||
let activeScopeId = null;
|
|
||||||
|
|
||||||
scopeSelect?.addEventListener('change', () => {
|
|
||||||
activeScopeId = Number(scopeSelect.value);
|
|
||||||
resetForm();
|
|
||||||
renderActiveScope();
|
|
||||||
});
|
|
||||||
scopeOpenCreateButton?.addEventListener('click', openScopeModal);
|
|
||||||
scopeModalCloseButton?.addEventListener('click', closeScopeModal);
|
|
||||||
scopeModalCancelButton?.addEventListener('click', closeScopeModal);
|
|
||||||
scopeModal?.addEventListener('click', (event) => {
|
|
||||||
if (event.target === scopeModal) closeScopeModal();
|
|
||||||
});
|
|
||||||
scopeModal?.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Escape') closeScopeModal();
|
|
||||||
});
|
|
||||||
scopeForm?.addEventListener('submit', createScope);
|
|
||||||
scopeDeleteButton?.addEventListener('click', deleteActiveScope);
|
|
||||||
form?.addEventListener('submit', saveSlot);
|
|
||||||
resetButton?.addEventListener('click', resetForm);
|
|
||||||
refreshButton?.addEventListener('click', loadAll);
|
|
||||||
startInput?.addEventListener('change', calculateDuration);
|
|
||||||
endInput?.addEventListener('change', calculateDuration);
|
|
||||||
tbody?.addEventListener('click', handleSlotTableClick);
|
|
||||||
|
|
||||||
await loadAll();
|
|
||||||
|
|
||||||
async function loadAll() {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
|
|
||||||
hideAlert('time-slot-alert');
|
|
||||||
try {
|
|
||||||
[scopes, slots] = await Promise.all([
|
|
||||||
api.get('/api/admin/time-slots/scopes'),
|
|
||||||
api.get('/api/admin/time-slots')
|
|
||||||
]);
|
|
||||||
if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) {
|
|
||||||
activeScopeId = defaultScope()?.id || scopes[0]?.id || null;
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
} catch (error) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
renderScopeSelects();
|
|
||||||
renderActiveScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActiveScope() {
|
|
||||||
updateScopeActions();
|
|
||||||
renderScopeSummary();
|
|
||||||
renderSlots();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderScopeSelects() {
|
|
||||||
scopeSelect.innerHTML = scopes.map(scope => `
|
|
||||||
<option value="${scope.id}">${escapeHtml(scopeOptionLabel(scope))}</option>
|
|
||||||
`).join('');
|
|
||||||
scopeSelect.value = activeScopeId ? String(activeScopeId) : '';
|
|
||||||
|
|
||||||
updateScopeActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateScopeActions() {
|
|
||||||
const activeScope = selectedScope();
|
|
||||||
scopeDeleteButton.disabled = !activeScope || activeScope.systemScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderScopeSummary() {
|
|
||||||
const scope = selectedScope();
|
|
||||||
if (!scope) {
|
|
||||||
scopeSummary.innerHTML = '<span class="muted">Сетки времени не настроены</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode;
|
|
||||||
const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : '';
|
|
||||||
const slotCount = slots.filter(slot => slot.scopeId === scope.id).length;
|
|
||||||
scopeSummary.innerHTML = `
|
|
||||||
<span class="scope-badge ${scope.applyMode === 'DEFAULT' ? 'scope-badge-base' : ''}">${escapeHtml(scopeBadgeLabel(scope))}</span>
|
|
||||||
<span>${escapeHtml(mode + day)}. Слотов: ${slotCount}.</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSlots() {
|
|
||||||
const scopeSlots = slots
|
|
||||||
.filter(slot => slot.scopeId === activeScopeId)
|
|
||||||
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
|
||||||
|
|
||||||
if (!scopeSlots.length) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">В выбранной сетке нет слотов</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = scopeSlots.map(slot => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(String(slot.orderNumber ?? '-'))}</td>
|
|
||||||
<td>${escapeHtml(trimTime(slot.startTime))}</td>
|
|
||||||
<td>${escapeHtml(trimTime(slot.endTime))}</td>
|
|
||||||
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
|
|
||||||
<td class="actions-cell">
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-danger btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openScopeModal() {
|
|
||||||
scopeForm.reset();
|
|
||||||
hideAlert('time-slot-scope-alert');
|
|
||||||
scopeModal.classList.add('open');
|
|
||||||
scopeModal.setAttribute('aria-hidden', 'false');
|
|
||||||
window.setTimeout(() => scopeNameInput?.focus(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeScopeModal() {
|
|
||||||
scopeModal.classList.remove('open');
|
|
||||||
scopeModal.setAttribute('aria-hidden', 'true');
|
|
||||||
hideAlert('time-slot-scope-alert');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createScope(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const name = scopeNameInput.value.trim();
|
|
||||||
if (!name) {
|
|
||||||
showAlert('time-slot-scope-alert', 'Введите название ручной сетки', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const saved = await api.post('/api/admin/time-slots/scopes', { name });
|
|
||||||
activeScopeId = saved.id;
|
|
||||||
closeScopeModal();
|
|
||||||
showAlert('time-slot-alert', 'Сетка времени создана', 'success');
|
|
||||||
await loadAll();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('time-slot-scope-alert', error.message || 'Ошибка создания сетки времени', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteActiveScope() {
|
|
||||||
const scope = selectedScope();
|
|
||||||
if (!scope || scope.systemScope) return;
|
|
||||||
if (!confirm(`Удалить сетку "${scope.name}" вместе с её слотами и ручными назначениями?`)) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
|
|
||||||
activeScopeId = defaultScope()?.id || null;
|
|
||||||
resetForm();
|
|
||||||
showAlert('time-slot-alert', 'Сетка времени удалена', 'success');
|
|
||||||
await loadAll();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('time-slot-alert', error.message || 'Ошибка удаления сетки времени', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSlot(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
hideAlert('time-slot-alert');
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
id: idInput.value ? Number(idInput.value) : null,
|
|
||||||
orderNumber: Number(orderInput.value),
|
|
||||||
scopeId: activeScopeId,
|
|
||||||
startTime: startInput.value,
|
|
||||||
endTime: endInput.value
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateSlotPayload(payload);
|
|
||||||
const id = idInput.value;
|
|
||||||
await (id
|
|
||||||
? api.put('/api/admin/time-slots/' + id, payload)
|
|
||||||
: api.post('/api/admin/time-slots', payload));
|
|
||||||
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
|
|
||||||
resetForm();
|
|
||||||
await loadAll();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSlotTableClick(event) {
|
|
||||||
const editButton = event.target.closest('.btn-edit-time-slot');
|
|
||||||
const deleteButton = event.target.closest('.btn-delete-time-slot');
|
|
||||||
|
|
||||||
if (editButton) {
|
|
||||||
const slot = slots.find(item => String(item.id) === String(editButton.dataset.id));
|
|
||||||
if (!slot) return;
|
|
||||||
activeScopeId = slot.scopeId;
|
|
||||||
title.textContent = 'Редактирование слота';
|
|
||||||
idInput.value = slot.id;
|
|
||||||
orderInput.value = slot.orderNumber || '';
|
|
||||||
startInput.value = trimTime(slot.startTime);
|
|
||||||
endInput.value = trimTime(slot.endTime);
|
|
||||||
durationInput.value = slot.durationMinutes || '';
|
|
||||||
hideAlert('time-slot-alert');
|
|
||||||
render();
|
|
||||||
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteButton) {
|
|
||||||
if (!confirm('Удалить временной слот?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
|
|
||||||
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
|
|
||||||
resetForm();
|
|
||||||
await loadAll();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateSlotPayload(payload) {
|
|
||||||
if (!payload.scopeId) {
|
|
||||||
throw new Error('Выберите сетку времени');
|
|
||||||
}
|
|
||||||
if (!payload.orderNumber || payload.orderNumber <= 0) {
|
|
||||||
throw new Error('Укажите номер пары');
|
|
||||||
}
|
|
||||||
if (!payload.startTime || !payload.endTime) {
|
|
||||||
throw new Error('Укажите время начала и окончания');
|
|
||||||
}
|
|
||||||
if (payload.startTime >= payload.endTime) {
|
|
||||||
throw new Error('Время начала должно быть раньше времени окончания');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateDuration() {
|
|
||||||
durationInput.value = '';
|
|
||||||
if (!startInput.value || !endInput.value || startInput.value >= endInput.value) return;
|
|
||||||
const [startHour, startMinute] = startInput.value.split(':').map(Number);
|
|
||||||
const [endHour, endMinute] = endInput.value.split(':').map(Number);
|
|
||||||
durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
form.reset();
|
|
||||||
idInput.value = '';
|
|
||||||
title.textContent = 'Новый слот';
|
|
||||||
hideAlert('time-slot-alert');
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedScope() {
|
|
||||||
return scopes.find(scope => scope.id === activeScopeId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultScope() {
|
|
||||||
return scopes.find(scope => scope.applyMode === 'DEFAULT') || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeOptionLabel(scope) {
|
|
||||||
const suffix = scope.applyMode === 'WEEKDAY'
|
|
||||||
? `, ${dayShort(scope.dayOfWeek)}`
|
|
||||||
: '';
|
|
||||||
return `${scope.name} - ${APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode}${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeBadgeLabel(scope) {
|
|
||||||
if (scope.applyMode === 'DEFAULT') return 'Базовая';
|
|
||||||
if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek);
|
|
||||||
return 'Ручная';
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayShort(dayOfWeek) {
|
|
||||||
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimTime(value) {
|
|
||||||
return value ? String(value).slice(0, 5) : '';
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user