diff --git a/UX_IMPROVEMENT_PLAN.md b/UX_IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..a550daf --- /dev/null +++ b/UX_IMPROVEMENT_PLAN.md @@ -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/` — кабинет студента. diff --git a/UX_SITE_CHECKLIST.md b/UX_SITE_CHECKLIST.md new file mode 100644 index 0000000..af856a5 --- /dev/null +++ b/UX_SITE_CHECKLIST.md @@ -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` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий. + +## Что пока не проверяем + +Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**. diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 728facc..8e7d9a8 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -8,6 +8,8 @@ import com.magistr.app.service.AcademicCalendarGridService; import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.ScheduleGeneratorService; import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; @@ -18,6 +20,8 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.ArrayList; +import java.util.Locale; import java.util.stream.Collectors; @RestController @@ -58,6 +62,32 @@ public class AcademicCalendarController { .toList(); } + @GetMapping("/options") + public List 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 options = new ArrayList<>(calendarRepository.searchOptions( + normalizedQuery, + academicYearId, + specialtyId, + profileId, + studyFormId, + PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("title").ascending()) + ).stream() + .map(this::toCalendarDto) + .toList()); + if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.id()))) { + calendarRepository.findByIdWithDetails(selectedId).map(this::toCalendarDto).ifPresent(options::add); + } + return options; + } + @GetMapping("/{id}") public ResponseEntity getCalendar(@PathVariable Long id) { return calendarRepository.findByIdWithDetails(id) diff --git a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java index 89c76eb..c37c283 100755 --- a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java +++ b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java @@ -10,11 +10,15 @@ import com.magistr.app.model.Role; import com.magistr.app.repository.ClassroomRepository; import com.magistr.app.repository.EquipmentRepository; import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.ArrayList; +import java.util.Locale; @RestController @RequestMapping("/api/classrooms") @@ -40,6 +44,26 @@ public class ClassroomController { .toList(); } + @GetMapping("/options") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List 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 options = new ArrayList<>(classroomRepository.searchActiveOptions( + normalizedQuery, + LifecycleEntity.STATUS_ARCHIVED, + PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("name").ascending()) + ).stream() + .map(this::mapToResponse) + .toList()); + if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) { + classroomRepository.findById(selectedId).map(this::mapToResponse).ifPresent(options::add); + } + return options; + } + @PostMapping public ResponseEntity createClassroom(@RequestBody ClassroomRequest request) { if (request.getName() == null || request.getName().isBlank()) { diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 85eb0f5..4461115 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -1,10 +1,13 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.AcademicCalendarSubjectDto; import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupResponse; +import com.magistr.app.dto.PageResponse; +import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.AcademicCalendarSubject; import com.magistr.app.model.Role; import com.magistr.app.model.Speciality; @@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.util.Collections; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -98,6 +108,118 @@ public class GroupController { } } + @GetMapping("/page") + public PageResponse getGroupsPage( + @RequestParam(defaultValue = "") String query, + @RequestParam(required = false) List 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 specification = groupSpecification( + query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived + ); + Page groups = groupRepository.findAll( + specification, + PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction)) + ); + return PageResponse.from(groups.map(this::mapToResponse)); + } + + @GetMapping("/options") + public List 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 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 groupSpecification(String query, + List educationFormIds, + Long departmentId, + Integer yearStartStudy, + boolean includeArchived) { + return (root, criteriaQuery, builder) -> { + List predicates = new ArrayList<>(); + if (!includeArchived) { + predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED)); + } + if (educationFormIds != null && !educationFormIds.isEmpty()) { + predicates.add(root.get("educationForm").get("id").in(educationFormIds)); + } + if (departmentId != null) { + predicates.add(builder.equal(root.get("departmentId"), departmentId)); + } + if (yearStartStudy != null) { + predicates.add(builder.equal(root.get("yearStartStudy"), yearStartStudy)); + } + String normalizedQuery = normalizeQuery(query); + if (!normalizedQuery.isEmpty()) { + String pattern = "%" + normalizedQuery + "%"; + predicates.add(builder.or( + builder.like(builder.lower(root.get("name")), pattern), + builder.like(builder.lower(root.get("speciality").get("specialityCode")), pattern), + builder.like(builder.lower(root.get("speciality").get("specialityName")), pattern), + builder.like(builder.lower(root.get("specialtyProfile").get("name")), pattern), + builder.like(builder.lower(root.get("educationForm").get("name")), pattern) + )); + } + return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new)); + }; + } + + private Sort groupSort(String field, String direction) { + String property = switch (field) { + case "yearStartStudy" -> "yearStartStudy"; + case "groupSize" -> "groupSize"; + case "status" -> "status"; + default -> "name"; + }; + return "desc".equalsIgnoreCase(direction) + ? Sort.by(property).descending() + : Sort.by(property).ascending(); + } + + private int normalizePage(int page) { + return Math.max(page, 0); + } + + private int normalizeSize(int size) { + return Math.max(1, Math.min(size, 100)); + } + + private String normalizeQuery(String query) { + return query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + } + @GetMapping("/{departmentId}") public ResponseEntity getGroupsByDepartmentId(@PathVariable Long departmentId) { logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java index 58abb1f..dfe63e6 100644 --- a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -12,10 +12,13 @@ import com.magistr.app.service.ScheduleGeneratorService; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Map; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + @RestController @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT}) public class SubgroupController { @@ -36,8 +39,16 @@ public class SubgroupController { } @GetMapping("/api/subgroups") - public List getAll() { - return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream() + public List getAll(@RequestParam(required = false) List groupId) { + if (groupId != null && groupId.size() > 100) { + throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп"); + } + List selected = groupId == null + ? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName() + : groupId.isEmpty() + ? List.of() + : subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList()); + return selected.stream() .map(this::toDto) .toList(); } diff --git a/backend/src/main/java/com/magistr/app/controller/SubjectController.java b/backend/src/main/java/com/magistr/app/controller/SubjectController.java index 019bb08..c76db33 100755 --- a/backend/src/main/java/com/magistr/app/controller/SubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java @@ -1,8 +1,10 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.SubjectResponse; +import com.magistr.app.dto.PageResponse; import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.Role; import com.magistr.app.model.Subject; @@ -11,9 +13,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; @RestController @@ -53,6 +60,74 @@ public class SubjectController { } } + @GetMapping("/page") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public PageResponse 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 subjectSpecification(String query, + Long departmentId, + boolean includeArchived) { + return (root, criteriaQuery, builder) -> { + List predicates = new ArrayList<>(); + if (!includeArchived) { + predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED)); + } + if (departmentId != null) { + predicates.add(builder.equal(root.get("departmentId"), departmentId)); + } + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + if (!normalizedQuery.isEmpty()) { + String pattern = "%" + normalizedQuery + "%"; + predicates.add(builder.or( + builder.like(builder.lower(root.get("name")), pattern), + builder.like(builder.lower(root.get("code")), pattern) + )); + } + return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new)); + }; + } + + private Sort subjectSort(String field, String direction) { + String property = switch (field) { + case "code" -> "code"; + case "departmentId" -> "departmentId"; + case "status" -> "status"; + default -> "name"; + }; + return "desc".equalsIgnoreCase(direction) + ? Sort.by(property).descending() + : Sort.by(property).ascending(); + } + + private SubjectResponse toResponse(Subject subject) { + return new SubjectResponse(subject.getId(), subject.getName(), subject.getCode(), subject.getDepartmentId()); + } + @GetMapping("/{departmentId}") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getSubjectsByDepartmentId(@PathVariable Long departmentId) { diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java index e462b66..d378024 100644 --- a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java +++ b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java @@ -5,6 +5,7 @@ import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.ReviewTeacherCreationRequest; import com.magistr.app.dto.TeacherCreationRequestResponse; import com.magistr.app.dto.UserResponse; +import com.magistr.app.dto.PageResponse; import com.magistr.app.model.*; import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.repository.TeacherCreationRequestRepository; @@ -12,11 +13,16 @@ import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; import com.magistr.app.service.BusinessTimeService; import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; @RestController @@ -55,6 +61,63 @@ public class TeacherCreationRequestController { .toList(); } + @GetMapping("/page") + public PageResponse 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 requestSpecification(String query, + TeacherCreationRequestStatus status, + Long departmentId) { + return (root, criteriaQuery, builder) -> { + List predicates = new ArrayList<>(); + if (status != null) { + predicates.add(builder.equal(root.get("status"), status)); + } + if (departmentId != null) { + predicates.add(builder.equal(root.get("department").get("id"), departmentId)); + } + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + if (!normalizedQuery.isEmpty()) { + String pattern = "%" + normalizedQuery + "%"; + predicates.add(builder.or( + builder.like(builder.lower(root.get("username")), pattern), + builder.like(builder.lower(root.get("fullName")), pattern), + builder.like(builder.lower(root.get("jobTitle")), pattern), + builder.like(builder.lower(root.get("department").get("departmentName")), pattern) + )); + } + return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new)); + }; + } + + private Sort requestSort(String field, String direction) { + String property = switch (field) { + case "fullName" -> "fullName"; + case "username" -> "username"; + case "status" -> "status"; + default -> "createdAt"; + }; + return "asc".equalsIgnoreCase(direction) + ? Sort.by(property).ascending() + : Sort.by(property).descending(); + } + @PostMapping("/{id}/approve") @Transactional public ResponseEntity approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) { diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java index a844c86..ea88a7a 100755 --- a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java @@ -16,10 +16,13 @@ import com.magistr.app.service.BusinessTimeService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Map; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + @RestController @RequestMapping("/api/teacher-subjects") @RequireRoles({Role.ADMIN, Role.DEPARTMENT}) @@ -53,15 +56,18 @@ public class TeacherSubjectController { } @GetMapping - public List getAll() { - return teacherSubjectRepository.findAll().stream() + public List getAll(@RequestParam(required = false) List subjectId) { + if (subjectId != null && subjectId.size() > 100) { + throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин"); + } + List assignments = subjectId == null + ? teacherSubjectRepository.findAll() + : subjectId.isEmpty() + ? List.of() + : teacherSubjectRepository.findBySubjectIdInWithDetails(subjectId.stream().distinct().toList()); + return assignments.stream() .filter(ts -> canManage(ts.getUser(), ts.getSubject())) - .map(ts -> new TeacherSubjectResponse( - ts.getUserId(), - ts.getUser().getUsername(), - ts.getSubjectId(), - ts.getSubject().getName() - )) + .map(this::toResponse) .toList(); } @@ -136,4 +142,14 @@ public class TeacherSubjectController { ) && departmentId.equals(subject.getDepartmentId()); } + + private TeacherSubjectResponse toResponse(TeacherSubject teacherSubject) { + return new TeacherSubjectResponse( + teacherSubject.getUserId(), + teacherSubject.getUser().getUsername(), + teacherSubject.getUser().getFullName(), + teacherSubject.getSubjectId(), + teacherSubject.getSubject().getName() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index 80a1ff9..9eecb8f 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -6,6 +6,7 @@ import com.magistr.app.dto.CreateUserRequest; import com.magistr.app.dto.DepartmentTransferRequest; import com.magistr.app.dto.TeacherDepartmentAssignmentDto; import com.magistr.app.dto.UserResponse; +import com.magistr.app.dto.PageResponse; import com.magistr.app.model.Department; import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.Role; @@ -22,11 +23,16 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; @RestController @@ -80,6 +86,23 @@ public class UserController { return response; } + @GetMapping("/page") + public PageResponse getUsersPage( + @RequestParam(defaultValue = "") String query, + @RequestParam(required = false) Role role, + @RequestParam(required = false) Long departmentId, + @RequestParam(defaultValue = "true") boolean includeArchived, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size, + @RequestParam(defaultValue = "fullName") String sort, + @RequestParam(defaultValue = "asc") String direction) { + var users = userRepository.findAll( + userSpecification(query, role, departmentId, includeArchived), + PageRequest.of(normalizePage(page), normalizeSize(size), userSort(sort, direction)) + ); + return PageResponse.from(users.map(this::toUserResponse)); + } + @GetMapping("/teachers") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public List getTeachers() { @@ -91,6 +114,92 @@ public class UserController { return response; } + @GetMapping("/teachers/options") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List 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 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 userSpecification(String query, + Role role, + Long departmentId, + boolean includeArchived) { + return (root, criteriaQuery, builder) -> { + List predicates = new ArrayList<>(); + if (!includeArchived) { + predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED)); + } + if (role != null) { + predicates.add(builder.equal(root.get("role"), role)); + } + if (departmentId != null) { + predicates.add(builder.equal(root.get("departmentId"), departmentId)); + } + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + if (!normalizedQuery.isEmpty()) { + String pattern = "%" + normalizedQuery + "%"; + predicates.add(builder.or( + builder.like(builder.lower(root.get("username")), pattern), + builder.like(builder.lower(root.get("fullName")), pattern), + builder.like(builder.lower(root.get("jobTitle")), pattern) + )); + } + return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new)); + }; + } + + private Sort userSort(String field, String direction) { + String property = switch (field) { + case "username" -> "username"; + case "role" -> "role"; + case "status" -> "status"; + default -> "fullName"; + }; + return "desc".equalsIgnoreCase(direction) + ? Sort.by(property).descending() + : Sort.by(property).ascending(); + } + + private int normalizePage(int page) { + return Math.max(page, 0); + } + + private int normalizeSize(int size) { + return Math.max(1, Math.min(size, 100)); + } + @GetMapping("/teachers/{departmentId}") @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getTeachersByDepartmentId(@PathVariable Long departmentId){ diff --git a/backend/src/main/java/com/magistr/app/dto/PageResponse.java b/backend/src/main/java/com/magistr/app/dto/PageResponse.java new file mode 100644 index 0000000..d64bdea --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/PageResponse.java @@ -0,0 +1,27 @@ +package com.magistr.app.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record PageResponse( + List items, + int page, + int size, + long totalItems, + int totalPages, + boolean first, + boolean last +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isFirst(), + page.isLast() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java b/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java index c3c4e93..b129295 100755 --- a/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java @@ -4,12 +4,14 @@ public class TeacherSubjectResponse { private Long userId; private String username; + private String fullName; private Long subjectId; private String subjectName; - public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) { + public TeacherSubjectResponse(Long userId, String username, String fullName, Long subjectId, String subjectName) { this.userId = userId; this.username = username; + this.fullName = fullName; this.subjectId = subjectId; this.subjectName = subjectName; } @@ -22,6 +24,10 @@ public class TeacherSubjectResponse { return username; } + public String getFullName() { + return fullName; + } + public Long getSubjectId() { return subjectId; } diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java index 51ee39d..caa0d39 100644 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java @@ -3,14 +3,17 @@ package com.magistr.app.repository; import com.magistr.app.model.AcademicCalendar; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; -public interface AcademicCalendarRepository extends JpaRepository { +public interface AcademicCalendarRepository extends JpaRepository, JpaSpecificationExecutor { long countByStudyFormId(Long studyFormId); @@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository 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 searchOptions(@Param("query") String query, + @Param("academicYearId") Long academicYearId, + @Param("specialtyId") Long specialtyId, + @Param("profileId") Long profileId, + @Param("studyFormId") Long studyFormId, + Pageable pageable); } diff --git a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java index c96cb49..98004c8 100755 --- a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java @@ -2,12 +2,29 @@ package com.magistr.app.repository; import com.magistr.app.model.Classroom; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.Optional; import java.util.List; -public interface ClassroomRepository extends JpaRepository { +public interface ClassroomRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByName(String name); List 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 searchActiveOptions(@Param("query") String query, + @Param("archivedStatus") String archivedStatus, + Pageable pageable); } diff --git a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java index 4cdfe96..c9fd58b 100755 --- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java @@ -3,6 +3,7 @@ package com.magistr.app.repository; import com.magistr.app.model.StudentGroup; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -10,7 +11,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; -public interface GroupRepository extends JpaRepository { +public interface GroupRepository extends JpaRepository, JpaSpecificationExecutor { List findByEducationFormId(Long educationFormId); diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java index a1524d9..5646a09 100644 --- a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository { """) List 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 findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List groupIds); + @Query(""" select subgroup from Subgroup subgroup diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java index bf942d0..c511239 100755 --- a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java @@ -2,11 +2,12 @@ package com.magistr.app.repository; import com.magistr.app.model.Subject; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import java.util.List; import java.util.Optional; -public interface SubjectRepository extends JpaRepository { +public interface SubjectRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByNameIgnoreCase(String name); List findByDepartmentId(Long departmentId); diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java index 44dfaec..ae70ed7 100644 --- a/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java @@ -3,10 +3,11 @@ package com.magistr.app.repository; import com.magistr.app.model.TeacherCreationRequest; import com.magistr.app.model.TeacherCreationRequestStatus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import java.util.List; -public interface TeacherCreationRequestRepository extends JpaRepository { +public interface TeacherCreationRequestRepository extends JpaRepository, JpaSpecificationExecutor { List findByDepartment_IdOrderByCreatedAtDesc(Long departmentId); diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java index 05c439c..af4106a 100755 --- a/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java @@ -12,6 +12,16 @@ public interface TeacherSubjectRepository extends JpaRepository findByUserId(Long userId); List 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 findBySubjectIdInWithDetails(@Param("subjectIds") List subjectIds); + @Query(value = """ select subject_link.user_id from teacher_subjects subject_link diff --git a/backend/src/main/java/com/magistr/app/repository/UserRepository.java b/backend/src/main/java/com/magistr/app/repository/UserRepository.java index 95e9700..fa52870 100755 --- a/backend/src/main/java/com/magistr/app/repository/UserRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java @@ -4,14 +4,18 @@ import com.magistr.app.model.Role; import com.magistr.app.model.User; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.time.LocalDate; import java.util.List; import java.util.Optional; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByUsername(String username); @@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository { List 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 searchTeacherOptions(@Param("query") String query, + @Param("role") Role role, + @Param("archivedStatus") String archivedStatus, + @Param("departmentId") Long departmentId, + @Param("date") LocalDate date, + Pageable pageable); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select user from User user where user.id = :id") Optional findByIdForUpdate(@Param("id") Long id); diff --git a/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java b/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java index 64e4bcb..11bab83 100644 --- a/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/TeacherSubjectControllerTest.java @@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -74,6 +75,52 @@ class TeacherSubjectControllerTest { assertThat(captor.getValue().getSubjectId()).isEqualTo(subject.getId()); } + @Test + void returnsAssignmentsOnlyForRequestedSubjects() { + TeacherSubjectRepository teacherSubjectRepository = mock(TeacherSubjectRepository.class); + UserRepository userRepository = mock(UserRepository.class); + SubjectRepository subjectRepository = mock(SubjectRepository.class); + TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class); + User teacher = teacher(); + Subject subject = new Subject(); + subject.setId(20L); + subject.setName("Высшая математика"); + TeacherSubject assignment = new TeacherSubject(teacher.getId(), subject.getId()); + setTeacherSubjectDetails(assignment, teacher, subject); + when(teacherSubjectRepository.findBySubjectIdInWithDetails(List.of(subject.getId()))) + .thenReturn(List.of(assignment)); + TeacherSubjectController controller = new TeacherSubjectController( + teacherSubjectRepository, + userRepository, + subjectRepository, + teacherDepartmentService, + BUSINESS_TIME + ); + AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null)); + + var result = controller.getAll(List.of(subject.getId())); + + assertThat(result).singleElement().satisfies(response -> { + assertThat(response.getSubjectId()).isEqualTo(subject.getId()); + assertThat(response.getUserId()).isEqualTo(teacher.getId()); + assertThat(response.getFullName()).isEqualTo(teacher.getFullName()); + }); + verify(teacherSubjectRepository).findBySubjectIdInWithDetails(List.of(subject.getId())); + } + + private void setTeacherSubjectDetails(TeacherSubject assignment, User teacher, Subject subject) { + try { + var userField = TeacherSubject.class.getDeclaredField("user"); + userField.setAccessible(true); + userField.set(assignment, teacher); + var subjectField = TeacherSubject.class.getDeclaredField("subject"); + subjectField.setAccessible(true); + subjectField.set(assignment, subject); + } catch (ReflectiveOperationException exception) { + throw new AssertionError("Не удалось подготовить тестовую привязку", exception); + } + } + private User teacher() { User teacher = new User(); teacher.setId(10L); diff --git a/docs/API.md b/docs/API.md index e3b63cc..4fd045f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -230,10 +230,39 @@ Refresh-токен ротируется при каждом успешном о `UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`. +### `GET /api/users/page` + +Пагинированный реестр пользователей для администратора. Параметры: `query`, `role`, +`departmentId`, `includeArchived`, `page`, `size`, `sort`, `direction`. Размер страницы +ограничен диапазоном `1..100`; поддерживаемые поля сортировки — `fullName`, `username`, +`role`, `status`. + +Пагинированные endpoints используют общий ответ: + +```json +{ + "items": [], + "page": 0, + "size": 25, + "totalItems": 0, + "totalPages": 0, + "first": true, + "last": true +} +``` + ### `GET /api/users/teachers` Список только преподавателей (роль `TEACHER`). +### `GET /api/users/teachers/options` + +Серверный поиск преподавателей для асинхронных списков. Параметры: `query`, +`departmentId`, `selectedId`, `limit` (`1..50`). Поиск выполняется по логину, ФИО и +должности; архивные записи исключаются. Для роли `DEPARTMENT` backend принудительно +использует кафедру текущего пользователя и учитывает действующие основные и дополнительные +назначения из `teacher_department_assignments`. `selectedId` не обходит это ограничение. + ### `GET /api/users/teachers/{departmentId}` Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`. @@ -309,6 +338,7 @@ Refresh-токен ротируется при каждом успешном о | Метод | URL | Назначение | |-------|-----|------------| | `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса | +| `GET` | `/api/teacher-requests/page?query=&status=PENDING&page=0&size=25` | Пагинированный поиск заявок по логину, ФИО, должности и кафедре | | `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя | | `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку | @@ -567,6 +597,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | Метод | URL | Назначение | |-------|-----|------------| | `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | +| `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса | | `GET` | `/api/admin/academic-calendars/{id}` | Один график | | `POST` | `/api/admin/academic-calendars` | Создать график | | `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` создаёт график и начальную сетку одной транзакцией. Для каждого курса из диапазона `1..courseCount` backend сохраняет один период с кодом `Т` на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после @@ -1305,6 +1341,21 @@ payload обрабатываются один раз, используется Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение. +### `GET /api/groups/page` + +Пагинированный реестр групп. Параметры: `query`, повторяемый `educationFormId`, +`departmentId`, `yearStartStudy`, `includeArchived`, `page`, `size`, `sort`, `direction`. +Поддерживаемые поля сортировки: `name`, `yearStartStudy`, `groupSize`, `status`. Поиск +проверяет название группы, код и название специальности, профиль и форму обучения. Для +роли `DEPARTMENT` кафедра всегда берётся из JWT-контекста независимо от переданного фильтра. + +### `GET /api/groups/options` + +Асинхронный поиск активных групп. Параметры: `query`, `departmentId`, `selectedId`, `limit` +(`1..50`). Завершившие обучение и архивные группы исключаются. Для роли `DEPARTMENT` +действует то же принудительное ограничение своей кафедрой, включая восстановление выбранного +`selectedId`. + **Ответ:** ```json [ @@ -1408,7 +1459,7 @@ legacy-alias для старых клиентов и исторически со | Метод | URL | Назначение | |-------|-----|------------| -| `GET` | `/api/subgroups` | Список всех подгрупп | +| `GET` | `/api/subgroups?groupId=1&groupId=2` | Подгруппы выбранных групп; без параметра — совместимый список всех подгрупп, не более 100 ID групп за запрос | | `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы | | `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу | | `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу | @@ -1486,6 +1537,12 @@ legacy-alias для старых клиентов и исторически со Список аудиторий с привязанным оборудованием. +### `GET /api/classrooms/options` + +Асинхронный поиск доступных неархивных аудиторий по названию и корпусу. Параметры: +`query`, `selectedId`, `limit` (`1..50`). Выбранная запись добавляется в результат, если +она не попала в первую страницу поиска. + **Ответ:** ```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 { "name": "Физика", @@ -1620,14 +1683,17 @@ legacy-alias для старых клиентов и исторически со ### `GET /api/teacher-subjects` -Список всех привязок. +Список привязок. Повторяемый параметр `subjectId` ограничивает результат указанными +дисциплинами; за один запрос принимается не более 100 ID. Без параметра сохраняется +совместимый режим получения всех доступных текущей роли привязок. **Ответ:** ```json [ { "userId": 2, - "userName": "Тестовый преподаватель", + "username": "teacher2", + "fullName": "Тестовый преподаватель", "subjectId": 1, "subjectName": "Высшая математика" } diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a5d8ced..f5e0439 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -20,6 +20,8 @@ frontend/ ├── package-lock.json # Зафиксированное npm-дерево ├── auth-session.js # Access JWT и профиль сессии только в памяти вкладки ├── telemetry.js # Same-origin загрузчик собранного OTel bundle +├── schedule-overview.js # Общий расчёт и рендер «Сегодня / Следующая пара» +├── schedule-overview.css # Общие карточки ближайших занятий и поискового выбора группы ├── security.conf # CSP и защитные HTTP-заголовки Apache ├── telemetry/ │ └── otel-entry.js # Исходная точка сборки OpenTelemetry @@ -29,6 +31,7 @@ frontend/ │ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика │ ├── academic-calendar-title.test.mjs # Составное название календарного графика │ ├── academic-calendar-tooltip.test.mjs # Позиционирование подсказки возле курсора и границ viewport +│ ├── schedule-overview.test.mjs # Текущее занятие, список на сегодня и следующая пара │ ├── admin-ui-regressions.test.mjs # Заголовки, меню, действия слотов и компоновка вкладок │ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов @@ -63,6 +66,13 @@ frontend/ │ │ ├── api.js # HTTP-обёртка (fetch + Authorization) │ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone │ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO +│ │ ├── async-combobox.js # Асинхронный поиск больших справочников +│ │ ├── dialog.js # Доступные подтверждения, сообщения и ввод причины +│ │ ├── dirty-state.js # Защита несохранённых изменений +│ │ ├── pagination.js # Общая панель серверной пагинации +│ │ ├── url-state.js # Чтение и запись фильтров в query string +│ │ ├── view-state.js # Единые loading/empty/error-состояния таблиц +│ │ ├── academic-period.js # Каскад учебного года и семестра в профильных вкладках │ │ ├── utils.js # Утилиты │ │ ├── schedule-period.js # Выбор ближайшего учебного периода для расписаний и нагрузки │ │ └── views/ # Модули представлений @@ -172,19 +182,19 @@ frontend/ | Tab | Описание | API | |-----|----------|-----| | `dashboard` | Сводные метрики и проверка конфликтов текущей недели с явным статусом полноты данных | `/api/departments`, `/api/classrooms`, `/api/groups`, `/api/users/teachers`, `/api/schedule/search`, `/api/admin/time-slots` | -| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` | -| `users` | CRUD пользователей | `/api/users` | -| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | +| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с поиском, статусом, пагинацией и редактированием перед одобрением | `/api/teacher-requests/page`, `/api/departments` | +| `users` | CRUD пользователей с серверными поиском, ролевым фильтром, сортировкой и пагинацией | `/api/users/page` | +| `groups` | CRUD групп, серверный поиск и пагинация, мультифильтр по формам обучения, настройка 0/2/3 подгрупп и поиск совместимого графика | `/api/groups/page`, `/api/groups/options`, `/api/admin/academic-calendars/options`, `/api/groups/{id}/subgroups` | | `classrooms` | Аудитории | `/api/classrooms` | -| `subjects` | Дисциплины | `/api/subjects` | +| `subjects` | Дисциплины с серверным реестром, привязками только для текущей страницы и асинхронным выбором преподавателя | `/api/subjects/page`, `/api/teacher-subjects?subjectId=…`, `/api/users/teachers/options` | | `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` | -| `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` | -| `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers` | -| `schedule` | Конструктор правил: по умолчанию редактирует опубликованное расписание, позволяет переключиться на черновик или создать его из выбранной версии; содержит подсказки пожеланий и визуальную матрицу групп | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | -| `schedule-versions` | Контур публикации: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` | -| `schedule-quality` | Диагностика выбранной версии семестра: оценка, метрики и проблемные правила с переходом к их редактированию | `/api/edu-office/schedule/quality`, `/api/admin/schedule-rules` | -| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` | +| `schedule-view` | Просмотр опубликованного расписания: учебный год и семестр выбираются каскадом в дополнительных фильтрах, поисковые списки сущностей при открытии остаются свёрнутыми и используют размытый непрозрачный слой меню, найденные расписания переключаются без общего контекста страницы, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` | +| `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену; преподаватель ищется асинхронно по всем кафедрам для `ADMIN`/`EDUCATION_OFFICE` и только по своей кафедре для `DEPARTMENT`; поисковое меню размывает фон, а фильтры согласования оформлены для светлой и тёмной тем | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers/options` | +| `schedule` | Конструктор правил: локальный каскад `Учебный год → Семестр → Версия` открывает опубликованное расписание или черновик; первичная загрузка скрывает нестабильную компоновку до подготовки полей, а таблица правил использует фиксированные колонки; группы, преподаватели и аудитории ищутся на сервере, подгруппы загружаются только для выбранных групп; предупреждение о несохранённых изменениях появляется только после действия пользователя | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/groups/options`, `/api/subgroups?groupId=…`, `/api/users/teachers/options`, `/api/classrooms/options` | +| `schedule-versions` | Контур публикации с локальным выбором учебного года и семестра: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` | +| `schedule-quality` | Диагностика выбранной версии через каскад `Учебный год → Семестр → Версия`: оценка, метрики и проблемные правила с переходом к их редактированию | `/api/edu-office/schedule/quality`, `/api/admin/schedule-rules` | +| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика; редко используемая форма создания учебного года расположена внизу вкладки `Графики` | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | ### Особенности админских вкладок @@ -206,10 +216,12 @@ frontend/ расположены реестр пожеланий на семестр и очередь заявок на изменение опубликованных пар. Пожелание можно принять или отклонить с комментарием. Заявку применяют только `ADMIN` и `EDUCATION_OFFICE`; кафедра видит записи своих преподавателей без управляющих - действий. Каждая карточка показывает исходное и запрошенное состояние, результат + действий. Фильтры обоих реестров используют тематические кастомные списки с отдельными + поверхностями для светлой и тёмной тем, а поиск преподавателя — общее размытое меню + `AsyncCombobox`. Каждая карточка показывает исходное и запрошенное состояние, результат предварительной проверки и хронологию решения. -- Вкладка `schedule-quality` доступна администратору и учебному отделу. После выбора - семестра и версии она выводит круговую оценку от 0 до 100, карточки метрик и реестр правил +- Вкладка `schedule-quality` доступна администратору и учебному отделу. После каскадного + выбора учебного года, семестра и версии она выводит круговую оценку от 0 до 100, карточки метрик и реестр правил с фильтрами по серьёзности и типу. Все проблемные занятия одного правила показаны одной карточкой; в ней выводятся число затронутых занятий, категории и суммарный штраф. Кнопка `Изменить правило` открывает выбранную версию в конструкторе и сразу заполняет форму этим @@ -217,9 +229,11 @@ frontend/ чтения. Переход между вкладками передаёт ID версии и правила через одноразовые ключи `magistr.schedule.openVersionId` и `magistr.schedule.openRuleId` в `localStorage`. - Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как - отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные + отдельный контур публикации. В верхней карточке учебный год сначала ограничивает список + семестров, поэтому архив периодов не превращается в один длинный селект. Карточка показывает версию, которую видят конечные пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал. - В конструкторе опубликованная версия текущего семестра выбирается по умолчанию и выделяется + В конструкторе локальный каскад `Учебный год → Семестр → Версия` по умолчанию выбирает + опубликованную версию текущего семестра и выделяет её зелёным контуром; её изменения применяются сразу. Селект позволяет перейти в медный режим черновика. Кнопка `Создать черновик` копирует выбранное опубликованное расписание или черновик и сразу открывает новую рабочую копию. Перед публикацией интерфейс @@ -227,18 +241,47 @@ frontend/ при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. -- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. При загрузке автоматически выбирается текущий семестр, а между учебными периодами — ближайший будущий; если будущего нет, используется последний завершённый. Для выбранного семестра frontend запрашивает весь период от его первой до последней даты и собирает повторяющиеся занятия в одну семестровую матрицу; режим «По текущей дате» сохраняет двухнедельный диапазон. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается действующей кафедральной связью преподавателя на дату занятия; группы другой кафедры не отбрасываются, если занятие ведёт преподаватель текущей кафедры. Преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Каждая карточка занятия явно показывает фактические границы недель: `(с 1 по 18 нед.)` либо `(7 нед.)`. На мобильной ширине вместо широкой матрицы показывается один день активного расписания с переключателем дней. -- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. +- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; поисковые меню группы, преподавателя и аудитории используют плотный фон с `backdrop-filter`, чтобы строки не смешивались с карточками под списком. Основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах учебный год каскадно ограничивает список семестров из `/api/schedule/semesters`; оба значения сохраняются в URL. При загрузке автоматически выбирается текущий семестр, а между учебными периодами — ближайший будущий; если будущего нет, используется последний завершённый. Экран всегда показывает опубликованную версию: выбор черновика остаётся в `schedule-quality` и `schedule`. Для выбранного семестра frontend запрашивает весь период от его первой до последней даты и собирает повторяющиеся занятия в одну семестровую матрицу. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается действующей кафедральной связью преподавателя на дату занятия; группы другой кафедры не отбрасываются, если занятие ведёт преподаватель текущей кафедры. Преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Каждая карточка занятия явно показывает фактические границы недель: `(с 1 по 18 нед.)` либо `(7 нед.)`. На мобильной ширине вместо широкой матрицы показывается один день активного расписания с переключателем дней. +- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Преподаватель и аудитория ищутся асинхронно через `/api/users/teachers/options` и `/api/classrooms/options`; в подписи аудитории показывается её название. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. - Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. -- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню возле нажатой кнопки, с автоматическим разворотом от границ viewport: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. -- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Учебный год создаётся одной формой вместе с обязательными осенним и весенним семестрами: пользователь вводит только четырёхзначный год начала, например `2026`; интерфейс подставляет год окончания `2027`, формирует название `2026-2027` и внутренний период `01.09.2026–30.06.2027`. Границы семестров можно скорректировать до сохранения. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` календарного графика в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Каждый код активности окрашивает ячейку, подсказку, диалог и итоговый чип своим `colorCode`. REST-контракт сетки остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`; два поля учебного года принимают и показывают только четыре цифры. +- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. До завершения первичной загрузки справочников и правил вкладка показывает единое состояние загрузки, после чего открывает уже подготовленную форму; таблица правил использует `table-layout: fixed`, явный `colgroup` и отключённую анимацию строк, поэтому заголовки колонок не смещаются при замене состояния загрузки данными. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню возле нажатой кнопки, с автоматическим разворотом от границ viewport: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. +- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Форма редкого ежегодного действия — создания учебного года вместе с семестрами — расположена после реестров учебных годов и календарных графиков, в нижней части вкладки `Графики`. Пользователь вводит только четырёхзначный год начала, например `2026`; интерфейс подставляет год окончания `2027`, формирует название `2026-2027` и внутренний период `01.09.2026–30.06.2027`. Границы семестров можно скорректировать до сохранения. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` календарного графика в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Каждый код активности окрашивает ячейку, подсказку, диалог и итоговый чип своим `colorCode`. REST-контракт сетки остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`; два поля учебного года принимают и показывают только четыре цифры. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. +### Общие UX-компоненты первого пакета + +- Общей панели учебного контекста под topbar нет. Каскадные селекты периода размещены только + в профильных вкладках: `schedule`, `schedule-versions`, `schedule-quality` и + `schedule-view`. Календарный график и назначение графика группе сохраняют собственные + локальные селекты. +- Вкладка и фильтры сохраняются в URL. Реестры групп, пользователей, дисциплин и заявок + восстанавливают поиск, фильтры, сортировку, номер и размер страницы после reload или + возврата по ссылке. Просмотр расписания сохраняет вид сущности, выбранную сущность, дату, + учебный год, семестр и дополнительные фильтры; конечные кабинеты сохраняют неделю, вкладку и группу. +- `pagination.js` работает с общим ответом `PageResponse`; размер страницы — 25, 50 или + 100. `view-state.js` унифицирует загрузку со spinner, пустой результат и ошибку с кнопкой + повторного запроса. +- `AsyncCombobox` загружает варианты с сервера, сохраняет выбранный ID, поддерживает + клавиатуру и роли `combobox/listbox`. Он используется для групп, преподавателей, + аудиторий и совместимых календарных графиков; предварительная загрузка вариантов не + раскрывает меню. Студент больше не загружает полный список групп. +- `dialog.js` заменяет нативные `confirm`, `prompt` и `alert`: возвращает фокус, удерживает + его внутри окна, закрывается по Escape и может требовать причину. Нативных диалогов в + admin/settings-модулях нет. +- `dirty-state.js` предупреждает при смене вкладки, версии, графика или закрытии страницы. + Календарная сетка и привязки дисциплин показывают маркер `•` на кнопке сохранения; + конструктор правил отслеживает изменения полей и состава слотов, но игнорирует + программную синхронизацию селектов при загрузке и сбросе формы. +- `trackUxEvent()` создаёт базовые события смены раздела, загрузки реестров и расписания, + сохранения календаря/правила и просмотра ближайших занятий. В production они + преобразуются в OpenTelemetry spans, а до готовности bundle хранятся в ограниченной + очереди. + ### Страница настроек (`/admin/settings/`) Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью. @@ -358,7 +401,7 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) { ### Просмотр расписаний (`/admin/#schedule-view`) -Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра расписаний. Доступны фильтры по семестру, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. При выборе семестра найденные занятия отображаются одной матрицей на весь период с явными границами недель для каждой пары; без семестра используется совмещённая двухнедельная таблица чётной/нечётной недели. +Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра опубликованного расписания. Доступны каскад учебного года и семестра, а также фильтры по группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. При выборе семестра найденные занятия отображаются одной матрицей на весь период с явными границами недель для каждой пары. ### Преподаватель (`/teacher/`) @@ -367,6 +410,8 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) { сессии. Основные элементы: +- общий блок «Сегодня» и карточка «Следующая пара»; отдельный запрос покрывает ближайшие + 14 дней, поэтому следующая пара находится и за границей текущей недели; - навигация по неделям: предыдущая, текущая, следующая; - выбор даты через `input[type="date"]`; - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; @@ -387,10 +432,13 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) { ### Студент (`/student/`) -В текущей модели студент не связан с конкретной группой, поэтому страница использует выбор группы из `/api/groups`. +В текущей модели студент не связан с конкретной группой, поэтому страница использует +асинхронный поиск группы через `/api/groups/options`. Основные элементы: -- селект группы с сохранением выбора в `localStorage.studentGroupId`; +- поисковый combobox группы с подсказками по специальности, профилю и курсу; выбор хранится + в `localStorage.studentGroupId` и `studentGroup` query string; +- общий блок «Сегодня» и карточка «Следующая пара», рассчитанные по ближайшим 14 дням; - недельная сетка по дням; - запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; - отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп. @@ -461,6 +509,8 @@ same-origin путь `/otel/v1/traces`. - `scripts/build-vendor.mjs` собирает их через esbuild в `/vendor/otel.js`; - браузер импортирует только same-origin bundle, CDN-код в runtime отсутствует; - на `localhost` телеметрия не включается. +- прикладные `magistr:ux-event` в production записываются отдельными spans с безопасными + скалярными атрибутами; очередь ограничена 100 событиями. ```javascript telemetryPromise = import('/vendor/otel.js'); diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 3b0104f..79afd49 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -630,7 +630,7 @@ } .schedule-view-extra-grid { - grid-template-columns: repeat(4, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); margin-top: 0.9rem; } diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index edaf6fc..89c81ba 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -689,6 +689,58 @@ letter-spacing: 0.04em; } +.schedule-initial-state { + display: flex; + min-height: 220px; + align-items: center; + justify-content: center; + gap: 0.7rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + background: var(--bg-card); + color: var(--text-secondary); +} + +.schedule-initial-content { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1.5rem; +} + +.schedule-initial-state[hidden], +.schedule-initial-content.is-loading { + display: none; +} + +.schedule-initial-content .card, +#schedule-table tbody tr { + animation: none; +} + +#schedule-table { + min-width: 1180px; + table-layout: fixed; +} + +#schedule-table .schedule-rule-col-id { width: 5%; } +#schedule-table .schedule-rule-col-subject { width: 16%; } +#schedule-table .schedule-rule-col-semester { width: 13%; } +#schedule-table .schedule-rule-col-groups { width: 16%; } +#schedule-table .schedule-rule-col-load { width: 14%; } +#schedule-table .schedule-rule-col-slots { width: 20%; } +#schedule-table .schedule-rule-col-actions { width: 16%; } + +#schedule-table th, +#schedule-table td { + overflow-wrap: anywhere; + vertical-align: top; +} + +#schedule-table thead th { + white-space: nowrap; +} + .schedule-group-select { max-width: 520px; } @@ -703,6 +755,23 @@ max-height: 220px; } +.schedule-group-search { + width: calc(100% - 1rem); + min-height: 38px; + margin: 0.5rem; + padding: 0 0.75rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font: inherit; +} + +.schedule-group-search:focus { + border-color: var(--accent); + outline: 3px solid var(--accent-glow); +} + .schedule-hours-panel { margin-top: 1rem; } @@ -3920,3 +3989,193 @@ tbody tr:hover { white-space: nowrap; line-height: 1.2; } +.registry-toolbar { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.75rem; + margin: 1rem 0; +} + +.registry-toolbar .form-group { + min-width: min(260px, 100%); + margin: 0; +} + +.registry-search { + width: min(420px, 100%); +} + +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 1rem; +} + +.pagination-actions, +.pagination-size { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.pagination-summary, +.pagination-page, +.pagination-size span { + color: var(--text-secondary); + font-size: 0.86rem; +} + +.pagination-size select { + min-height: 36px; + padding: 0.25rem 1.75rem 0.25rem 0.55rem; + color: var(--text-primary); + background: var(--bg-secondary); + border: 1px solid var(--bg-card-border); + border-radius: 8px; +} + +.state-spinner { + display: inline-block; + width: 15px; + height: 15px; + margin-right: 0.55rem; + vertical-align: -2px; + border: 2px solid color-mix(in srgb, var(--accent) 22%, transparent); + border-top-color: var(--accent); + border-radius: 50%; + animation: state-spin 0.75s linear infinite; +} + +@keyframes state-spin { to { transform: rotate(360deg); } } + +.view-state-error { + color: var(--danger, #dc2626); +} + +.view-state-error .btn { + margin-left: 0.75rem; +} + +.async-combobox { + position: relative; + width: 100%; +} + +.async-combobox-control { + position: relative; +} + +.async-combobox-control input { + width: 100%; + min-height: 42px; + padding-right: 2.5rem; +} + +.async-combobox-clear { + position: absolute; + top: 50%; + right: 0.45rem; + width: 30px; + height: 30px; + transform: translateY(-50%); + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; +} + +.async-combobox-clear:hover { background: var(--bg-hover); color: var(--text-primary); } + +.async-combobox-menu { + position: absolute; + z-index: 1200; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + max-height: 320px; + overflow: auto; + padding: 0.35rem; + border: 1px solid var(--bg-card-border); + border-radius: 12px; + background: rgba(10, 10, 15, 0.96); + backdrop-filter: blur(20px) saturate(135%); + -webkit-backdrop-filter: blur(20px) saturate(135%); + box-shadow: 0 18px 45px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(255, 255, 255, 0.04) inset; +} + +[data-theme="light"] .async-combobox-menu { + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16), 0 0 0 1px rgba(15, 23, 42, 0.04) inset; +} + +.async-combobox-option { + display: grid; + width: 100%; + gap: 0.2rem; + padding: 0.7rem 0.75rem; + color: var(--text-primary); + text-align: left; + background: transparent; + border: 0; + border-radius: 9px; + cursor: pointer; +} + +.async-combobox-option:hover, +.async-combobox-option:focus, +.async-combobox-option[aria-selected="true"] { + outline: none; + background: var(--bg-hover); +} + +.async-combobox-option small, +.async-combobox-state { + color: var(--text-secondary); +} + +.async-combobox-state { + padding: 0.8rem; + font-size: 0.9rem; +} + +.async-combobox-state.is-error { color: var(--danger, #dc2626); } + +.project-dialog-backdrop { + position: fixed; + z-index: 4000; + inset: 0; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(15, 23, 42, 0.48); + backdrop-filter: blur(8px); +} + +.project-dialog { + width: min(520px, 100%); + padding: 1.25rem; + border: 1px solid var(--bg-card-border); + border-radius: 18px; + color: var(--text-primary); + background: var(--bg-card); + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.32); +} + +.project-dialog header, +.project-dialog footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.project-dialog header h2 { margin: 0; } +.project-dialog footer { justify-content: flex-end; margin-top: 1rem; } +.project-dialog-field { display: grid; gap: 0.4rem; margin-top: 1rem; } +.project-dialog-field textarea { width: 100%; resize: vertical; } +.project-dialog-error { min-height: 1.3rem; margin-top: 0.35rem; color: var(--danger, #dc2626); font-size: 0.85rem; } diff --git a/frontend/admin/css/schedule-versions.css b/frontend/admin/css/schedule-versions.css index ef2deeb..6e6bdfb 100644 --- a/frontend/admin/css/schedule-versions.css +++ b/frontend/admin/css/schedule-versions.css @@ -79,6 +79,8 @@ } .version-hero-controls label { + display: block; + margin-bottom: .35rem; color: rgba(255, 255, 255, .62); font-size: .7rem; font-weight: 800; @@ -98,6 +100,12 @@ .version-hero-controls option { color: #152536; background: #f7f4ed; } +.version-hero-period-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .65rem; +} + .version-status { min-height: 2.8rem; padding: .8rem 1rem; @@ -130,9 +138,13 @@ } .version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; } .version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; } -.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .55rem; align-items: end; } -.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.version-constructor-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; align-items: end; } +.version-constructor-field { min-width: 0; } +.version-constructor-field-wide { grid-column: 1 / -1; } +.version-constructor-controls label { display: block; margin-bottom: .35rem; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } .version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); } +.version-constructor-field select { width: 100%; } +.version-constructor-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: .55rem; } .version-current { display: grid; @@ -304,5 +316,7 @@ .version-diff-score { grid-template-columns: 1fr; } .version-history-row { grid-template-columns: 1fr; gap: .25rem; } .version-constructor-controls { grid-template-columns: 1fr; } - .version-constructor-controls label { grid-column: auto; } + .version-constructor-field-wide { grid-column: auto; } + .version-constructor-actions { grid-column: auto; flex-direction: column; } + .version-hero-period-grid { grid-template-columns: 1fr; } } diff --git a/frontend/admin/css/teacher-absences.css b/frontend/admin/css/teacher-absences.css index 5b33159..0a9fe6e 100644 --- a/frontend/admin/css/teacher-absences.css +++ b/frontend/admin/css/teacher-absences.css @@ -89,6 +89,15 @@ border-radius: var(--radius-lg); } +.absence-create-card { + z-index: 20; + overflow: visible; +} + +.absence-create-card:focus-within { + z-index: 30; +} + .absence-create-card .card-header-row > div:first-child, .absence-registry-head > div:first-child { display: flex; @@ -526,13 +535,64 @@ body.absence-wizard-open { .teacher-dialogue-toolbar { display: flex; + align-items: stretch; gap: .55rem; margin: 1rem 0; } -.teacher-dialogue-toolbar select { +.teacher-dialogue-toolbar > select, +.teacher-dialogue-toolbar .custom-select-wrapper { min-width: 0; - flex: 1; + flex: 1 1 0; +} + +.teacher-dialogue-toolbar > select { + min-height: 44px; + padding: 0.65rem 2.15rem 0.65rem 0.8rem; + border: 1px solid color-mix(in srgb, #f59e0b 24%, var(--bg-card-border)); + border-radius: 11px; + background: var(--bg-input); + color: var(--text-primary); + font: inherit; +} + +.teacher-dialogue-toolbar .custom-select-trigger { + min-height: 44px; + border-color: color-mix(in srgb, #f59e0b 24%, var(--bg-card-border)); + border-radius: 11px; + background: color-mix(in srgb, var(--bg-input) 88%, #f59e0b 4%); +} + +.teacher-dialogue-toolbar .custom-select-trigger:hover, +.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger { + border-color: color-mix(in srgb, #f59e0b 62%, var(--bg-card-border)); + background: color-mix(in srgb, var(--bg-hover) 90%, #f59e0b 6%); +} + +.teacher-dialogue-toolbar .custom-select-wrapper.open .custom-select-trigger, +.teacher-dialogue-toolbar .custom-select-trigger:focus { + box-shadow: 0 0 0 4px color-mix(in srgb, #f59e0b 20%, transparent); +} + +.teacher-dialogue-toolbar .custom-select-menu { + border-color: color-mix(in srgb, #f59e0b 25%, var(--bg-card-border)); + background: rgba(10, 10, 15, 0.96); + backdrop-filter: blur(20px) saturate(135%); + -webkit-backdrop-filter: blur(20px) saturate(135%); +} + +.teacher-dialogue-toolbar .custom-select-item:hover:not(.disabled) { + background: color-mix(in srgb, #f59e0b 10%, var(--bg-hover)); +} + +.teacher-dialogue-toolbar .custom-select-item.selected { + background: color-mix(in srgb, #f59e0b 18%, var(--bg-input)); + color: var(--text-primary); +} + +[data-theme="light"] .teacher-dialogue-toolbar .custom-select-menu { + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.15), 0 0 0 1px rgba(15, 23, 42, 0.04) inset; } .teacher-dialogue-list { @@ -669,6 +729,16 @@ body.absence-wizard-open { grid-template-columns: 1fr; } + .teacher-dialogue-toolbar { + flex-wrap: wrap; + } + + .teacher-dialogue-toolbar .custom-select-wrapper, + .teacher-dialogue-toolbar > select, + .teacher-dialogue-toolbar > .btn { + flex: 1 1 100%; + } + .absence-command-stats { grid-template-columns: repeat(3, 1fr); } diff --git a/frontend/admin/js/academic-period.js b/frontend/admin/js/academic-period.js new file mode 100644 index 0000000..1f00a1f --- /dev/null +++ b/frontend/admin/js/academic-period.js @@ -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); +} diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index f936e44..99c59e4 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -135,7 +135,8 @@ export async function logout() { export const api = { get: async (url) => { - const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix)); + const isCachable = !url.includes('/page') && !url.includes('/options') + && CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix)); if (isCachable) { if (!cache.has(url)) { cache.set(url, await apiFetch(url, 'GET')); diff --git a/frontend/admin/js/async-combobox.js b/frontend/admin/js/async-combobox.js new file mode 100644 index 0000000..ca034d6 --- /dev/null +++ b/frontend/admin/js/async-combobox.js @@ -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 = ` +
+ + +
+ + `; + 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 = '
Поиск...
'; + 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 = `
${escapeText(error.message || 'Не удалось выполнить поиск')}
`; + } + } + + renderItems() { + if (!this.items.length) { + this.menu.innerHTML = `
${escapeText(this.emptyLabel)}
`; + return; + } + this.menu.innerHTML = this.items.map((item, index) => { + const value = String(this.optionValue(item)); + const description = this.optionDescription(item); + return ``; + }).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 = ``; + 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 = ''; + this.select.value = ''; + this.input.value = ''; + delete this.input.dataset.selectedValue; + this.select.dispatchEvent(new Event('change', { bubbles: true })); + this.input.focus(); + this.search(''); + } + + syncFromSelect() { + const selected = this.select.selectedOptions?.[0]; + this.input.value = selected?.value ? selected.textContent.trim() : ''; + if (selected?.value) this.input.dataset.selectedValue = selected.value; + } + + open() { + this.menu.hidden = false; + this.input.setAttribute('aria-expanded', 'true'); + this.root.classList.add('is-open'); + } + + close() { + this.menu.hidden = true; + this.input.setAttribute('aria-expanded', 'false'); + this.root.classList.remove('is-open'); + this.syncFromSelect(); + } + + handleKeydown(event) { + const options = [...this.menu.querySelectorAll('[role="option"]')]; + const current = options.indexOf(document.activeElement); + if (event.key === 'Escape') { + this.close(); + } else if (event.key === 'ArrowDown' && options.length) { + event.preventDefault(); + options[Math.min(current + 1, options.length - 1)].focus(); + } else if (event.key === 'ArrowUp' && options.length) { + event.preventDefault(); + options[Math.max(current - 1, 0)].focus(); + } + } + + handleMenuKeydown(event) { + const options = [...this.menu.querySelectorAll('[role="option"]')]; + const current = options.indexOf(document.activeElement); + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); + this.input.focus(); + } else if (event.key === 'ArrowDown' && current >= 0) { + event.preventDefault(); + options[Math.min(current + 1, options.length - 1)]?.focus(); + } else if (event.key === 'ArrowUp' && current >= 0) { + event.preventDefault(); + (current === 0 ? this.input : options[current - 1])?.focus(); + } + } + + destroy() { + document.removeEventListener('click', this.handleOutsideClick); + this.requestNumber += 1; + this.root.remove(); + this.select.hidden = false; + this.labels.forEach(label => { label.htmlFor = this.select.id; }); + } +} + +function escapeText(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/frontend/admin/js/dialog.js b/frontend/admin/js/dialog.js new file mode 100644 index 0000000..c4ab17b --- /dev/null +++ b/frontend/admin/js/dialog.js @@ -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 = ` +
+ +
+ `; + const backdrop = root.firstElementChild; + const dialog = backdrop.querySelector('.project-dialog'); + const inputElement = dialog.querySelector('[data-dialog-input]'); + + return new Promise(resolve => { + const close = result => { + document.removeEventListener('keydown', handleKeydown); + root.innerHTML = ''; + activeDialog = null; + previousFocus?.focus?.(); + resolve(result); + }; + activeDialog = { close }; + const cancel = () => close(input ? null : false); + const confirm = () => { + if (input?.required && !inputElement?.value.trim()) { + dialog.querySelector('[data-dialog-error]').textContent = input.requiredMessage || 'Заполните поле'; + inputElement?.focus(); + return; + } + close(input ? inputElement.value.trim() : true); + }; + const handleKeydown = event => { + if (event.key === 'Escape') cancel(); + if (event.key === 'Tab') trapFocus(event, dialog); + }; + document.addEventListener('keydown', handleKeydown); + dialog.querySelectorAll('[data-dialog-cancel]').forEach(button => button.addEventListener('click', cancel)); + dialog.querySelector('[data-dialog-confirm]').addEventListener('click', confirm); + backdrop.addEventListener('click', event => { + if (event.target === backdrop) cancel(); + }); + window.setTimeout(() => (inputElement || dialog.querySelector('[data-dialog-confirm]')).focus(), 0); + }); +} + +export function confirmAction(options) { + return showDialog(options); +} + +export function promptAction(options) { + return showDialog({ ...options, input: options.input || { label: 'Комментарий' } }); +} + +export function alertAction(options) { + return showDialog({ ...options, cancelLabel: null, confirmLabel: options.confirmLabel || 'Понятно' }); +} + +function trapFocus(event, root) { + const focusable = [...root.querySelectorAll('button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')]; + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} + +function escapeText(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/frontend/admin/js/dirty-state.js b/frontend/admin/js/dirty-state.js new file mode 100644 index 0000000..92c42dd --- /dev/null +++ b/frontend/admin/js/dirty-state.js @@ -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; +} diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index c368f9c..22fa11c 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -3,6 +3,8 @@ import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js'; import { initializeTelemetry } from '../../telemetry.js'; +import { confirmAbandonDirtyChanges } from './dirty-state.js'; +import { trackUxEvent } from '../../telemetry.js'; async function bootstrap() { const session = await initializeSession(); @@ -224,6 +226,7 @@ navItems.forEach(item => { async function switchTab(tab) { if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return; + if (currentTab && !(await confirmAbandonDirtyChanges())) return; // UI Update navItems.forEach(n => n.classList.remove('active')); @@ -245,6 +248,7 @@ async function switchTab(tab) { } currentTab = tab; + trackUxEvent('раздел_открыт', { tab, role: currentRole }); if (window.location.hash !== `#${tab}`) { window.location.hash = tab; } @@ -281,7 +285,7 @@ async function refreshTeacherRequestBadge(countOverride = null) { if (currentRole !== 'ADMIN') return; try { const count = countOverride === null - ? (await api.get('/api/teacher-requests?status=PENDING') || []).length + ? Number((await api.get('/api/teacher-requests/page?status=PENDING&page=0&size=1')).totalItems || 0) : countOverride; updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count)); updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count)); diff --git a/frontend/admin/js/pagination.js b/frontend/admin/js/pagination.js new file mode 100644 index 0000000..e7d0f81 --- /dev/null +++ b/frontend/admin/js/pagination.js @@ -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 = ` +
+ Показано ${start}–${end} из ${totalItems} ${escapeHtml(label)} +
+
+ + + ${totalPages ? page + 1 : 0} / ${totalPages} + +
+ `; + 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)); + }); +} diff --git a/frontend/admin/js/url-state.js b/frontend/admin/js/url-state.js new file mode 100644 index 0000000..d32bed2 --- /dev/null +++ b/frontend/admin/js/url-state.js @@ -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); + }; +} diff --git a/frontend/admin/js/view-state.js b/frontend/admin/js/view-state.js new file mode 100644 index 0000000..41cb77e --- /dev/null +++ b/frontend/admin/js/view-state.js @@ -0,0 +1,24 @@ +import { escapeHtml } from './utils.js'; + +export function renderTableLoading(tbody, columns, message = 'Загрузка данных...') { + tbody.innerHTML = `${escapeHtml(message)}`; + tbody.setAttribute('aria-busy', 'true'); +} + +export function renderTableEmpty(tbody, columns, message = 'Данных пока нет') { + tbody.innerHTML = `${escapeHtml(message)}`; + tbody.setAttribute('aria-busy', 'false'); +} + +export function renderTableError(tbody, columns, message, retry) { + tbody.innerHTML = ` + ${escapeHtml(message || 'Не удалось загрузить данные')} + ${retry ? '' : ''} + `; + tbody.setAttribute('aria-busy', 'false'); + tbody.querySelector('[data-view-retry]')?.addEventListener('click', retry); +} + +export function markTableReady(tbody) { + tbody.setAttribute('aria-busy', 'false'); +} diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 2637925..7010ebe 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -3,6 +3,9 @@ import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-in import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { academicCalendarWeekNumber } from './academic-calendar-grid.js'; import { buildAcademicCalendarTitle } from './academic-calendar-title.js'; +import { confirmAction } from '../dialog.js'; +import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js'; +import { trackUxEvent } from '../../../telemetry.js'; const DAY_OPTIONS = [ { value: 1, label: 'Понедельник', shortLabel: 'Пн' }, @@ -219,6 +222,10 @@ export async function initAcademicCalendar() { let isSyncingCalendarDayDialog = false; let calendarDayTooltip = null; let calendarDayTooltipPositionAnimation = null; + let loadedGridCalendarId = ''; + let loadedSubjectCalendarId = ''; + const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?'); + const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?'); bindEvents(); @@ -248,7 +255,10 @@ export async function initAcademicCalendar() { ].forEach(bindDateInputMask); calendarTabButtons.forEach(button => { - button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab)); + button.addEventListener('click', async () => { + if (!await confirmAbandonDirtyChanges()) return; + activateCalendarTab(button.dataset.calendarTab); + }); }); academicYearsRefreshButton?.addEventListener('click', loadYears); calendarsRefreshButton?.addEventListener('click', loadCalendars); @@ -269,6 +279,7 @@ export async function initAcademicCalendar() { if (event.target.matches('.calendar-day-activity')) { updateCalendarDayVisual(event.target.closest('.calendar-day')); renderCalendarTotals(); + markGridDirty(); } }); calendarGrid?.addEventListener('mousedown', (event) => { @@ -320,6 +331,7 @@ export async function initAcademicCalendar() { }); updateCalendarDayDialogSummaryForSelection(days); renderCalendarTotals(); + markGridDirty(); }); calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); @@ -441,7 +453,13 @@ export async function initAcademicCalendar() { } if (deleteYearButton) { - if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; + const confirmed = await confirmAction({ + title: 'Удаление учебного года', + message: 'Удалить учебный год вместе с семестрами и календарными графиками?', + confirmLabel: 'Удалить', + danger: true + }); + if (!confirmed) return; try { await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); showAlert('academic-year-alert', 'Учебный год удалён', 'success'); @@ -653,7 +671,13 @@ export async function initAcademicCalendar() { } if (deleteButton) { - if (!confirm('Удалить календарный график?')) return; + const confirmed = await confirmAction({ + title: 'Удаление календарного графика', + message: 'Удалить календарный график? Связанные настройки больше не будут доступны.', + confirmLabel: 'Удалить', + danger: true + }); + if (!confirmed) return; try { await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); @@ -691,9 +715,16 @@ export async function initAcademicCalendar() { async function loadCalendarSubjects() { hideAlert('calendar-subject-alert'); const calendar = selectedSubjectCalendar(); + if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) { + subjectCalendarSelect.value = loadedSubjectCalendarId; + refreshSelectVisuals(subjectCalendarSelect); + return; + } populateSubjectSemesterSelect(); calendarSubjectRows = []; if (!calendar) { + loadedSubjectCalendarId = ''; + clearSubjectDirty(); renderCalendarSubjects(); return; } @@ -701,6 +732,8 @@ export async function initAcademicCalendar() { try { const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); calendarSubjectRows = sortCalendarSubjectRows(rows || []); + loadedSubjectCalendarId = String(calendar.id); + clearSubjectDirty(); renderCalendarSubjects(); } catch (error) { subjectSummary.innerHTML = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; @@ -741,6 +774,7 @@ export async function initAcademicCalendar() { } ]); renderCalendarSubjects(); + markSubjectDirty(); 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); calendarSubjectRows = sortCalendarSubjectRows(saved || []); renderCalendarSubjects(); + clearSubjectDirty(); + trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length }); showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success'); } catch (error) { showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error'); @@ -774,6 +810,7 @@ export async function initAcademicCalendar() { Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId ); renderCalendarSubjects(); + markSubjectDirty(); showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success'); } @@ -820,6 +857,11 @@ export async function initAcademicCalendar() { async function loadCalendarGrid() { hideAlert('calendar-editor-alert'); const calendar = selectedEditorCalendar(); + if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) { + editorCalendarSelect.value = loadedGridCalendarId; + refreshSelectVisuals(editorCalendarSelect); + return; + } if (!calendar) { showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); return; @@ -829,6 +871,9 @@ export async function initAcademicCalendar() { const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); const gridRows = rows.length ? rows : buildDefaultGrid(calendar); renderCalendarGrid(gridRows); + loadedGridCalendarId = String(calendar.id); + clearGridDirty(); + trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 }); showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); } catch (error) { showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); @@ -856,6 +901,8 @@ export async function initAcademicCalendar() { } try { await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); + clearGridDirty(); + trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length }); showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); await loadCalendarGrid(); } catch (error) { @@ -895,9 +942,30 @@ export async function initAcademicCalendar() { } }); renderCalendarTotals(); + if (changed) markGridDirty(); showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); } + function markGridDirty() { + gridDirtyScope.mark(); + if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку •'; + } + + function clearGridDirty() { + gridDirtyScope.clear(); + if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку'; + } + + function markSubjectDirty() { + subjectDirtyScope.mark(); + if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки •'; + } + + function clearSubjectDirty() { + subjectDirtyScope.clear(); + if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки'; + } + function renderCalendarGrid(rows) { activeCalendarDay = null; selectedCalendarDayKey = null; diff --git a/frontend/admin/js/views/classrooms.js b/frontend/admin/js/views/classrooms.js index 8d5090a..4eb6a50 100755 --- a/frontend/admin/js/views/classrooms.js +++ b/frontend/admin/js/views/classrooms.js @@ -1,5 +1,6 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js'; +import { alertAction, confirmAction } from '../dialog.js'; async function fetchEquipments() { try { @@ -131,11 +132,16 @@ export async function initClassrooms() { const btnEdit = e.target.closest('.classroom-edit-action'); if (btnDelete) { - if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return; + if (!await confirmAction({ + title: 'Вывод аудитории из эксплуатации', + message: 'Историческое расписание сохранится, но аудитория перестанет участвовать в планировании.', + confirmLabel: 'Вывести из эксплуатации', + danger: true + })) return; try { await api.delete('/api/classrooms/' + btnDelete.dataset.id); loadClassrooms(); - } catch (err) { alert('Ошибка архивирования'); } + } catch (err) { await alertAction({ title: 'Ошибка архивирования', message: err.message || 'Не удалось архивировать аудиторию' }); } } const btnRestore = e.target.closest('.btn-restore-classroom'); @@ -143,7 +149,7 @@ export async function initClassrooms() { try { await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {}); loadClassrooms(); - } catch (err) { alert('Ошибка восстановления аудитории'); } + } catch (err) { await alertAction({ title: 'Ошибка восстановления', message: err.message || 'Не удалось восстановить аудиторию' }); } } if (btnToggleStatus) { @@ -152,7 +158,7 @@ export async function initClassrooms() { try { await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus }); loadClassrooms(); - } catch (err) { alert('Ошибка изменения статуса'); } + } catch (err) { await alertAction({ title: 'Ошибка изменения статуса', message: err.message || 'Не удалось изменить доступность аудитории' }); } } if (btnEdit) { @@ -178,7 +184,7 @@ export async function initClassrooms() { hideAlert('edit-classroom-alert'); modalEditClassroom.classList.add('open'); } catch (e) { - alert('Ошибка загрузки данных аудитории'); + await alertAction({ title: 'Ошибка загрузки', message: e.message || 'Не удалось загрузить данные аудитории' }); } } @@ -288,12 +294,17 @@ export async function initClassrooms() { manageEquipmentTbody.addEventListener('click', async (e) => { const btnDelete = e.target.closest('.btn-delete-equipment'); if (!btnDelete) return; - if (!confirm('Удалить оборудование из каталога?')) return; + if (!await confirmAction({ + title: 'Удаление оборудования', + message: 'Удалить оборудование из каталога?', + confirmLabel: 'Удалить', + danger: true + })) return; try { await api.delete('/api/equipments/' + btnDelete.dataset.id); await loadManageEquipments(); } catch (err) { - alert(err.message || 'Ошибка удаления'); + await alertAction({ title: 'Ошибка удаления', message: err.message || 'Не удалось удалить оборудование' }); } }); diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index b9e6150..f8505e7 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -1,6 +1,12 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { buildAcademicCalendarTitle } from './academic-calendar-title.js'; +import { AsyncCombobox } from '../async-combobox.js'; +import { alertAction, confirmAction } from '../dialog.js'; +import { renderPagination } from '../pagination.js'; +import { debounce, readUrlState, readUrlValues, writeUrlState } from '../url-state.js'; +import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js'; +import { trackUxEvent } from '../../../telemetry.js'; async function fetchEducationForms() { try { @@ -24,6 +30,10 @@ export async function initGroups() { const filterEfMenu = document.getElementById('filter-ef-menu'); const filterEfText = document.getElementById('filter-ef-text'); const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes'); + const searchInput = document.getElementById('groups-search'); + const sortSelect = document.getElementById('groups-sort'); + const filterResetButton = document.getElementById('groups-filter-reset'); + const pagination = document.getElementById('groups-pagination'); // Модалка группы const modalEditGroup = document.getElementById('modal-edit-group'); @@ -65,9 +75,19 @@ export async function initGroups() { let specialties = []; let profilesBySpecialty = new Map(); let academicYears = []; - let calendars = []; let currentAssignments = []; let activeSubgroupGroup = null; + let calendarCombobox = null; + const restored = readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' }); + const pageState = { + query: restored.groupQuery, + page: Math.max(Number(restored.groupPage) || 0, 0), + size: [25, 50, 100].includes(Number(restored.groupSize)) ? Number(restored.groupSize) : 25, + sort: restored.groupSort, + educationFormIds: readUrlValues('groupEducationForm') + }; + searchInput.value = pageState.query; + sortSelect.value = pageState.sort; bindEvents(); await loadInitialData(); @@ -89,7 +109,30 @@ export async function initGroups() { // Модалка календарей manageCalendarForm.addEventListener('submit', saveAssignment); - manageCalendarYearSelect.addEventListener('change', populateCalendarSelect); + manageCalendarYearSelect.addEventListener('change', () => { + calendarCombobox?.clear(); + populateCalendarSelect(); + }); + searchInput.addEventListener('input', debounce(() => { + pageState.query = searchInput.value.trim(); + pageState.page = 0; + loadGroups(); + })); + sortSelect.addEventListener('change', () => { + pageState.sort = sortSelect.value; + pageState.page = 0; + loadGroups(); + }); + filterResetButton.addEventListener('click', () => { + pageState.query = ''; + pageState.page = 0; + pageState.sort = 'name,asc'; + pageState.educationFormIds = []; + searchInput.value = ''; + sortSelect.value = pageState.sort; + renderFilterEfCheckboxes(educationForms); + loadGroups(); + }); groupsTbody.addEventListener('click', handleGroupTableClick); @@ -143,20 +186,19 @@ export async function initGroups() { async function loadInitialData() { try { - [educationForms, departments, specialties, academicYears, calendars] = await Promise.all([ + [educationForms, departments, specialties, academicYears] = await Promise.all([ fetchEducationForms(), api.get('/api/departments'), api.get('/api/specialties'), - api.get('/api/admin/calendar/years'), - api.get('/api/admin/academic-calendars') + api.get('/api/admin/calendar/years') ]); await loadProfiles(); populateEfSelects(educationForms); populateDepartmentSelects(departments); populateSpecialitySelects(specialties); populateYearSelect(); + initCalendarCombobox(); await loadGroups(); - await loadSubgroups(); } catch (error) { groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } @@ -171,28 +213,50 @@ export async function initGroups() { } async function loadGroups() { + renderTableLoading(groupsTbody, 10, 'Загрузка групп...'); try { - allGroups = await api.get('/api/groups?includeArchived=true'); - applyGroupFilter(); + const [sort, direction] = pageState.sort.split(','); + const params = new URLSearchParams({ + query: pageState.query, + page: String(pageState.page), + size: String(pageState.size), + sort, + direction, + includeArchived: 'true' + }); + pageState.educationFormIds.forEach(id => params.append('educationFormId', id)); + const result = await api.get(`/api/groups/page?${params}`); + allGroups = result.items || []; + if (!allGroups.length && result.totalItems > 0 && pageState.page > 0) { + pageState.page = Math.max(0, result.totalPages - 1); + return loadGroups(); + } + renderGroups(allGroups); + renderPagination(pagination, result, { + label: 'групп', + onPage: page => { pageState.page = page; loadGroups(); }, + onSize: size => { pageState.size = size; pageState.page = 0; loadGroups(); } + }); + persistPageState(); + trackUxEvent('реестр_групп_загружен', { total: Number(result.totalItems || 0), page: pageState.page }); } catch (error) { - groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + renderTableError(groupsTbody, 10, error.message || 'Не удалось загрузить группы', loadGroups); } } - async function loadSubgroups() { + async function loadSubgroups(groupId) { try { - subgroups = await api.get('/api/subgroups'); + subgroups = groupId ? await api.get(`/api/groups/${groupId}/subgroups`) : []; } catch (error) { - console.error('Не удалось загрузить подгруппы'); + subgroups = []; + throw error; } } function applyGroupFilter() { - const selectedIds = selectedFilterEducationFormIds(); - const filtered = selectedIds.length - ? allGroups.filter(group => selectedIds.includes(String(group.educationFormId))) - : allGroups; - renderGroups(filtered); + pageState.educationFormIds = selectedFilterEducationFormIds(); + pageState.page = 0; + loadGroups(); } function handleFilterEfChange(event) { @@ -247,7 +311,9 @@ export async function initGroups() { function renderGroups(groups) { if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; + renderTableEmpty(groupsTbody, 10, pageState.query || pageState.educationFormIds.length + ? 'По заданным условиям группы не найдены' + : 'Групп пока нет'); return; } groupsTbody.innerHTML = groups.map(group => ` @@ -273,6 +339,7 @@ export async function initGroups() { `).join(''); + markTableReady(groupsTbody); } function populateEfSelects(forms) { @@ -282,7 +349,7 @@ export async function initGroups() { } function renderFilterEfCheckboxes(forms) { - const selectedIds = new Set(selectedFilterEducationFormIds()); + const selectedIds = new Set(pageState.educationFormIds); const allChecked = !selectedIds.size; filterEfCheckboxes.innerHTML = `