Много разных изменений

This commit is contained in:
Zuev
2026-08-20 00:55:27 +03:00
parent 3599d850f6
commit a1c4ee4298
78 changed files with 4698 additions and 428 deletions

676
UX_IMPROVEMENT_PLAN.md Normal file
View File

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

55
UX_SITE_CHECKLIST.md Normal file
View File

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

View File

@@ -8,6 +8,8 @@ import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.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,32 @@ public class AcademicCalendarController {
.toList(); .toList();
} }
@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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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` | Отклонить заявку |
@@ -567,6 +597,7 @@ 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/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}` | Обновить график |
@@ -593,6 +624,11 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`. название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
Отдельного пользовательского названия у календарного графика нет. Отдельного пользовательского названия у календарного графика нет.
`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 сохраняет один период с кодом `Т`
на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после
@@ -1305,6 +1341,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
[ [
@@ -1408,7 +1459,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}` | Обновить подгруппу |
@@ -1486,6 +1537,12 @@ legacy-alias для старых клиентов и исторически со
Список аудиторий с привязанным оборудованием. Список аудиторий с привязанным оборудованием.
### `GET /api/classrooms/options`
Асинхронный поиск доступных неархивных аудиторий по названию и корпусу. Параметры:
`query`, `selectedId`, `limit` (`1..50`). Выбранная запись добавляется в результат, если
она не попала в первую страницу поиска.
**Ответ:** **Ответ:**
```json ```json
[ [
@@ -1539,6 +1596,12 @@ legacy-alias для старых клиентов и исторически со
Список всех дисциплин. Список всех дисциплин.
### `GET /api/subjects/page`
Пагинированный реестр дисциплин. Параметры: `query`, `departmentId`, `includeArchived`,
`page`, `size`, `sort`, `direction`; сортировка разрешена по `name`, `code`,
`departmentId`, `status`. Роль `DEPARTMENT` всегда ограничивается своей кафедрой на backend.
```json ```json
{ {
"name": "Физика", "name": "Физика",
@@ -1620,14 +1683,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": "Высшая математика"
} }

File diff suppressed because one or more lines are too long

View File

@@ -630,7 +630,7 @@
} }
.schedule-view-extra-grid { .schedule-view-extra-grid {
grid-template-columns: repeat(4, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
margin-top: 0.9rem; margin-top: 0.9rem;
} }

View File

@@ -689,6 +689,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 +755,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;
} }
@@ -3920,3 +3989,193 @@ tbody tr:hover {
white-space: nowrap; white-space: nowrap;
line-height: 1.2; line-height: 1.2;
} }
.registry-toolbar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
margin: 1rem 0;
}
.registry-toolbar .form-group {
min-width: min(260px, 100%);
margin: 0;
}
.registry-search {
width: min(420px, 100%);
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 1rem;
}
.pagination-actions,
.pagination-size {
display: flex;
align-items: center;
gap: 0.65rem;
}
.pagination-summary,
.pagination-page,
.pagination-size span {
color: var(--text-secondary);
font-size: 0.86rem;
}
.pagination-size select {
min-height: 36px;
padding: 0.25rem 1.75rem 0.25rem 0.55rem;
color: var(--text-primary);
background: var(--bg-secondary);
border: 1px solid var(--bg-card-border);
border-radius: 8px;
}
.state-spinner {
display: inline-block;
width: 15px;
height: 15px;
margin-right: 0.55rem;
vertical-align: -2px;
border: 2px solid color-mix(in srgb, var(--accent) 22%, transparent);
border-top-color: var(--accent);
border-radius: 50%;
animation: state-spin 0.75s linear infinite;
}
@keyframes state-spin { to { transform: rotate(360deg); } }
.view-state-error {
color: var(--danger, #dc2626);
}
.view-state-error .btn {
margin-left: 0.75rem;
}
.async-combobox {
position: relative;
width: 100%;
}
.async-combobox-control {
position: relative;
}
.async-combobox-control input {
width: 100%;
min-height: 42px;
padding-right: 2.5rem;
}
.async-combobox-clear {
position: absolute;
top: 50%;
right: 0.45rem;
width: 30px;
height: 30px;
transform: translateY(-50%);
color: var(--text-secondary);
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.async-combobox-clear:hover { background: var(--bg-hover); color: var(--text-primary); }
.async-combobox-menu {
position: absolute;
z-index: 1200;
top: calc(100% + 0.35rem);
left: 0;
right: 0;
max-height: 320px;
overflow: auto;
padding: 0.35rem;
border: 1px solid var(--bg-card-border);
border-radius: 12px;
background: rgba(10, 10, 15, 0.96);
backdrop-filter: blur(20px) saturate(135%);
-webkit-backdrop-filter: blur(20px) saturate(135%);
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
}
[data-theme="light"] .async-combobox-menu {
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16), 0 0 0 1px rgba(15, 23, 42, 0.04) inset;
}
.async-combobox-option {
display: grid;
width: 100%;
gap: 0.2rem;
padding: 0.7rem 0.75rem;
color: var(--text-primary);
text-align: left;
background: transparent;
border: 0;
border-radius: 9px;
cursor: pointer;
}
.async-combobox-option:hover,
.async-combobox-option:focus,
.async-combobox-option[aria-selected="true"] {
outline: none;
background: var(--bg-hover);
}
.async-combobox-option small,
.async-combobox-state {
color: var(--text-secondary);
}
.async-combobox-state {
padding: 0.8rem;
font-size: 0.9rem;
}
.async-combobox-state.is-error { color: var(--danger, #dc2626); }
.project-dialog-backdrop {
position: fixed;
z-index: 4000;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(15, 23, 42, 0.48);
backdrop-filter: blur(8px);
}
.project-dialog {
width: min(520px, 100%);
padding: 1.25rem;
border: 1px solid var(--bg-card-border);
border-radius: 18px;
color: var(--text-primary);
background: var(--bg-card);
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.32);
}
.project-dialog header,
.project-dialog footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.project-dialog header h2 { margin: 0; }
.project-dialog footer { justify-content: flex-end; margin-top: 1rem; }
.project-dialog-field { display: grid; gap: 0.4rem; margin-top: 1rem; }
.project-dialog-field textarea { width: 100%; resize: vertical; }
.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; }

View File

@@ -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;
@@ -130,9 +138,13 @@
} }
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; } .version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; } .version-constructor-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;
@@ -304,5 +316,7 @@
.version-diff-score { grid-template-columns: 1fr; } .version-diff-score { grid-template-columns: 1fr; }
.version-history-row { grid-template-columns: 1fr; gap: .25rem; } .version-history-row { grid-template-columns: 1fr; gap: .25rem; }
.version-constructor-controls { grid-template-columns: 1fr; } .version-constructor-controls { grid-template-columns: 1fr; }
.version-constructor-controls label { grid-column: auto; } .version-constructor-field-wide { grid-column: auto; }
.version-constructor-actions { grid-column: auto; flex-direction: column; }
.version-hero-period-grid { grid-template-columns: 1fr; }
} }

View File

@@ -89,6 +89,15 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.absence-create-card {
z-index: 20;
overflow: visible;
}
.absence-create-card:focus-within {
z-index: 30;
}
.absence-create-card .card-header-row > div:first-child, .absence-create-card .card-header-row > div:first-child,
.absence-registry-head > div:first-child { .absence-registry-head > div:first-child {
display: flex; display: flex;
@@ -526,13 +535,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 {
@@ -669,6 +729,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);
} }

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,9 @@ import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-in
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js'; import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js'; import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
import { confirmAction } from '../dialog.js';
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
import { trackUxEvent } from '../../../telemetry.js';
const DAY_OPTIONS = [ const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' }, { value: 1, label: 'Понедельник', shortLabel: 'Пн' },
@@ -219,6 +222,10 @@ export async function initAcademicCalendar() {
let isSyncingCalendarDayDialog = false; let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null; let calendarDayTooltip = null;
let calendarDayTooltipPositionAnimation = null; let calendarDayTooltipPositionAnimation = null;
let loadedGridCalendarId = '';
let loadedSubjectCalendarId = '';
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
bindEvents(); bindEvents();
@@ -248,7 +255,10 @@ export async function initAcademicCalendar() {
].forEach(bindDateInputMask); ].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => { calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab)); button.addEventListener('click', async () => {
if (!await confirmAbandonDirtyChanges()) return;
activateCalendarTab(button.dataset.calendarTab);
});
}); });
academicYearsRefreshButton?.addEventListener('click', loadYears); academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars); calendarsRefreshButton?.addEventListener('click', loadCalendars);
@@ -269,6 +279,7 @@ export async function initAcademicCalendar() {
if (event.target.matches('.calendar-day-activity')) { if (event.target.matches('.calendar-day-activity')) {
updateCalendarDayVisual(event.target.closest('.calendar-day')); updateCalendarDayVisual(event.target.closest('.calendar-day'));
renderCalendarTotals(); renderCalendarTotals();
markGridDirty();
} }
}); });
calendarGrid?.addEventListener('mousedown', (event) => { calendarGrid?.addEventListener('mousedown', (event) => {
@@ -320,6 +331,7 @@ export async function initAcademicCalendar() {
}); });
updateCalendarDayDialogSummaryForSelection(days); updateCalendarDayDialogSummaryForSelection(days);
renderCalendarTotals(); renderCalendarTotals();
markGridDirty();
}); });
calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog);
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
@@ -441,7 +453,13 @@ export async function initAcademicCalendar() {
} }
if (deleteYearButton) { if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; const confirmed = await confirmAction({
title: 'Удаление учебного года',
message: 'Удалить учебный год вместе с семестрами и календарными графиками?',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) return;
try { try {
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
showAlert('academic-year-alert', 'Учебный год удалён', 'success'); showAlert('academic-year-alert', 'Учебный год удалён', 'success');
@@ -653,7 +671,13 @@ export async function initAcademicCalendar() {
} }
if (deleteButton) { if (deleteButton) {
if (!confirm('Удалить календарный график?')) return; const confirmed = await confirmAction({
title: 'Удаление календарного графика',
message: 'Удалить календарный график? Связанные настройки больше не будут доступны.',
confirmLabel: 'Удалить',
danger: true
});
if (!confirmed) return;
try { try {
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
@@ -691,9 +715,16 @@ export async function initAcademicCalendar() {
async function loadCalendarSubjects() { async function loadCalendarSubjects() {
hideAlert('calendar-subject-alert'); hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar(); const calendar = selectedSubjectCalendar();
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
subjectCalendarSelect.value = loadedSubjectCalendarId;
refreshSelectVisuals(subjectCalendarSelect);
return;
}
populateSubjectSemesterSelect(); populateSubjectSemesterSelect();
calendarSubjectRows = []; calendarSubjectRows = [];
if (!calendar) { if (!calendar) {
loadedSubjectCalendarId = '';
clearSubjectDirty();
renderCalendarSubjects(); renderCalendarSubjects();
return; return;
} }
@@ -701,6 +732,8 @@ export async function initAcademicCalendar() {
try { try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
calendarSubjectRows = sortCalendarSubjectRows(rows || []); calendarSubjectRows = sortCalendarSubjectRows(rows || []);
loadedSubjectCalendarId = String(calendar.id);
clearSubjectDirty();
renderCalendarSubjects(); renderCalendarSubjects();
} catch (error) { } catch (error) {
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`; subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
@@ -741,6 +774,7 @@ export async function initAcademicCalendar() {
} }
]); ]);
renderCalendarSubjects(); renderCalendarSubjects();
markSubjectDirty();
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success'); showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
} }
@@ -759,6 +793,8 @@ export async function initAcademicCalendar() {
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload); const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
calendarSubjectRows = sortCalendarSubjectRows(saved || []); calendarSubjectRows = sortCalendarSubjectRows(saved || []);
renderCalendarSubjects(); renderCalendarSubjects();
clearSubjectDirty();
trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length });
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success'); showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
} catch (error) { } catch (error) {
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error'); showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
@@ -774,6 +810,7 @@ export async function initAcademicCalendar() {
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
); );
renderCalendarSubjects(); renderCalendarSubjects();
markSubjectDirty();
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success'); showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
} }
@@ -820,6 +857,11 @@ export async function initAcademicCalendar() {
async function loadCalendarGrid() { async function loadCalendarGrid() {
hideAlert('calendar-editor-alert'); hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
editorCalendarSelect.value = loadedGridCalendarId;
refreshSelectVisuals(editorCalendarSelect);
return;
}
if (!calendar) { if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return; return;
@@ -829,6 +871,9 @@ export async function initAcademicCalendar() {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
const gridRows = rows.length ? rows : buildDefaultGrid(calendar); const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(gridRows); renderCalendarGrid(gridRows);
loadedGridCalendarId = String(calendar.id);
clearGridDirty();
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) { } catch (error) {
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
@@ -856,6 +901,8 @@ export async function initAcademicCalendar() {
} }
try { try {
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
clearGridDirty();
trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length });
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
await loadCalendarGrid(); await loadCalendarGrid();
} catch (error) { } catch (error) {
@@ -895,9 +942,30 @@ export async function initAcademicCalendar() {
} }
}); });
renderCalendarTotals(); renderCalendarTotals();
if (changed) markGridDirty();
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
} }
function markGridDirty() {
gridDirtyScope.mark();
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку •';
}
function clearGridDirty() {
gridDirtyScope.clear();
if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку';
}
function markSubjectDirty() {
subjectDirtyScope.mark();
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки •';
}
function clearSubjectDirty() {
subjectDirtyScope.clear();
if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки';
}
function renderCalendarGrid(rows) { function renderCalendarGrid(rows) {
activeCalendarDay = null; activeCalendarDay = null;
selectedCalendarDayKey = null; selectedCalendarDayKey = null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,15 @@
import { api } from '../api.js'; import { api } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
import { trackUxEvent } from '../../../telemetry.js';
import { AsyncCombobox } from '../async-combobox.js';
import { debounce } from '../url-state.js';
const DAY_OPTIONS = [ const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник' }, { value: 1, label: 'Понедельник' },
@@ -61,6 +71,10 @@ export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить'; return slotCount === 1 ? 'Очистить' : 'Удалить';
} }
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
return !programmatic && !(type === 'input' && fromAsyncCombobox);
}
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') { export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status)); const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
return available.find(version => String(version.id) === String(requestedVersionId)) return available.find(version => String(version.id) === String(requestedVersionId))
@@ -100,11 +114,16 @@ const LESSON_TYPE_LIMITS = [
]; ];
export async function initSchedule() { export async function initSchedule() {
const dirtyScope = createDirtyScope('schedule-constructor', 'В конструкторе есть несохранённые изменения правила. Покинуть раздел?');
const initialState = document.getElementById('schedule-initial-state');
const initialContent = document.getElementById('schedule-initial-content');
const ruleForm = document.getElementById('schedule-rule-form'); const ruleForm = document.getElementById('schedule-rule-form');
const ruleFormTitle = document.getElementById('schedule-rule-form-title'); const ruleFormTitle = document.getElementById('schedule-rule-form-title');
const ruleIdInput = document.getElementById('schedule-rule-id'); const ruleIdInput = document.getElementById('schedule-rule-id');
const ruleSubjectSelect = document.getElementById('schedule-rule-subject'); const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester'); const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
const scheduleVersionYearSelect = document.getElementById('schedule-version-year');
const scheduleVersionSemesterSelect = document.getElementById('schedule-version-semester');
const scheduleVersionSelect = document.getElementById('schedule-version-select'); const scheduleVersionSelect = document.getElementById('schedule-version-select');
const scheduleVersionContext = document.getElementById('schedule-version-context'); const scheduleVersionContext = document.getElementById('schedule-version-context');
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow'); const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
@@ -128,6 +147,7 @@ export async function initSchedule() {
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu'); const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
const ruleGroupsText = document.getElementById('schedule-rule-groups-text'); const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes'); const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
const ruleGroupSearch = document.getElementById('schedule-rule-group-search');
const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
const ruleResetButton = document.getElementById('schedule-rule-reset'); const ruleResetButton = document.getElementById('schedule-rule-reset');
const slotAddButton = document.getElementById('schedule-slot-add'); const slotAddButton = document.getElementById('schedule-slot-add');
@@ -170,8 +190,6 @@ export async function initSchedule() {
let rules = []; let rules = [];
let subjects = []; let subjects = [];
let groups = []; let groups = [];
let teachers = [];
let classrooms = [];
let lessonTypes = []; let lessonTypes = [];
let subgroups = []; let subgroups = [];
let timeSlots = []; let timeSlots = [];
@@ -190,6 +208,14 @@ export async function initSchedule() {
let visualContextMenuPositionAnimation = null; let visualContextMenuPositionAnimation = null;
let pendingSlotMove = null; let pendingSlotMove = null;
let creatingDraft = false; let creatingDraft = false;
let lastVersionYearValue = '';
let lastVersionSemesterValue = '';
let lastVersionValue = '';
let slotComboboxes = [];
let conflictComboboxes = [];
let groupSearchRequest = 0;
let subgroupRequest = 0;
let syncingRuleForm = false;
bindEvents(); bindEvents();
@@ -201,13 +227,26 @@ export async function initSchedule() {
syncRuleSemesterWithVersion(); syncRuleSemesterWithVersion();
setRuleEditorEnabled(Boolean(selectedScheduleVersion())); setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
await loadRules(); await loadRules();
dirtyScope.clear();
} catch (error) { } catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
} finally {
initialContent?.classList.remove('is-loading');
initialContent?.setAttribute('aria-busy', 'false');
if (initialState) initialState.hidden = true;
} }
function bindEvents() { function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules); refreshRulesButton?.addEventListener('click', loadRules);
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
scheduleVersionSelect?.addEventListener('change', async () => { scheduleVersionSelect?.addEventListener('change', async () => {
const requestedValue = scheduleVersionSelect.value;
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
scheduleVersionSelect.value = lastVersionValue;
return;
}
lastVersionValue = requestedValue;
if (scheduleVersionSelect.value) { if (scheduleVersionSelect.value) {
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value); localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
} }
@@ -227,7 +266,10 @@ export async function initSchedule() {
}); });
draftForm?.addEventListener('submit', createDraftFromSelectedVersion); draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
ruleResetButton?.addEventListener('click', resetRuleForm); ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); slotAddButton?.addEventListener('click', () => {
renderRuleSlots([...readRuleSlots(false), {}]);
dirtyScope.mark();
});
visualToggleButton?.addEventListener('click', toggleVisualDrawer); visualToggleButton?.addEventListener('click', toggleVisualDrawer);
visualCloseButton?.addEventListener('click', closeVisualDrawer); visualCloseButton?.addEventListener('click', closeVisualDrawer);
visualBackdrop?.addEventListener('click', closeVisualDrawer); visualBackdrop?.addEventListener('click', closeVisualDrawer);
@@ -305,6 +347,7 @@ export async function initSchedule() {
const slots = readRuleSlots(false); const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1); slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]); renderRuleSlots(slots.length ? slots : [{}]);
dirtyScope.mark();
return; return;
} }
@@ -321,9 +364,11 @@ export async function initSchedule() {
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu); ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown); ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation()); ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
ruleGroupCheckboxes.addEventListener('change', () => { ruleGroupSearch?.addEventListener('input', debounce(() => searchRuleGroups(ruleGroupSearch.value), 320));
ruleGroupCheckboxes.addEventListener('change', async () => {
const slots = readRuleSlots(false);
updateRuleGroupsText(); updateRuleGroupsText();
renderRuleSlots(readRuleSlots(false)); if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots);
}); });
document.addEventListener('click', () => { document.addEventListener('click', () => {
closeRuleGroupsMenu(); closeRuleGroupsMenu();
@@ -340,25 +385,39 @@ export async function initSchedule() {
updatePreferenceHints(); updatePreferenceHints();
}); });
ruleForm.addEventListener('submit', saveRule); ruleForm.addEventListener('submit', saveRule);
ruleForm.addEventListener('input', event => {
if (shouldMarkRuleFormDirty({
programmatic: syncingRuleForm,
type: event.type,
fromAsyncCombobox: Boolean(event.target.closest('.async-combobox'))
})) dirtyScope.mark();
});
ruleForm.addEventListener('change', event => {
if (shouldMarkRuleFormDirty({ programmatic: syncingRuleForm, type: event.type })) dirtyScope.mark();
});
rulesTbody.addEventListener('click', handleRuleTableClick); rulesTbody.addEventListener('click', handleRuleTableClick);
} }
async function loadBaseLists() { async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([ [subjects, lessonTypes] = await Promise.all([
api.get('/api/subjects'), api.get('/api/subjects'),
api.get('/api/groups'), api.get('/api/lesson-types')
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/lesson-types'),
api.get('/api/subgroups')
]); ]);
subgroups = [];
populateSubjectSelects(); populateSubjectSelects();
await searchRuleGroups('');
} }
async function loadYears() { async function loadYears() {
academicYears = await api.get('/api/admin/calendar/years'); academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []); semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects(); populateSemesterSelects();
const selected = resolveAcademicPeriod(semesters, {
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
date: localIsoDate(new Date())
});
renderVersionPeriodSelects(selected.academicYearId, selected.semesterId);
selectDefaultVisualPeriod(); selectDefaultVisualPeriod();
} }
@@ -368,15 +427,27 @@ export async function initSchedule() {
} }
async function loadScheduleVersions(preferredVersionId = null) { async function loadScheduleVersions(preferredVersionId = null) {
const responses = await Promise.all(semesters.map(semester => const semesterId = scheduleVersionSemesterSelect?.value || defaultScheduleSemesterId();
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`) if (!semesterId) {
)); scheduleVersions = [];
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status)); scheduleVersionSelect.innerHTML = '<option value="">Сначала выберите семестр</option>';
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || ''; updateVersionContext();
setRuleEditorEnabled(false);
return;
}
localStorage.setItem('magistr.schedule.yearId', scheduleVersionYearSelect?.value || '');
localStorage.setItem('magistr.schedule.semesterId', semesterId);
scheduleVersions = (await api.get(
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
)).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
const requestedVersionId = preferredVersionId
|| localStorage.getItem('magistr.schedule.openVersionId')
|| localStorage.getItem('magistr.schedule.versionId')
|| '';
localStorage.removeItem('magistr.schedule.openVersionId'); localStorage.removeItem('magistr.schedule.openVersionId');
const selected = chooseScheduleVersion( const selected = chooseScheduleVersion(
scheduleVersions, scheduleVersions,
ruleSemesterSelect.value || defaultScheduleSemesterId(), semesterId,
requestedVersionId requestedVersionId
); );
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED'); const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
@@ -388,6 +459,7 @@ export async function initSchedule() {
].filter(Boolean).join('') ].filter(Boolean).join('')
: '<option value="">Расписания не созданы</option>'; : '<option value="">Расписания не созданы</option>';
scheduleVersionSelect.value = selected ? String(selected.id) : ''; scheduleVersionSelect.value = selected ? String(selected.id) : '';
lastVersionValue = scheduleVersionSelect.value;
if (selected) { if (selected) {
localStorage.setItem('magistr.schedule.versionId', String(selected.id)); localStorage.setItem('magistr.schedule.versionId', String(selected.id));
} }
@@ -395,6 +467,64 @@ export async function initSchedule() {
setRuleEditorEnabled(Boolean(selected)); setRuleEditorEnabled(Boolean(selected));
} }
async function handleVersionYearChange() {
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
return;
}
const yearId = scheduleVersionYearSelect?.value || '';
const yearSemesters = semestersForAcademicYear(semesters, yearId);
const selected = resolveAcademicPeriod(yearSemesters, { date: localIsoDate(new Date()) });
renderVersionPeriodSelects(yearId, selected.semesterId);
await applyVersionPeriodChange();
}
async function handleVersionSemesterChange() {
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
return;
}
await applyVersionPeriodChange();
}
async function applyVersionPeriodChange() {
lastVersionYearValue = scheduleVersionYearSelect?.value || '';
lastVersionSemesterValue = scheduleVersionSemesterSelect?.value || '';
localStorage.setItem('magistr.schedule.yearId', lastVersionYearValue);
localStorage.setItem('magistr.schedule.semesterId', lastVersionSemesterValue);
try {
await loadScheduleVersions();
resetRuleForm();
syncRuleSemesterWithVersion();
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Не удалось сменить учебный период', 'error');
}
}
function renderVersionPeriodSelects(academicYearId, semesterId) {
if (!scheduleVersionYearSelect || !scheduleVersionSemesterSelect) return;
const years = academicYearsFromSemesters(semesters);
const selectedYearId = String(academicYearId || years[0]?.id || '');
scheduleVersionYearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
scheduleVersionYearSelect.value = selectedYearId;
const yearSemesters = semestersForAcademicYear(semesters, selectedYearId);
scheduleVersionSemesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(shortSemesterLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| yearSemesters[0];
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
lastVersionYearValue = scheduleVersionYearSelect.value;
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
syncSelects(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
}
function versionOptionGroup(label, versions) { function versionOptionGroup(label, versions) {
if (!versions.length) return ''; if (!versions.length) return '';
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => ` return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
@@ -405,12 +535,11 @@ export async function initSchedule() {
} }
function defaultScheduleSemesterId() { function defaultScheduleSemesterId() {
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId'); return resolveAcademicPeriod(semesters, {
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId)); academicYearId: localStorage.getItem('magistr.schedule.yearId'),
if (saved) return saved.id; semesterId: localStorage.getItem('magistr.schedule.semesterId'),
const today = localIsoDate(new Date()); date: localIsoDate(new Date())
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today); }).semesterId;
return current?.id || semesters[0]?.id || '';
} }
function updateVersionContext() { function updateVersionContext() {
@@ -972,7 +1101,12 @@ export async function initSchedule() {
async function deleteRuleFromVisual(rule) { async function deleteRuleFromVisual(rule) {
closeVisualContextMenu(); closeVisualContextMenu();
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return; if (!await confirmAction({
title: 'Удалить правило расписания?',
message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`,
confirmLabel: 'Удалить правило',
danger: true
})) return;
try { try {
await api.delete('/api/admin/schedule-rules/' + rule.id); await api.delete('/api/admin/schedule-rules/' + rule.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
@@ -1265,7 +1399,12 @@ export async function initSchedule() {
} }
if (deleteButton) { if (deleteButton) {
if (!confirm('Удалить правило расписания?')) return; if (!await confirmAction({
title: 'Удалить правило расписания?',
message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.',
confirmLabel: 'Удалить правило',
danger: true
})) return;
try { try {
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id); await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
@@ -1302,7 +1441,11 @@ export async function initSchedule() {
} }
async function finishSuccessfulRuleSave(saved) { async function finishSuccessfulRuleSave(saved) {
const mode = ruleIdInput.value ? 'update' : 'create';
const versionId = scheduleVersionSelect.value;
dirtyScope.clear();
resetRuleForm(); resetRuleForm();
trackUxEvent('правило_расписания_сохранено', { mode, versionId });
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success'); showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
await loadRules(); await loadRules();
} }
@@ -1427,6 +1570,8 @@ export async function initSchedule() {
} }
function renderConflictSlots(rule, conflictFields = []) { function renderConflictSlots(rule, conflictFields = []) {
conflictComboboxes.forEach(combobox => combobox.destroy());
conflictComboboxes = [];
const slots = rule.slots || []; const slots = rule.slots || [];
if (!conflictSlotsContainer) return; if (!conflictSlotsContainer) return;
if (!slots.length) { if (!slots.length) {
@@ -1450,11 +1595,12 @@ export async function initSchedule() {
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div> <div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div> <div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div> <div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div> <div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" aria-label="Преподаватель конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div> <div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" aria-label="Аудитория конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
</section> </section>
`; `;
}).join(''); }).join('');
initializeEntityComboboxes(conflictSlotsContainer, '.conflict-slot-teacher', '.conflict-slot-classroom', conflictComboboxes);
} }
function conflictFieldClass(field, fields) { function conflictFieldClass(field, fields) {
@@ -1578,7 +1724,7 @@ export async function initSchedule() {
function buildRulePayload() { function buildRulePayload() {
const groupIds = selectedRuleGroupIds(); const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true); const slots = readRuleSlots(true).map(ruleSlotToPayload);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания'); if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
@@ -1597,17 +1743,22 @@ export async function initSchedule() {
}; };
} }
function fillRuleForm(rule) { async function fillRuleForm(rule) {
ruleFormTitle.textContent = 'Редактирование правила расписания'; ruleFormTitle.textContent = 'Редактирование правила расписания';
ruleIdInput.value = rule.id; ruleIdInput.value = rule.id;
ruleSubjectSelect.value = rule.subjectId || ''; ruleSubjectSelect.value = rule.subjectId || '';
ruleSemesterSelect.value = rule.semesterId || ''; ruleSemesterSelect.value = rule.semesterId || '';
fillTypeLimits(rule); fillTypeLimits(rule);
syncSelects(ruleSubjectSelect, ruleSemesterSelect); syncSelects(ruleSubjectSelect, ruleSemesterSelect);
cacheRuleGroups(rule.groupIds || [], rule.groupNames || []);
renderRuleGroups(rule.groupIds || []); renderRuleGroups(rule.groupIds || []);
searchRuleGroups(ruleGroupSearch?.value || '');
ruleSlotsContainer.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Загрузка подгрупп выбранных групп...</div>';
if (!await loadSubgroupsForGroups(rule.groupIds || [])) return;
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]); renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
syncRuleSemesterWithVersion(); syncRuleSemesterWithVersion();
hideAlert('schedule-rule-alert'); hideAlert('schedule-rule-alert');
dirtyScope.clear();
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
@@ -1616,11 +1767,14 @@ export async function initSchedule() {
ruleFormTitle.textContent = 'Новое правило расписания'; ruleFormTitle.textContent = 'Новое правило расписания';
ruleIdInput.value = ''; ruleIdInput.value = '';
fillTypeLimits({}); fillTypeLimits({});
subgroupRequest += 1;
subgroups = [];
renderRuleGroups([]); renderRuleGroups([]);
renderRuleSlots([{}]); renderRuleSlots([{}]);
syncRuleSemesterWithVersion(); syncRuleSemesterWithVersion();
syncSelects(ruleSubjectSelect, ruleSemesterSelect); syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert'); hideAlert('schedule-rule-alert');
dirtyScope.clear();
} }
function renderRuleGroups(selectedIds) { function renderRuleGroups(selectedIds) {
@@ -1640,7 +1794,60 @@ export async function initSchedule() {
updateRuleGroupsText(); updateRuleGroupsText();
} }
async function searchRuleGroups(query = '') {
const request = ++groupSearchRequest;
const selectedBeforeSearch = selectedRuleGroupIds();
if (!groups.length) {
ruleGroupCheckboxes.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Поиск групп...</div>';
}
try {
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
const loaded = await api.get(`/api/groups/options?${params}`);
if (request !== groupSearchRequest) return;
const selectedIds = selectedRuleGroupIds().length ? selectedRuleGroupIds() : selectedBeforeSearch;
const selectedGroups = groups.filter(group => selectedIds.includes(String(group.id)));
groups = mergeEntitiesById([...selectedGroups, ...(loaded || [])]);
renderRuleGroups(selectedIds);
} catch (error) {
if (request !== groupSearchRequest) return;
ruleGroupCheckboxes.innerHTML = `<div class="loading-row view-state-error">${escapeHtml(error.message || 'Не удалось найти группы')}</div>`;
}
}
async function loadSubgroupsForGroups(groupIds) {
const request = ++subgroupRequest;
const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))];
if (!normalizedIds.length) {
subgroups = [];
return true;
}
try {
const params = new URLSearchParams();
normalizedIds.forEach(groupId => params.append('groupId', groupId));
const loaded = await api.get(`/api/subgroups?${params}`);
if (request !== subgroupRequest) return false;
subgroups = loaded || [];
return true;
} catch (error) {
if (request !== subgroupRequest) return false;
subgroups = [];
showAlert('schedule-rule-alert', error.message || 'Не удалось загрузить подгруппы выбранных групп', 'error');
return true;
}
}
function cacheRuleGroups(ids, names) {
const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` }));
groups = mergeEntitiesById([...groups, ...additions]);
}
function mergeEntitiesById(items) {
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
}
function renderRuleSlots(slots) { function renderRuleSlots(slots) {
slotComboboxes.forEach(combobox => combobox.destroy());
slotComboboxes = [];
const normalizedSlots = slots?.length ? slots : [{}]; const normalizedSlots = slots?.length ? slots : [{}];
const isSingleSlot = normalizedSlots.length === 1; const isSingleSlot = normalizedSlots.length === 1;
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length); const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
@@ -1650,8 +1857,8 @@ export async function initSchedule() {
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div> <div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div> <div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div> <div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div> <div class="form-group"><label>Преподаватель</label><select class="slot-teacher" aria-label="Преподаватель слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div> <div class="form-group"><label>Аудитория</label><select class="slot-classroom" aria-label="Аудитория слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div> <div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''} ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div> <div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
@@ -1659,6 +1866,7 @@ export async function initSchedule() {
<div class="schedule-preference-hint" data-preference-hint></div> <div class="schedule-preference-hint" data-preference-hint></div>
</div> </div>
`).join(''); `).join('');
initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes);
updatePreferenceHints(); updatePreferenceHints();
} }
@@ -1721,6 +1929,8 @@ export async function initSchedule() {
subgroupIds, subgroupIds,
teacherId: Number(row.querySelector('.slot-teacher').value), teacherId: Number(row.querySelector('.slot-teacher').value),
classroomId: Number(row.querySelector('.slot-classroom').value), classroomId: Number(row.querySelector('.slot-classroom').value),
teacherName: selectedOptionLabel(row.querySelector('.slot-teacher')),
classroomName: selectedOptionLabel(row.querySelector('.slot-classroom')),
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
lessonFormat: row.querySelector('.slot-format').value lessonFormat: row.querySelector('.slot-format').value
}; };
@@ -2022,13 +2232,49 @@ export async function initSchedule() {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
} }
function toTeacherOption(teacher) { function initializeEntityComboboxes(container, teacherSelector, classroomSelector, collector) {
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` }; container.querySelectorAll(teacherSelector).forEach(select => {
collector.push(new AsyncCombobox(select, {
allowEmpty: false,
placeholder: 'Найдите преподавателя',
emptyLabel: 'Преподаватели не найдены',
optionLabel: teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/users/teachers/options?${params}`);
}
}));
});
container.querySelectorAll(classroomSelector).forEach(select => {
collector.push(new AsyncCombobox(select, {
allowEmpty: false,
placeholder: 'Найдите аудиторию',
emptyLabel: 'Аудитории не найдены',
optionLabel: classroom => classroom.name || `Аудитория ${classroom.id}`,
optionDescription: classroom => [
classroom.building,
classroom.floor != null ? `${classroom.floor} этаж` : '',
classroom.capacity ? `${classroom.capacity} мест` : ''
].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/classrooms/options?${params}`);
}
}));
});
} }
function toClassroomOption(classroom) { function selectedEntityOption(id, label) {
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : ''; return id
return { value: classroom.id, label: `${classroom.name}${capacity}` }; ? `<option value="${escapeHtml(String(id))}" selected>${escapeHtml(label || `ID ${id}`)}</option>`
: '<option value="" selected></option>';
}
function selectedOptionLabel(select) {
return select?.selectedOptions?.[0]?.textContent?.trim() || '';
} }
function toLessonTypeOption(lessonType) { function toLessonTypeOption(lessonType) {
@@ -2098,9 +2344,16 @@ export async function initSchedule() {
} }
function syncSelects(...selects) { function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => { const targets = selects.filter(Boolean);
const previousSyncState = syncingRuleForm;
if (targets.some(select => ruleForm.contains(select))) syncingRuleForm = true;
try {
targets.forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));
}); });
} finally {
syncingRuleForm = previousSyncState;
}
} }
function visualCellKey(groupId, dayOfWeek, slotKey) { function visualCellKey(groupId, dayOfWeek, slotKey) {

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
export async function initUniversityStructure() { export async function initUniversityStructure() {
const deptTbody = document.getElementById('departments-tbody'); const deptTbody = document.getElementById('departments-tbody');
@@ -179,7 +180,7 @@ export async function initUniversityStructure() {
} }
if (deleteBtn) { if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return; if (!await confirmAction({ title: 'Удаление кафедры', message: 'Удалить кафедру?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete('/api/departments/' + deleteBtn.dataset.id); await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success'); showAlert('create-dept-alert', 'Кафедра удалена', 'success');
@@ -252,7 +253,7 @@ export async function initUniversityStructure() {
} }
if (deleteBtn) { if (deleteBtn) {
if (!confirm('Удалить специальность?')) return; if (!await confirmAction({ title: 'Удаление специальности', message: 'Удалить специальность?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id); await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success'); showAlert('create-spec-alert', 'Специальность удалена', 'success');
@@ -390,7 +391,7 @@ export async function initUniversityStructure() {
} }
if (deleteBtn) { if (deleteBtn) {
if (!confirm('Удалить профиль обучения?')) return; if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`); await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
showAlert('manage-profile-alert', 'Профиль удален', 'success'); showAlert('manage-profile-alert', 'Профиль удален', 'success');
@@ -517,7 +518,7 @@ export async function initUniversityStructure() {
if (deleteBtn) { if (deleteBtn) {
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id); const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
if (!profile) return; if (!profile) return;
if (!confirm('Удалить профиль обучения?')) return; if (!await confirmAction({ title: 'Удаление профиля', message: 'Удалить профиль обучения?', confirmLabel: 'Удалить', danger: true })) return;
try { try {
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`); await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
showAlert('tab-profile-alert', 'Профиль удален', 'success'); showAlert('tab-profile-alert', 'Профиль удален', 'success');

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,82 +5,6 @@
</div> </div>
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel"> <section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
<div class="card create-card academic-year-builder-card">
<div class="card-header-row">
<div>
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
</div>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
</div>
<form id="academic-year-form" novalidate>
<input type="hidden" id="academic-year-id">
<div class="academic-year-period-grid">
<div class="form-group">
<label for="academic-year-start">Год начала учебного года</label>
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
</div>
<div class="form-group">
<label for="academic-year-end">Год окончания учебного года</label>
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
</div>
</div>
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
<div class="academic-year-semester-builder-head">
<div>
<h3 id="academic-year-semesters-title">Семестры</h3>
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
</div>
<span class="academic-year-required-badge">Обязательно</span>
</div>
<div class="academic-year-semester-grid">
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
<input type="hidden" id="academic-year-autumn-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
<strong>Осенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-autumn-start">Начало</label>
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-autumn-end">Окончание</label>
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
<article class="academic-year-semester-card" data-academic-year-semester="spring">
<input type="hidden" id="academic-year-spring-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
<strong>Весенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-spring-start">Начало</label>
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-spring-end">Окончание</label>
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
</div>
</section>
<div class="academic-year-form-actions">
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
</div>
<div class="form-alert" id="academic-year-alert" role="alert"></div>
</form>
</div>
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Учебные годы</h2> <h2>Учебные годы</h2>
@@ -173,6 +97,82 @@
</div> </div>
</div> </div>
<div class="card create-card academic-year-builder-card">
<div class="card-header-row">
<div>
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
</div>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
</div>
<form id="academic-year-form" novalidate>
<input type="hidden" id="academic-year-id">
<div class="academic-year-period-grid">
<div class="form-group">
<label for="academic-year-start">Год начала учебного года</label>
<input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
</div>
<div class="form-group">
<label for="academic-year-end">Год окончания учебного года</label>
<input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
</div>
</div>
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
<div class="academic-year-semester-builder-head">
<div>
<h3 id="academic-year-semesters-title">Семестры</h3>
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
</div>
<span class="academic-year-required-badge">Обязательно</span>
</div>
<div class="academic-year-semester-grid">
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
<input type="hidden" id="academic-year-autumn-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
<strong>Осенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-autumn-start">Начало</label>
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-autumn-end">Окончание</label>
<input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
<article class="academic-year-semester-card" data-academic-year-semester="spring">
<input type="hidden" id="academic-year-spring-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
<strong>Весенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-spring-start">Начало</label>
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-spring-end">Окончание</label>
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
</div>
</section>
<div class="academic-year-form-actions">
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
</div>
<div class="form-alert" id="academic-year-alert" role="alert"></div>
</form>
</div>
</section> </section>
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden> <section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildScheduleOverviewModel } from '../schedule-overview.js';
test('обзор расписания выделяет занятия сегодня и ближайшую пару', () => {
const now = new Date(2026, 7, 19, 10, 15, 0);
const lessons = [
{ date: '2026-08-20', startTime: '09:00:00', endTime: '10:30:00', subjectName: 'Завтра' },
{ date: '2026-08-19', startTime: '08:30:00', endTime: '10:00:00', subjectName: 'Завершилась' },
{ date: '2026-08-19', startTime: '11:00:00', endTime: '12:30:00', subjectName: 'Ближайшая' }
];
const model = buildScheduleOverviewModel(lessons, now);
assert.equal(model.todayIso, '2026-08-19');
assert.deepEqual(model.todayLessons.map(item => item.subjectName), ['Завершилась', 'Ближайшая']);
assert.equal(model.nextLesson.subjectName, 'Ближайшая');
});
test('текущее занятие считается ближайшим до времени окончания', () => {
const now = new Date(2026, 7, 19, 10, 15, 0);
const model = buildScheduleOverviewModel([
{ date: '2026-08-19', startTime: '10:00', endTime: '11:30', subjectName: 'Идёт сейчас' },
{ date: '2026-08-19', startTime: '12:00', endTime: '13:30', subjectName: 'Следующее' }
], now);
assert.equal(model.nextLesson.subjectName, 'Идёт сейчас');
});

View File

@@ -32,14 +32,17 @@ test('конструктор по умолчанию открывает опуб
assert.equal(chooseScheduleVersion(versions, 10, 4).id, 2); assert.equal(chooseScheduleVersion(versions, 10, 4).id, 2);
}); });
test('контур версий подключён к SPA, а конструктор работает с выбранной версией и создаёт черновик из неё', async () => { test('контур версий подключён к SPA, а профильные вкладки имеют локальный выбор периода', async () => {
const [main, roles, constructor, quality, index, view] = await Promise.all([ const [main, roles, constructor, quality, index, view, qualityView, versionsView, scheduleView] = await Promise.all([
readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'), readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'), readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'), readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule-quality.js', import.meta.url), 'utf8'), readFile(new URL('../admin/js/views/schedule-quality.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/index.html', import.meta.url), 'utf8'), readFile(new URL('../admin/index.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule.html', import.meta.url), 'utf8') readFile(new URL('../admin/views/schedule.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule-quality.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule-versions.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule-view.html', import.meta.url), 'utf8')
]); ]);
assert.match(main, /'schedule-versions'/); assert.match(main, /'schedule-versions'/);
@@ -52,5 +55,12 @@ test('контур версий подключён к SPA, а конструкт
assert.match(constructor, /function localIsoDate\(date\)/); assert.match(constructor, /function localIsoDate\(date\)/);
assert.match(constructor, /data-slot-id/); assert.match(constructor, /data-slot-id/);
assert.match(view, /id="schedule-version-create-draft"/); assert.match(view, /id="schedule-version-create-draft"/);
assert.match(view, /id="schedule-version-year"/);
assert.match(view, /id="schedule-version-semester"/);
assert.match(qualityView, /id="quality-year"/);
assert.match(qualityView, /id="quality-version"/);
assert.match(versionsView, /id="version-year"/);
assert.match(scheduleView, /id="schedule-view-year"/);
assert.doesNotMatch(index, /id="workspace-context"/);
assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/); assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/);
}); });

View File

@@ -12,9 +12,15 @@ import {
defaultSchedulePeriod, defaultSchedulePeriod,
preferredScheduleSemester preferredScheduleSemester
} from '../admin/js/schedule-period.js'; } from '../admin/js/schedule-period.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../admin/js/academic-period.js';
const autumnSemester = { const autumnSemester = {
id: 11, id: 11,
academicYearId: 5,
academicYearTitle: '2025/2026', academicYearTitle: '2025/2026',
semesterType: 'autumn', semesterType: 'autumn',
startDate: '2025-09-01', startDate: '2025-09-01',
@@ -23,6 +29,7 @@ const autumnSemester = {
const springSemester = { const springSemester = {
id: 12, id: 12,
academicYearId: 5,
academicYearTitle: '2025/2026', academicYearTitle: '2025/2026',
semesterType: 'spring', semesterType: 'spring',
startDate: '2026-02-09', startDate: '2026-02-09',
@@ -77,6 +84,7 @@ test('кафедральное расписание не отбрасывает
test('между семестрами выбирается ближайший будущий период с занятиями', () => { test('между семестрами выбирается ближайший будущий период с занятиями', () => {
const nextAutumnSemester = { const nextAutumnSemester = {
id: 13, id: 13,
academicYearId: 6,
academicYearTitle: '2026/2027', academicYearTitle: '2026/2027',
semesterType: 'autumn', semesterType: 'autumn',
startDate: '2026-09-01', startDate: '2026-09-01',
@@ -91,3 +99,32 @@ test('между семестрами выбирается ближайший б
endDate: '2026-09-14' endDate: '2026-09-14'
}); });
}); });
test('локальные селекты сначала ограничивают семестры выбранным учебным годом', () => {
const nextAutumnSemester = {
id: 13,
academicYearId: 6,
academicYearTitle: '2026/2027',
semesterType: 'autumn',
startDate: '2026-09-01',
endDate: '2027-01-31'
};
const semesters = [nextAutumnSemester, springSemester, autumnSemester];
assert.deepEqual(academicYearsFromSemesters(semesters), [
{ id: '6', title: '2026/2027' },
{ id: '5', title: '2025/2026' }
]);
assert.deepEqual(
semestersForAcademicYear(semesters, 5).map(semester => semester.id),
[12, 11]
);
assert.deepEqual(resolveAcademicPeriod(semesters, {
academicYearId: 5,
semesterId: 12,
date: '2026-08-12'
}), {
academicYearId: '5',
semesterId: '12'
});
});

View File

@@ -4,7 +4,8 @@ import test from 'node:test';
import { import {
decisionOptions, decisionOptions,
parseDecisionOption parseDecisionOption,
teacherAbsenceTeacherOptionsUrl
} from '../admin/js/views/teacher-absences.js'; } from '../admin/js/views/teacher-absences.js';
test('значение мастера преобразуется в payload точечного изменения', () => { test('значение мастера преобразуется в payload точечного изменения', () => {
@@ -42,6 +43,33 @@ test('мастер показывает только реально получе
]); ]);
}); });
test('поиск отсутствующего преподавателя не ограничивает учебный отдел его служебной кафедрой', () => {
assert.equal(
teacherAbsenceTeacherOptionsUrl({
query: 'Иванов',
role: 'EDUCATION_OFFICE',
departmentId: 5
}),
'/api/users/teachers/options?query=%D0%98%D0%B2%D0%B0%D0%BD%D0%BE%D0%B2&limit=30'
);
assert.equal(
teacherAbsenceTeacherOptionsUrl({
query: 'Петров',
selectedId: 17,
role: 'DEPARTMENT',
departmentId: 5
}),
'/api/users/teachers/options?query=%D0%9F%D0%B5%D1%82%D1%80%D0%BE%D0%B2&limit=30&departmentId=5&selectedId=17'
);
});
test('список преподавателей раскрывается поверх следующей карточки', async () => {
const styles = await readFile(new URL('../admin/css/teacher-absences.css', import.meta.url), 'utf8');
assert.match(styles, /\.absence-create-card\s*\{[^}]*z-index:\s*20[^}]*overflow:\s*visible/s);
assert.match(styles, /\.absence-create-card:focus-within\s*\{[^}]*z-index:\s*30/s);
});
test('вкладка отсутствий доступна ответственным ролям и подключена к SPA', async () => { test('вкладка отсутствий доступна ответственным ролям и подключена к SPA', async () => {
const [capabilities, main, index] = await Promise.all([ const [capabilities, main, index] = await Promise.all([
readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'), readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'),

View File

@@ -0,0 +1,24 @@
---
type: "query"
date: "2026-08-19T00:14:06.203598+00:00"
question: "можешь добавить выбор года семестра версии на тех вкладках где этого не хватает"
contributor: "graphify"
outcome: "useful"
source_nodes: ["Semester", "AcademicYear", "ScheduleRule"]
---
# Q: можешь добавить выбор года семестра версии на тех вкладках где этого не хватает
## Answer
Expanded from original query via vocab: [schedule, semester, academic, year, calendar, frontend, admin, view]. Локальные каскады нужны во вкладках конструктора, управления версиями, анализа качества и просмотра расписания; экран просмотра по действующему контракту показывает только опубликованную версию.
## Outcome
- Signal: useful
## Source Nodes
- Semester
- AcademicYear
- ScheduleRule

View File

@@ -0,0 +1,24 @@
---
type: "query"
date: "2026-08-19T00:41:42.387015+00:00"
question: "при открытии вкладки просмотр расписаний, список групп сразу развёрнут, а должен быть свёрнут. в запросах преподавателей в блоке регистрации отсутствия во первых не ищется преподаватель, во вторых всплывающий список прячется за нижний блок, а должен быть на слое выше. при выходе со вкладки конструктора парвил всегда показывается что есть несохранённые изменения, даже когда их нет."
contributor: "graphify"
outcome: "dead_end"
source_nodes: ["dropdown.js", "Frontend-архитектура", "main.js"]
---
# Q: при открытии вкладки просмотр расписаний, список групп сразу развёрнут, а должен быть свёрнут. в запросах преподавателей в блоке регистрации отсутствия во первых не ищется преподаватель, во вторых всплывающий список прячется за нижний блок, а должен быть на слое выше. при выходе со вкладки конструктора парвил всегда показывается что есть несохранённые изменения, даже когда их нет.
## Answer
Expanded from original query via vocab: [schedule, view, group, select, dropdown, teacher, request, search, form, rule, frontend, admin]. BFS нашёл только общую архитектуру SPA и старый CustomSelect; актуальные AsyncCombobox и dirty-state в графе отсутствовали, поэтому причины подтверждены напрямую в frontend/admin/js/async-combobox.js, frontend/admin/js/views/schedule-view.js, frontend/admin/js/views/teacher-absences.js и frontend/admin/js/views/schedule.js.
## Outcome
- Signal: dead_end
## Source Nodes
- dropdown.js
- Frontend-архитектура
- main.js