diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..2ef8f23 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,21 @@ +#:schema https://developers.openai.com/codex/config-schema.json + +web_search = "live" + +developer_instructions = """ +Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент. +Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов. +При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня. +Используй проектные скиллы из .agents/skills, когда задача соответствует их description. +""" + +[skills] +include_instructions = true + +[[skills.config]] +path = "../.agents/skills/auto-update-docs" +enabled = true + +[[skills.config]] +path = "../.agents/skills/frontend-design" +enabled = true diff --git a/AGENTS.md b/AGENTS.md index 2d3d4b2..4686c47 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,8 @@ magistr/ ├── frontend/ # Статические файлы │ ├── admin/ # Интерфейс администратора │ │ └── settings/ # Страница настроек (отдельный SPA) +│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA +│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA │ ├── teacher/ # Интерфейс преподавателя │ └── student/ # Интерфейс студента ├── docs/ # 📖 Документация проекта diff --git a/SCHEDULE_PROPOSAL.md b/SCHEDULE_PROPOSAL.md deleted file mode 100644 index 3b8c6e0..0000000 --- a/SCHEDULE_PROPOSAL.md +++ /dev/null @@ -1,178 +0,0 @@ -# Концепция динамической генерации расписания - -Данный документ представляет собой подробное архитектурное описание новой системы управления расписанием. Система переходит от статического хранения каждой отдельной пары к параметрическому: мы сохраняем **правила проведения** дисциплины и **календарную сетку**, а фактическое расписание на любую дату вычисляется «на лету» (генерируется). - -> **Контекст миграции:** Новая система полностью заменяет существующие таблицы `lessons` (статическое расписание) и `schedule_data` (плановая нагрузка). Обе таблицы будут мигрированы в единую модель `schedule_rules` + `schedule_rule_slots`, которая совмещает хранение нагрузки (часы) и расписания (слоты) в одной структуре. - ---- - -## 1. Подробное описание компонентов системы - -Новая архитектура строится на строгом разделении данных на три логических слоя: Календарь (основа отсчета времени), Правила (шаблоны занятий) и Генератор (движок рендеринга фактического расписания). - -### 1.1 Справочная база времени (Календарный учебный график) -Чтобы система понимала, *когда* можно ставить пары, а когда нет, вводится понятие календарного графика. Он состоит из трёх взаимосвязанных сущностей: - -* **Академические периоды (Учебные года и Семестры).** Иерархия из двух уровней: - * **Учебный год** — контейнер с названием и датами (напр. «2024/2025», `01.09.2024` — `30.06.2025`). - * **Семестр** — дочерняя сущность учебного года. Содержит дату начала, от которой отсчитывается «Неделя 1» данного семестра. Нумерация недель начинается заново для каждого семестра. Тип семестра (`autumn` / `spring`) определяет, какой набор правил активен. - - Именно от даты начала семестра отсчитывается «Неделя 1». Конвенция чётности (верхняя = чётная или нечётная) **настраивается на уровне тенанта**, так как у разных университетов разные традиции. Это избавляет систему от уязвимостей, связанных с плавающими днями начала учёбы, високосными годами и смещениями дней недели. - -* **Справочник исключений (Праздники и Выходные).** В этой таблице хранятся конкретные даты `YYYY-MM-DD`, когда университет юридически или физически закрыт (например, государственные праздники). Если по правилу пара должна быть в этот день, алгоритм будет знать, что его нужно пропустить без штрафов и ошибок. - -* **Матрица учебного графика.** Это цифровая копия эксель-таблицы (`Курс + Специальность` → `Номер недели` → `Тип деятельности`). Привязка идёт к `course_number` + `specialty_id`, а **не** к конкретной группе, так как учебный график одинаков для всех групп одного курса одной специальности. Номер текущего курса группы вычисляется из поля `year_start_study` модели `StudentGroup` относительно текущей даты по формуле: `course = текущий_учебный_год - year_start_study + 1`. Типы деятельности включают `THEORY` (Теория, пары идут в штатном режиме), `EXAM` (Э — экзаменационная сессия), `VACATION` (К — каникулы), `PRACTICE` (У, П — практика). Если, например, у 3-го курса на 18-й неделе стоит статус `EXAM`, алгоритм даже не будет пытаться генерировать для них теоретические лекции, а отобразит блок «Экзаменационная сессия». - -### 1.2 Справочник временных слотов (Time Slots) -Вместо хардкода фиксированных 7 пар, система хранит временные слоты в отдельной **настраиваемой таблице**. Каждый тенант (университет) может иметь собственное количество пар, их длительность и временные рамки. - -Слот содержит: -* `order_number` — порядковый номер пары в дне (1, 2, 3...). -* `start_time` — время начала (напр. `08:00`). -* `end_time` — время окончания (напр. `09:30`). -* `duration_minutes` — длительность пары в минутах. - -Это позволяет каждому университету настраивать количество и продолжительность пар без модификации кода. - -### 1.3 Движок правил (Schedule Rules) -Старый подход подразумевал, что каждая пара в базе (каждая клеточка) — это изолированная запись `lessons` («понедельник, 1-я пара, математика»). Новая система вводит сущность сводного **Правила Дисциплины**. Одно правило описывает расписание целого курса по конкретному предмету для одной или нескольких студенческих групп (включая потоковые лекции). - -**Базовые параметры (Лимиты Правила):** -* `subject_id` — ID преподаваемой дисциплины. -* `semester_id` — ID семестра, к которому привязано правило. Одна и та же дисциплина может читаться в разных семестрах с разными параметрами. -* `startDate` — Дата или номер недели семестра, с которой предмет начинает читаться (поскольку не все предметы идут строго с 1-й недели семестра). -* `totalHours` — Полный объём выделенных **академических часов** (1 ак. час = 45 минут; одна пара = 2 ак. часа). Это важнейший **лимитатор**, который обеспечивает автоматическую остановку генерации: как только заявленные часы будут вычитаны, предмет перестает отображаться в расписании студентов на последующих неделях. - -**Связь с группами (Many-to-Many):** -Одно правило может быть связано с несколькими группами через промежуточную таблицу `schedule_rule_groups`. Это обеспечивает поддержку **потоковых лекций** — когда один преподаватель читает лекцию нескольким группам одновременно в одной аудитории. При этом правило создаётся один раз, а группы к нему привязываются списком. - -**Массив паттернов (Слоты правила):** -Само «тело» правила разбивается на подчинённые слоты. Если предмет идёт в Пн и Ср, это будет 2 слота внутри одного Правила. Слот содержит: -* `dayOfWeek`: день недели (1–7, Пн–Вс). -* `parity`: тип четности — `ENUM('BOTH', 'EVEN', 'ODD')`. `BOTH` — каждую неделю, `EVEN` — по чётным (нижним) неделям, `ODD` — по нечётным (верхним). Конкретное соответствие «чётная = верхняя или нижняя» определяется настройкой тенанта. -* `time_slot_id`: FK на таблицу `time_slots` — порядковый номер и время пары. -* `subgroup_id`: FK на подгруппу (NULL = вся группа). *Это гарантирует, что мы сможем ставить разным подгруппам пересекающиеся занятия в разных аудиториях без алгоритмических конфликтов.* -* `teacher_id`: FK на преподавателя слота. -* `classroom_id`: FK на аудиторию слота. -* `lesson_type_id`: FK на тип занятия (`Лекция`, `Практическая работа`, `Лабораторная работа`). -* `lesson_format`: формат проведения (`Очно` / `Онлайн`). - -> **Обоснование:** Хранение `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` в **слотах**, а не в главном правиле, позволяет гибко описывать ситуации вроде: лекции в понедельник читает лектор Иванов (Аудитория 100), а лабораторные в среду ведёт практик Петров (Аудитория 102В) — в рамках одного правила по предмету «Программирование», расходуя общий `totalHours`. - -### 1.4 Генератор (Рендерер) расписания -Это слой бизнес-логики (служба `ScheduleGeneratorService` в Java), который работает исключительно в оперативной памяти бэкенда и производит расчёт расписания «on-demand» (по требованию) при запросе от клиента фронтенда. - -**Пошаговый алгоритм работы генератора:** -1. Фронтенд (Интерфейс пользователя) запрашивает: *«Дай мне расписание группы ИТ-21 на конкретный период, например, с 14 октября по 20 октября»*. -2. Генератор определяет семестр по запрошенным датам и вычисляет, что 14 октября соответствует, к примеру, 7-й неделе семестра (вычисление от `startDate` семестра). -3. Он сверяется с *Матрицей учебного графика*. Для этого генератор определяет текущий курс группы по формуле `текущий_учебный_год - year_start_study + 1` и находит `specialty_id` группы. Если у данного курса/специальности сейчас стоит `VACATION` (Каникулы) или `PRACTICE` (Практика), генератор сразу возвращает пустой ответ или ответ со статусом периода. -4. Если статус недели позволяет проводить занятия (`THEORY`), генератор поднимает из Базы Данных все активные **Правила** для запрошенной группы (через таблицу `schedule_rule_groups`), привязанные к текущему семестру. -5. **Механика Лимитатора часов:** Для каждого правила алгоритм «симулирует» прогон времени с даты старта правила до текущей запрошенной недели. Он подсчитывает количество успешно проведённых ак. часов (по 2 ак. часа за каждый отработанный слот), пропуская даты, попавшие в справочник праздников, и недели с типом деятельности отличным от `THEORY`. -6. Если у правила лимит `totalHours` достиг значения `0`, программа понимает, что курс вычитан, и предмет не отображается. Если часы ещё остались, алгоритм проецирует шаблоны (слоты правила) на запрошенную текущую неделю с учётом чётности, аудиторий и подгрупп, отдавая готовый JSON-массив в браузер пользователя. - -**Генерация расписания для преподавателя:** -Аналогричный алгоритм, но поиск правил идёт не по привязке к группе, а по `teacher_id` в слотах. Генератор собирает все `schedule_rule_slots`, где `teacher_id` = ID текущего преподавателя, получает родительские правила и рендерит расписание, обогащая каждую запись списком групп из `schedule_rule_groups`. - -**Кеширование:** -Для оптимизации производительности (т.к. симуляция прогона за весь семестр для каждого запроса ресурсоёмка) предусмотрен кеш: -* Список праздников текущего учебного года кешируется при первом обращении и инвалидируется при изменении таблицы `holidays`. -* Матрица учебного графика кешируется по ключу `(course, specialty_id, semester_id)`. -* Результаты подсчёта `consumed_hours` для каждого правила могут кешироваться с инвалидацией при изменении праздников или правил. - ---- - -## 2. Архитектурные Решения - -На основе обсуждений были задокументированы следующие концептуальные решения по архитектуре: - -1. **Реакция на праздники (Продление курса):** - Алгоритм воспринимает праздник как «пропуск хода», не отнимая проведённые часы от `totalHours`. Это означает, что пара **не переносится** на другой день или время — она просто пропускается без вычета часов. Фактически предмет будет отображаться в расписании дольше (больше недель), пока `totalHours` не будет полностью исчерпан. Преподаватель честно выработает положенный объём часов за счёт увеличения количества недель преподавания. - -2. **Нормализация через связанные таблицы:** - Мы не используем сырые массивы (`INTEGER[]`) или JSONB-колонки. Реализована структура со строгой нормализацией: - * Главная таблица: `schedule_rules` (хранит лимиты и дату старта). - * Подчинённая таблица: `schedule_rule_slots` (хранит конкретный день, чётность, номер пары, преподавателя, аудиторию, тип и формат — прикреплённые к ID главного правила через Foreign Key). - * Связующая таблица: `schedule_rule_groups` (Many-to-Many между правилом и группами). - Это позволяет базе данных строить сложные выборки в стиле «Покажи загруженность кабинета №21 во вторник на второй паре по чётным неделям», исключая тяжёлый парсинг JSON. - -3. **Поддержка подгрупп внутри слотов:** - В таблицу `schedule_rule_slots` введено поле `subgroup_id` (Id подгруппы, nullable). Алгоритм генератора сможет рендерить два предмета для одной группы одновременно и без конфликтов, если они ассоциированы с разными подгруппами одной материнской группы. - -4. **Обогащённые слоты (Вариант Б):** - `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` хранятся в каждой строке `schedule_rule_slots`, а не в главном правиле. Это позволяет описывать лекции и практики одного предмета в рамках одного правила, расходуя общий `totalHours`. - -5. **Потоковые лекции через Many-to-Many:** - Одно правило связывается с несколькими группами через `schedule_rule_groups`. Для потоковой лекции создаётся одно правило, к которому привязываются все участвующие группы. - -6. **Настраиваемость по тенантам:** - Архитектурно все тенанты одинаковы — каждый университет получает идентичную пустую базу данных. Временные слоты (количество, длительность, время начала/окончания пар), конвенция чётности и прочие параметры не требуют специального механизма: каждый университет просто заполняет свою БД самостоятельно через панель администратора. - ---- - -## 3. Подробный План Действий по Реализации - -Интеграция новой архитектуры затронет весь стек приложения (DB → Backend → API → Frontend). Работу предлагается вести строго поэтапно: - -### Этап 1. База Данных (Flyway Миграции) - -**Схема Временных слотов:** -* `time_slots` (id, order_number, start_time TIME, end_time TIME, duration_minutes INT). - * Заполняется администратором. Нет фиксированных значений — каждый тенант настраивает свою сетку пар. - -**Схема Календарного графика:** -* `academic_years` (id, title VARCHAR, start_date DATE, end_date DATE). -* `semesters` (id, academic_year_id FK, semester_type ENUM('autumn','spring'), start_date DATE, end_date DATE). - * Именно от `semesters.start_date` отсчитывается «Неделя 1». -* `holidays` (id, date DATE, academic_year_id FK, description VARCHAR). -* `academic_calendar_matrix` (id, semester_id FK, course_number INT, specialty_id FK, week_number INT, activity_type ENUM('THEORY','EXAM','VACATION','PRACTICE')). - * Привязка к `course_number` + `specialty_id`, а НЕ к конкретной группе. - -**Схема Движка Правил:** -* `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date DATE, total_academic_hours INT). - * `total_academic_hours` — в академических часах (1 ак. час = 45 мин, одна пара = 2 ак. часа). -* `schedule_rule_groups` (schedule_rule_id FK, group_id FK) — PK составной. - * Связующая таблица для потоковых лекций. -* `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week INT CHECK(1–7), parity ENUM('BOTH','EVEN','ODD'), time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format VARCHAR). - -**Скрипт Миграции (Data ETL):** Написание SQL/Java скрипта для миграции данных из двух источников: -1. **Из `schedule_data`** → `schedule_rules` + `schedule_rule_groups`: перенос плановой нагрузки (`number_of_hours` → `total_academic_hours`, `group_id`, `subjects_id`, `teacher_id`, `lesson_type_id`, `is_division`, `semester_type`, `period`). -2. **Из `lessons`** → `schedule_rule_slots`: перенос расписания с трансформацией данных: - * `day` (строка «Понедельник»...«Суббота») → `day_of_week` (INT 1–6). - * `time` (строка «8:00 - 9:30») → `time_slot_id` (FK на `time_slots`). - * `week` (строка «Верхняя»/«Нижняя»/«Обе») → `parity` (ENUM `ODD`/`EVEN`/`BOTH`). - * Группировка записей с одинаковым `(subject_id, group_id)` в одно правило. - -После успешной миграции и верификации данных — удаление таблиц `lessons` и `schedule_data`. - -### Этап 2. Бэкенд и Вычислительное Ядро (Java + Spring Boot) -* `AcademicDateService.java` — сервис утилит для календарной математики: - * Перевод дат в номер недели семестра. - * Определение чётности недели с учётом настройки тенанта. - * Проверка попадания дня в справочник `holidays`. - * Вычисление текущего курса группы: `текущий_учебный_год - year_start_study + 1`. -* `ScheduleRuleRepository.java` — JPA репозитории для извлечения графа правил из базы данных, с оптимизацией N+1 проблемы через `JOIN FETCH` со слотами и группами. -* `ScheduleGeneratorService.java` — Сердце системы. Основные методы: - * `List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate)` — расписание группы. - * `List buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate)` — расписание преподавателя (поиск по `teacher_id` в слотах, обогащение информацией о группах). - * Реализует всю бизнес-логику из пункта 1.4 (подсчёт вычитанных часов, пропуск праздников, кеширование). -* Адаптация валидаторов пересечения аудиторий: теперь валидатор должен работать не на уровне «каждой пары», а симулировать весь семестр на этапе сохранения нового Правила в панели администратора. - -### Этап 3. Обновление REST API (Контроллеры) -* **Новый эндпоинт расписания:** `GET /api/schedule` переходит на диапазонную модель. Параметры: `?groupId=123&startDate=2024-10-14&endDate=2024-10-20` или `?teacherId=456&startDate=...&endDate=...`. Ответ — массив объектов с полными датами `YYYY-MM-DD`. -* **Обратная совместимость:** Старый эндпоинт `GET /api/users/lessons` будет помечен как `@Deprecated` и продолжит работать до полной миграции фронтенда. После завершения миграции фронтенда — удаление. -* **CRUD-контроллеры для админки:** - * `/api/admin/time-slots` (настройка сетки временных слотов). - * `/api/admin/calendar/years` (учебные годы и семестры). - * `/api/admin/calendar/matrix` (настройка каникул и сессий по курсам/специальностям/неделям). - * `/api/admin/calendar/holidays` (добавление исключений). - * `/api/admin/schedule-rules` (управление жизненным циклом Правил, их слотами и привязкой к группам). - -### Этап 4. Интерфейсы Frontend (Vanilla JS + HTML) -* **Страницы просмотра (Студенты и Преподаватели):** - * Реализация переключателя календарных дат (Date Picker или кнопки-перелистывания недель). - * Логика, которая при свайпе или клике запрашивает у API конкретный диапазон дат и перерисовывает DOM-дерево. - * Для преподавателей — отображение всех групп, привязанных к каждому занятию. -* **Панель Администратора (SPA-интерфейсы):** - * **Вкладка «Временные слоты»:** Настройка сетки пар — количество, время начала/окончания, длительность. - * **Вкладка «Учебный график»:** Визуальная сетка-матрица (недели по горизонтали, Курсы/Специальности по вертикали), где админ может закрашивать пересечения разными цветами, назначая статусы (Практика, Каникулы, Теория, Экзамены). - * **Вкладка «Конструктор Правил»:** Глобально новый визуальный инструмент расписания. Админ выбирает Группы (одну или несколько для потока) и Дисциплину, задаёт `totalHours` в академических часах, а затем динамически добавляет строчки массива слотов через кнопку «Добавить занятие» со списками (Selects) для Дня Недели, Временного слота, Подгруппы, Чётности, Преподавателя, Аудитории и Типа занятия. diff --git a/SCHEDULE_TASKS.md b/SCHEDULE_TASKS.md deleted file mode 100644 index 30c600d..0000000 --- a/SCHEDULE_TASKS.md +++ /dev/null @@ -1,218 +0,0 @@ -# 📋 Задачи: Динамическая генерация расписания - -> Декомпозиция [`SCHEDULE_PROPOSAL.md`](SCHEDULE_PROPOSAL.md) на подзадачи для доски планирования. -> Категории: **Backend**, **Frontend**, **DevOps/DB** - ---- - -## DevOps / Database - -### Flyway-миграция: Временные слоты -- [ ] Создать миграцию: таблица `time_slots` (id, order_number, start_time, end_time, duration_minutes) -- [ ] Добавить CHECK-ограничения (start_time < end_time, duration_minutes > 0, order_number > 0) - ---- - -### Flyway-миграция: Учебные годы и семестры -- [ ] Создать миграцию: таблица `academic_years` (id, title, start_date, end_date) -- [ ] Создать миграцию: таблица `semesters` (id, academic_year_id FK, semester_type ENUM, start_date, end_date) -- [ ] Добавить CHECK-ограничения и индексы - ---- - -### Flyway-миграция: Праздники -- [ ] Создать миграцию: таблица `holidays` (id, date, academic_year_id FK, description) -- [ ] Добавить уникальный индекс на (date, academic_year_id) - ---- - -### Flyway-миграция: Матрица учебного графика -- [ ] Создать миграцию: таблица `academic_calendar_matrix` (id, semester_id FK, course_number, specialty_id FK, week_number, activity_type ENUM) -- [ ] Добавить ENUM: `THEORY`, `EXAM`, `VACATION`, `PRACTICE` -- [ ] Добавить уникальный индекс на (semester_id, course_number, specialty_id, week_number) - ---- - -### Flyway-миграция: Правила расписания -- [ ] Создать миграцию: таблица `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date, total_academic_hours) -- [ ] Создать миграцию: связующая таблица `schedule_rule_groups` (schedule_rule_id FK, group_id FK, PK составной) -- [ ] Создать миграцию: таблица `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week, parity ENUM, time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format) -- [ ] Добавить CHECK на day_of_week (1–7) -- [ ] Добавить ENUM: `BOTH`, `EVEN`, `ODD` - ---- - -### ETL-миграция данных -- [ ] Написать SQL/Java скрипт миграции `schedule_data` → `schedule_rules` + `schedule_rule_groups` - - [ ] Маппинг `number_of_hours` → `total_academic_hours` - - [ ] Маппинг привязок групп -- [ ] Написать SQL/Java скрипт миграции `lessons` → `schedule_rule_slots` - - [ ] Трансформация `day` (строка) → `day_of_week` (INT 1–6) - - [ ] Трансформация `time` (строка) → `time_slot_id` (FK) - - [ ] Трансформация `week` (строка) → `parity` (ENUM) - - [ ] Группировка записей с одинаковым (subject_id, group_id) в одно правило -- [ ] Верификация мигрированных данных (количество записей, целостность FK) -- [ ] Создать миграцию на удаление устаревших таблиц `lessons` и `schedule_data` (после верификации) - ---- - -## Backend (Java + Spring Boot) - -### JPA-сущности (Model) -- [ ] Создать Entity: `TimeSlot` -- [ ] Создать Entity: `AcademicYear` -- [ ] Создать Entity: `Semester` (связь ManyToOne → AcademicYear) -- [ ] Создать Entity: `Holiday` (связь ManyToOne → AcademicYear) -- [ ] Создать Entity: `AcademicCalendarMatrix` (связи на Semester, Specialty) -- [ ] Создать Entity: `ScheduleRule` (связи на Subject, Semester) -- [ ] Создать Entity: `ScheduleRuleSlot` (связи на ScheduleRule, TimeSlot, Teacher, Classroom, LessonType) -- [ ] Настроить ManyToMany-связь ScheduleRule ↔ StudentGroup через `schedule_rule_groups` - ---- - -### DTO -- [ ] Создать DTO: `TimeSlotDto` -- [ ] Создать DTO: `AcademicYearDto`, `SemesterDto` -- [ ] Создать DTO: `HolidayDto` -- [ ] Создать DTO: `AcademicCalendarMatrixDto` -- [ ] Создать DTO: `ScheduleRuleDto`, `ScheduleRuleSlotDto` -- [ ] Создать DTO: `RenderedLessonDto` (ответ генератора расписания) - ---- - -### Repository -- [ ] Создать `TimeSlotRepository` -- [ ] Создать `AcademicYearRepository` -- [ ] Создать `SemesterRepository` (метод findByDateRange) -- [ ] Создать `HolidayRepository` (метод findByAcademicYearId) -- [ ] Создать `AcademicCalendarMatrixRepository` (метод findBySemesterAndCourseAndSpecialty) -- [ ] Создать `ScheduleRuleRepository` с JOIN FETCH (решение N+1 проблемы) - - [ ] Метод: findByGroupIdAndSemesterId (через schedule_rule_groups) - - [ ] Метод: findByTeacherIdAndSemesterId (через schedule_rule_slots.teacher_id) - ---- - -### Сервис: AcademicDateService -- [ ] Метод: перевод произвольной даты → номер недели семестра -- [ ] Метод: определение чётности недели с учётом настройки тенанта -- [ ] Метод: проверка попадания даты в справочник `holidays` -- [ ] Метод: вычисление текущего курса группы (`текущий_учебный_год - year_start_study + 1`) -- [ ] Метод: определение семестра по дате -- [ ] Написать юнит-тесты для AcademicDateService - ---- - -### Сервис: ScheduleGeneratorService -- [ ] Метод: `buildScheduleForGroup(groupId, startDate, endDate)` — расписание группы - - [ ] Определение семестра по диапазону дат - - [ ] Вычисление номера недели и курса группы - - [ ] Проверка типа деятельности через матрицу графика - - [ ] Загрузка активных правил для группы - - [ ] Симуляция прогона часов (подсчёт consumed_hours) - - [ ] Пропуск праздников при подсчёте часов - - [ ] Проекция слотов на запрошенную неделю с учётом чётности и подгрупп -- [ ] Метод: `buildScheduleForTeacher(teacherId, startDate, endDate)` — расписание преподавателя - - [ ] Поиск правил по teacher_id в слотах - - [ ] Обогащение ответа списком групп из schedule_rule_groups -- [ ] Написать юнит-тесты для ScheduleGeneratorService -- [ ] Написать интеграционные тесты (полный цикл с тестовой БД) - ---- - -### Кеширование -- [ ] Реализовать кеш списка праздников по учебному году -- [ ] Реализовать кеш матрицы учебного графика по ключу (course, specialty_id, semester_id) -- [ ] Реализовать кеш consumed_hours для каждого правила -- [ ] Реализовать инвалидацию кеша праздников при CRUD-операциях с holidays -- [ ] Реализовать инвалидацию кеша consumed_hours при изменении правил или праздников - ---- - -### Валидация -- [ ] Адаптировать валидатор пересечения аудиторий (симуляция всего семестра при сохранении правила) -- [ ] Валидация пересечения преподавателей (один преподаватель не может вести две пары одновременно) -- [ ] Валидация пересечения групп (одна группа не может быть на двух занятиях одновременно, кроме подгрупп) - ---- - -### REST API: Контроллеры -- [ ] `GET /api/schedule` — Новый эндпоинт расписания (параметры: groupId/teacherId + startDate + endDate) -- [ ] Пометить `GET /api/users/lessons` как `@Deprecated` (обратная совместимость) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/time-slots` -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/years` -- [ ] CRUD: `GET/PUT /api/admin/calendar/semesters` (вложены в years) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/holidays` -- [ ] CRUD: `GET/PUT /api/admin/calendar/matrix` (массовое сохранение матрицы) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/schedule-rules` - - [ ] Включая вложенные слоты и привязку групп -- [ ] Написать интеграционные тесты для API - ---- - -### Удаление устаревшего кода -- [ ] Удалить/рефакторить старый `LessonsController` (после миграции фронтенда) -- [ ] Удалить/рефакторить старый `ScheduleDataController` -- [ ] Удалить старые Entity: `Lesson`, `ScheduleData` -- [ ] Удалить старые Repository и Service для lessons/schedule_data - ---- - -## Frontend (Vanilla JS + HTML/CSS) - -### Просмотр расписания: Студенты -- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) -- [ ] Переключить API-запросы на новый `GET /api/schedule?groupId=...&startDate=...&endDate=...` -- [ ] Рендеринг расписания по дням и временным слотам -- [ ] Отображение статуса периода (Каникулы / Практика / Экзамены), если неделя не учебная -- [ ] Отображение информации о подгруппах (два занятия рядом для разных подгрупп) - ---- - -### Просмотр расписания: Преподаватели -- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) -- [ ] Переключить API-запросы на новый `GET /api/schedule?teacherId=...&startDate=...&endDate=...` -- [ ] Отображение всех групп, привязанных к каждому занятию -- [ ] Отображение подгрупп, если преподаватель ведёт у подгруппы - ---- - -### Панель администратора: Вкладка «Временные слоты» -- [ ] Создать UI-страницу настройки временных слотов -- [ ] CRUD-интерфейс: добавление/редактирование/удаление пар -- [ ] Отображение таблицы: номер пары → время начала → время окончания → длительность -- [ ] Валидация на фронтенде (пересечение времён, корректность данных) - ---- - -### Панель администратора: Вкладка «Учебный график» -- [ ] Создать UI: выбор учебного года и семестра -- [ ] Создать UI: CRUD учебных годов и семестров -- [ ] Создать UI: CRUD праздников (список дат с описанием) -- [ ] Создать визуальную сетку-матрицу: - - [ ] Горизонтальная ось — номера недель - - [ ] Вертикальная ось — Курс + Специальность - - [ ] Цветовая кодировка ячеек: Теория/Экзамены/Каникулы/Практика - - [ ] Клик/драг для массового назначения статуса -- [ ] Сохранение матрицы через API `PUT /api/admin/calendar/matrix` - ---- - -### Панель администратора: Вкладка «Конструктор Правил» -- [ ] Создать UI: список существующих правил с фильтрацией (по группе, предмету, семестру) -- [ ] Форма создания/редактирования правила: - - [ ] Мультиселект групп (для потоковых лекций) - - [ ] Выбор дисциплины (subject) - - [ ] Выбор семестра - - [ ] Ввод totalHours (академические часы) - - [ ] Ввод даты начала (active_from_date) -- [ ] Динамический массив слотов (кнопка «Добавить занятие»): - - [ ] Select: День недели - - [ ] Select: Временной слот (из таблицы time_slots) - - [ ] Select: Чётность (Обе/Чётная/Нечётная) - - [ ] Select: Подгруппа (опционально) - - [ ] Select: Преподаватель - - [ ] Select: Аудитория - - [ ] Select: Тип занятия (Лекция/Практика/Лаба) - - [ ] Select: Формат (Очно/Онлайн) -- [ ] Визуальное предупреждение при конфликтах (аудитория/преподаватель уже заняты) -- [ ] Удаление правила с подтверждением diff --git a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..68dfc2d --- /dev/null +++ b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,248 @@ +# Отчёт об изменениях по задачам научного руководителя + +Дата: 2026-05-19 + +## Краткий итог + +Выполнен первый цельный релиз архитектурной основы: + +- добавлены роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; +- добавлена backend-проверка bearer-токена и ролей через `AuthorizationInterceptor` и `@RequireRoles`; +- добавлен жизненный цикл справочников: `ACTIVE` / `ARCHIVED`; +- физическое удаление ключевых сущностей заменено архивированием там, где это влияет на историю; +- добавлена история переводов преподавателей между кафедрами; +- добавлены точечные изменения расписания: перенос, отмена, замена; +- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности; +- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям; +- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям; +- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр; +- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки; +- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`; +- обновлена документация проекта. + +## Backend + +### Роли и авторизация + +Поддерживаются роли: + +- `ADMIN`; +- `EDUCATION_OFFICE`; +- `DEPARTMENT`; +- `SCHEDULE_VIEWER`; +- `TEACHER`; +- `STUDENT`. + +Добавлены: + +- `AuthSessionService` — in-memory UUID-сессии; +- `AuthContext` — текущий пользователь в `ThreadLocal`; +- `AuthorizationInterceptor` — проверка `Authorization: Bearer ...`; +- `@RequireRoles` — ограничение доступа на уровне контроллеров и методов; +- `GET /api/auth/me` — текущий пользователь по токену. + +Redirect после входа: + +- `ADMIN` -> `/admin/`; +- `EDUCATION_OFFICE` -> `/admin/#schedule-view`; +- `DEPARTMENT` -> `/admin/#department-workspace`; +- `SCHEDULE_VIEWER` -> `/admin/#schedule-view`; +- `TEACHER` -> `/teacher/`; +- `STUDENT` -> `/student/`. + +### Миграция БД + +Отдельная миграция `V2__roles_lifecycle_temporal_schedule.sql` удалена по текущему решению проекта. + +Все изменения схемы внесены в: + +```text +backend/src/main/resources/db/migration/V1__init.sql +``` + +`V1__init.sql` теперь сразу создаёт: + +- lifecycle-поля к справочникам и пользователям; +- `teacher_department_assignments`; +- `subject_comments`; +- version-поля к `schedule_rules`; +- lock-поля к `schedule_rule_slots`; +- `schedule_overrides`; +- стартовых пользователей `учебный_отдел`, `кафедра_иб` и `просмотр_расписаний`. + +### Архивирование вместо удаления + +Архивирование добавлено для пользователей, аудиторий, оборудования, кафедр, специальностей, групп, подгрупп, дисциплин и правил расписания. + +Архивные аудитории, преподаватели, группы и дисциплины запрещены для новых назначений. Историческое расписание при этом не теряет ссылки. + +### История кафедр преподавателя + +Добавлены endpoint: + +- `GET /api/users/{id}/department-history`; +- `POST /api/users/{id}/department-transfer`; +- `GET /api/users/teachers/by-department/{departmentId}?date=YYYY-MM-DD`. + +При переводе преподавателя старая запись закрывается, новая открывается с `valid_from`, а `users.department_id` обновляется как текущая кафедра. + +### Расписание и загруженность + +Добавлены: + +- `GET /api/schedule/search`; +- `GET /api/workload/teachers`; +- `GET /api/workload/classrooms`; +- `GET /api/workload/departments`; +- `GET /api/workload/time-slots`; +- `GET /api/workload/free-classrooms`; +- `GET/POST/PUT/DELETE /api/edu-office/schedule/overrides`. + +`GET /api/schedule` теперь использует общий слой поиска, поэтому учитывает точечные изменения расписания. + +Роль `SCHEDULE_VIEWER` имеет доступ только к read-only просмотру расписаний и справочникам-фильтрам. + +### Кабинет кафедры API + +Добавлены: + +- `GET /api/department/subjects`; +- `POST /api/department/subjects/import`; +- `GET /api/department/subjects/{subjectId}/comments`; +- `POST /api/department/subjects/{subjectId}/comments`; +- `GET /api/department/teachers`; +- `GET /api/department/schedule`. + +Для роли `DEPARTMENT` кафедра берётся из текущего пользователя. Для администратора можно выбрать кафедру в UI. + +## Frontend + +### Единая панель по ролям + +Отдельные интерфейсы `/department/` и `/edu-office/` заменены на redirect в общую админ-панель: + +- `/department/` -> `/admin/#department-workspace`; +- `/edu-office/` -> `/admin/#schedule-view`. + +В `frontend/admin/js/main.js` добавлена фильтрация вкладок: + +- `ADMIN` видит все вкладки; +- `EDUCATION_OFFICE` видит просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории и оборудование; +- `DEPARTMENT` видит кабинет кафедры и просмотр расписаний; +- `SCHEDULE_VIEWER` видит только просмотр расписаний. + +Backend не опирается только на скрытие вкладок. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, а `@RequireRoles` ограничивает операции на контроллерах и методах. Для кафедры дополнительно закрыты обходные пути: + +- общий `/api/subjects` оставлен для записи только администратору, кафедра загружает дисциплины через scoped `/api/department/subjects/import`; +- `/api/teacher-subjects` для роли `DEPARTMENT` проверяет, что и преподаватель, и дисциплина относятся к кафедре текущего пользователя; +- `SCHEDULE_VIEWER` имеет только read-only доступ к расписаниям и справочникам-фильтрам. + +После браузерной проверки исправлено визуальное скрытие недоступных вкладок: `layout.css` теперь принудительно скрывает `.nav-item[hidden]` и скрытые пункты меню настроек, поэтому роли больше не видят чужие вкладки в sidebar. + +### Просмотр расписаний + +Добавлена вкладка: + +```text +frontend/admin/views/schedule-view.html +frontend/admin/js/views/schedule-view.js +``` + +Возможности: + +- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности; +- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели; +- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям; +- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели; +- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку; +- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота. + +### Загруженность + +Вкладка `auditorium-workload` оставлена техническим tab id, но в UI называется `Загруженность`. + +Возможности: + +- выбор типа загруженности: аудитории, преподаватели или кафедры; +- сводная матрица по выбранной дате: строки — выбранные сущности, столбцы — пары; +- кафедральная матрица группирует занятия по кафедре преподавателя; +- для аудиторий сохранены фильтры корпуса, вместимости и оборудования; +- выбор конкретной аудитории, преподавателя или кафедры заменяет обзор двухнедельной таблицей по дням недели и времени; +- чётная и нечётная недели показываются в одной ячейке: если состояние одинаковое, ячейка цельная, если отличается — делится вертикально. + +### Кабинет кафедры + +Добавлена вкладка: + +```text +frontend/admin/views/department-workspace.html +frontend/admin/js/views/department-workspace.js +``` + +Возможности: + +- просмотр дисциплин кафедры; +- загрузка дисциплин из списка `код; название`; +- просмотр и добавление комментариев к дисциплинам; +- просмотр преподавателей кафедры; +- просмотр нагрузки преподавателей кафедры за период. + +### Админка + +Обновлено: + +- создание пользователей теперь поддерживает роли `DEPARTMENT`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`; +- удаление пользователей заменено на архивирование; +- вкладка аудиторий показывает архивные записи и умеет восстанавливать аудиторию; +- удаление аудитории заменено на вывод из эксплуатации; +- настройки временных слотов доступны `ADMIN` и `EDUCATION_OFFICE`. + +## Документация + +Обновлены: + +- `AGENTS.md`; +- `docs/README.md`; +- `docs/API.md`; +- `docs/DATABASE.md`; +- `docs/BUSINESS_LOGIC.md`; +- `docs/FRONTEND.md`; +- `docs/ARCHITECTURE.md`. + +## Проверки + +Статически проверены новые и изменённые JS-файлы: + +```bash +node --check frontend/admin/js/main.js +node --check frontend/admin/js/views/schedule-view.js +node --check frontend/admin/js/views/auditorium-workload.js +node --check frontend/admin/js/views/department-workspace.js +node --check frontend/admin/settings/js/main.js +node --check frontend/admin/js/api.js +node --check frontend/admin/js/views/users.js +node --check frontend/admin/js/views/classrooms.js +git diff --check +``` + +Backend-компиляция проверяется через Docker Maven: + +```bash +docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile +``` + +Результат: компиляция прошла успешно. + +Единая `V1__init.sql` проверена на пустой PostgreSQL 16 внутри Docker: SQL применился успешно. + +Локальный `mvn` в окружении отсутствует, поэтому используется Docker. + +## Что осталось следующим этапом + +Остались задачи, которые требуют отдельного цикла проработки: + +- UI для просмотра истории переводов преподавателя в админке; +- UI для списка и редактирования уже созданных `schedule_overrides`; +- более строгая проверка конфликтов перед сохранением переносов; +- полноценный импорт XLSX/CSV для кафедры вместо текстовой загрузки; +- браузерный smoke-test и проверка ролевых вкладок после запуска `localhost:80`. diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthContext.java b/backend/src/main/java/com/magistr/app/config/auth/AuthContext.java new file mode 100644 index 0000000..4e90d69 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthContext.java @@ -0,0 +1,26 @@ +package com.magistr.app.config.auth; + +public final class AuthContext { + + private static final ThreadLocal CURRENT_USER = new ThreadLocal<>(); + + private AuthContext() { + } + + public static void setCurrentUser(AuthenticatedUser user) { + CURRENT_USER.set(user); + } + + public static AuthenticatedUser getCurrentUser() { + return CURRENT_USER.get(); + } + + public static Long currentUserId() { + AuthenticatedUser user = getCurrentUser(); + return user == null ? null : user.id(); + } + + public static void clear() { + CURRENT_USER.remove(); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java b/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java new file mode 100644 index 0000000..3ccf675 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java @@ -0,0 +1,33 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.User; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class AuthSessionService { + + private final Map sessions = new ConcurrentHashMap<>(); + + public String createSession(User user) { + String token = UUID.randomUUID().toString(); + sessions.put(token, new AuthenticatedUser( + user.getId(), + user.getUsername(), + user.getRole(), + user.getDepartmentId() + )); + return token; + } + + public Optional findByToken(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + return Optional.ofNullable(sessions.get(token)); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthenticatedUser.java b/backend/src/main/java/com/magistr/app/config/auth/AuthenticatedUser.java new file mode 100644 index 0000000..1530b9d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthenticatedUser.java @@ -0,0 +1,6 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.Role; + +public record AuthenticatedUser(Long id, String username, Role role, Long departmentId) { +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java new file mode 100644 index 0000000..d07ae1d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java @@ -0,0 +1,80 @@ +package com.magistr.app.config.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.magistr.app.model.Role; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; + +@Component +public class AuthorizationInterceptor implements HandlerInterceptor { + + private final AuthSessionService sessionService; + private final ObjectMapper objectMapper; + + public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) { + this.sessionService = sessionService; + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { + if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + if (request.getRequestURI().equals("/api/auth/login")) { + return true; + } + + String token = bearerToken(request.getHeader("Authorization")); + AuthenticatedUser user = sessionService.findByToken(token).orElse(null); + if (user == null) { + writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему"); + return false; + } + + AuthContext.setCurrentUser(user); + RequireRoles roles = resolveRoles(handler); + if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции"); + return false; + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + AuthContext.clear(); + } + + private RequireRoles resolveRoles(Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return null; + } + RequireRoles methodRoles = handlerMethod.getMethodAnnotation(RequireRoles.class); + if (methodRoles != null) { + return methodRoles; + } + return handlerMethod.getBeanType().getAnnotation(RequireRoles.class); + } + + private String bearerToken(String header) { + if (header == null || !header.startsWith("Bearer ")) { + return null; + } + return header.substring("Bearer ".length()).trim(); + } + + private void writeError(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getOutputStream(), Map.of("message", message)); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java b/backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java new file mode 100644 index 0000000..d3844b4 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java @@ -0,0 +1,14 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.Role; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RequireRoles { + Role[] value(); +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java index 71ced9e..767f897 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java @@ -2,6 +2,7 @@ package com.magistr.app.config.tenant; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.magistr.app.config.auth.AuthorizationInterceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -123,6 +124,9 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { @org.springframework.beans.factory.annotation.Autowired private TenantRoutingDataSource tenantRoutingDataSource; + @org.springframework.beans.factory.annotation.Autowired + private AuthorizationInterceptor authorizationInterceptor; + @Bean public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) { TenantInterceptor interceptor = new TenantInterceptor(); @@ -134,6 +138,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { // Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB) registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**"); + registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**"); } private List loadTenantsFromFile() { diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java new file mode 100644 index 0000000..d4116b7 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java @@ -0,0 +1,282 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.*; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/calendar") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) +public class AcademicCalendarAdminController { + + private final AcademicYearRepository academicYearRepository; + private final SemesterRepository semesterRepository; + private final AcademicCalendarActivityTypeRepository activityTypeRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, + SemesterRepository semesterRepository, + AcademicCalendarActivityTypeRepository activityTypeRepository, + AcademicCalendarDayRepository calendarDayRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.academicYearRepository = academicYearRepository; + this.semesterRepository = semesterRepository; + this.activityTypeRepository = activityTypeRepository; + this.calendarDayRepository = calendarDayRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping("/years") + public List getYears() { + return academicYearRepository.findAll().stream() + .sorted(Comparator.comparing(AcademicYear::getStartDate).reversed()) + .map(this::toAcademicYearDto) + .toList(); + } + + @PostMapping("/years") + public ResponseEntity createYear(@RequestBody AcademicYearDto request) { + String validationError = validateYear(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (academicYearRepository.findByTitle(request.title()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует")); + } + + AcademicYear year = new AcademicYear(); + applyYear(year, request); + return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + } + + @PutMapping("/years/{id}") + public ResponseEntity updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) { + AcademicYear year = academicYearRepository.findById(id).orElse(null); + if (year == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validateYear(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + applyYear(year, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + } + + @DeleteMapping("/years/{id}") + public ResponseEntity deleteYear(@PathVariable Long id) { + if (!academicYearRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + academicYearRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Учебный год удалён")); + } + + @GetMapping("/years/{academicYearId}/semesters") + public List getSemesters(@PathVariable Long academicYearId) { + return semesterRepository.findByAcademicYearIdOrderByStartDateAsc(academicYearId).stream() + .map(this::toSemesterDto) + .toList(); + } + + @PostMapping("/years/{academicYearId}/semesters") + public ResponseEntity createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) { + AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null); + if (year == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + String validationError = validateSemester(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году")); + } + + Semester semester = new Semester(); + semester.setAcademicYear(year); + applySemester(semester, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); + } + + @PutMapping("/semesters/{id}") + public ResponseEntity updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) { + Semester semester = semesterRepository.findById(id).orElse(null); + if (semester == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validateSemester(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + applySemester(semester, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); + } + + @GetMapping("/activity-types") + public List getActivityTypes() { + return activityTypeRepository.findAllByOrderByDisplayOrderAscCodeAsc().stream() + .map(this::toActivityTypeDto) + .toList(); + } + + @PostMapping("/activity-types") + public ResponseEntity createActivityType(@RequestBody AcademicCalendarActivityTypeDto request) { + String validationError = validateActivityType(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (activityTypeRepository.findByCode(request.code().trim()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует")); + } + AcademicCalendarActivityType activityType = new AcademicCalendarActivityType(); + applyActivityType(activityType, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType))); + } + + @PutMapping("/activity-types/{id}") + public ResponseEntity updateActivityType(@PathVariable Long id, + @RequestBody AcademicCalendarActivityTypeDto request) { + AcademicCalendarActivityType activityType = activityTypeRepository.findById(id).orElse(null); + if (activityType == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validateActivityType(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (activityTypeRepository.findByCode(request.code().trim()) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует")); + } + applyActivityType(activityType, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType))); + } + + @DeleteMapping("/activity-types/{id}") + public ResponseEntity deleteActivityType(@PathVariable Long id) { + if (!activityTypeRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + if (calendarDayRepository.existsByActivityTypeId(id)) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках")); + } + activityTypeRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Код активности удалён")); + } + + private String validateYear(AcademicYearDto request) { + if (request.title() == null || request.title().isBlank()) { + return "Название учебного года обязательно"; + } + if (request.startDate() == null || request.endDate() == null) { + return "Даты начала и окончания учебного года обязательны"; + } + if (request.endDate().isBefore(request.startDate())) { + return "Дата окончания не может быть раньше даты начала"; + } + return null; + } + + private String validateSemester(SemesterDto request) { + if (request.semesterType() == null) { + return "Тип семестра обязателен"; + } + if (request.startDate() == null || request.endDate() == null) { + return "Даты начала и окончания семестра обязательны"; + } + if (request.endDate().isBefore(request.startDate())) { + return "Дата окончания семестра не может быть раньше даты начала"; + } + return null; + } + + private String validateActivityType(AcademicCalendarActivityTypeDto request) { + if (request == null) { + return "Передайте код активности"; + } + if (request.code() == null || request.code().isBlank()) { + return "Код активности обязателен"; + } + if (request.name() == null || request.name().isBlank()) { + return "Название активности обязательно"; + } + return null; + } + + private void applyYear(AcademicYear year, AcademicYearDto request) { + year.setTitle(request.title().trim()); + year.setStartDate(request.startDate()); + year.setEndDate(request.endDate()); + } + + private void applySemester(Semester semester, SemesterDto request) { + semester.setSemesterType(request.semesterType()); + semester.setStartDate(request.startDate()); + semester.setEndDate(request.endDate()); + } + + private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) { + activityType.setCode(request.code().trim()); + activityType.setName(request.name().trim()); + activityType.setAllowSchedule(Boolean.TRUE.equals(request.allowSchedule())); + activityType.setColorCode(request.colorCode() == null || request.colorCode().isBlank() + ? "#64748b" + : request.colorCode().trim()); + activityType.setDisplayOrder(request.displayOrder() == null ? 100 : request.displayOrder()); + activityType.setDescription(request.description() == null || request.description().isBlank() + ? null + : request.description().trim()); + } + + private AcademicYearDto toAcademicYearDto(AcademicYear year) { + List semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId()) + .stream() + .map(this::toSemesterDto) + .toList(); + return new AcademicYearDto(year.getId(), year.getTitle(), year.getStartDate(), year.getEndDate(), semesters); + } + + private SemesterDto toSemesterDto(Semester semester) { + return new SemesterDto( + semester.getId(), + semester.getAcademicYear().getId(), + semester.getAcademicYear().getTitle(), + semester.getSemesterType(), + semester.getStartDate(), + semester.getEndDate() + ); + } + + private AcademicCalendarActivityTypeDto toActivityTypeDto(AcademicCalendarActivityType activityType) { + return new AcademicCalendarActivityTypeDto( + activityType.getId(), + activityType.getCode(), + activityType.getName(), + activityType.getAllowSchedule(), + activityType.getColorCode(), + activityType.getDisplayOrder(), + activityType.getDescription() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java new file mode 100644 index 0000000..478b952 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -0,0 +1,250 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.*; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/academic-calendars") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) +public class AcademicCalendarController { + + private final AcademicCalendarRepository calendarRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarActivityTypeRepository activityTypeRepository; + private final AcademicYearRepository academicYearRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository profileRepository; + private final EducationFormRepository educationFormRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicCalendarController(AcademicCalendarRepository calendarRepository, + AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarActivityTypeRepository activityTypeRepository, + AcademicYearRepository academicYearRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository profileRepository, + EducationFormRepository educationFormRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.calendarRepository = calendarRepository; + this.calendarDayRepository = calendarDayRepository; + this.activityTypeRepository = activityTypeRepository; + this.academicYearRepository = academicYearRepository; + this.specialtiesRepository = specialtiesRepository; + this.profileRepository = profileRepository; + this.educationFormRepository = educationFormRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getCalendars(@RequestParam(required = false) Long academicYearId, + @RequestParam(required = false) Long specialtyId, + @RequestParam(required = false) Long profileId) { + return calendarRepository.findAllWithDetails(academicYearId, specialtyId, profileId).stream() + .map(this::toCalendarDto) + .toList(); + } + + @GetMapping("/{id}") + public ResponseEntity getCalendar(@PathVariable Long id) { + return calendarRepository.findByIdWithDetails(id) + .>map(calendar -> ResponseEntity.ok(toCalendarDto(calendar))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createCalendar(@RequestBody AcademicCalendarDto request) { + try { + AcademicCalendar calendar = new AcademicCalendar(); + applyCalendar(calendar, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @PutMapping("/{id}") + public ResponseEntity updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) { + AcademicCalendar calendar = calendarRepository.findById(id).orElse(null); + if (calendar == null) { + return ResponseEntity.notFound().build(); + } + try { + applyCalendar(calendar, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCalendar(@PathVariable Long id) { + if (!calendarRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + try { + calendarRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Календарный график удалён")); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Нельзя удалить график, который назначен учебным группам")); + } + } + + @GetMapping("/{id}/grid") + public ResponseEntity getGrid(@PathVariable Long id) { + if (!calendarRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream() + .map(this::toGridDayDto) + .toList()); + } + + @PutMapping("/{id}/grid") + @Transactional + public ResponseEntity saveGrid(@PathVariable Long id, @RequestBody List rows) { + AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null); + if (calendar == null) { + return ResponseEntity.notFound().build(); + } + if (rows == null || rows.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика")); + } + + try { + calendarDayRepository.deleteByCalendarId(id); + for (AcademicCalendarGridDayDto row : rows) { + AcademicCalendarDay day = buildGridDay(calendar, row); + calendarDayRepository.save(day); + } + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) { + if (request == null) { + throw new IllegalArgumentException("Передайте данные календарного графика"); + } + if (request.title() == null || request.title().isBlank()) { + throw new IllegalArgumentException("Название графика обязательно"); + } + if (request.courseCount() == null || request.courseCount() <= 0) { + throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); + } + if (request.academicYearId() == null || request.specialtyId() == null + || request.specialtyProfileId() == null || request.studyFormId() == null) { + throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны"); + } + + AcademicYear year = academicYearRepository.findById(request.academicYearId()) + .orElseThrow(() -> new IllegalArgumentException("Учебный год не найден")); + Speciality speciality = specialtiesRepository.findById(request.specialtyId()) + .orElseThrow(() -> new IllegalArgumentException("Специальность не найдена")); + SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId()) + .orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден")); + if (!profile.getSpeciality().getId().equals(speciality.getId())) { + throw new IllegalArgumentException("Профиль не относится к выбранной специальности"); + } + EducationForm studyForm = educationFormRepository.findById(request.studyFormId()) + .orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена")); + + calendar.setTitle(request.title().trim()); + calendar.setAcademicYear(year); + calendar.setSpeciality(speciality); + calendar.setSpecialtyProfile(profile); + calendar.setStudyForm(studyForm); + calendar.setCourseCount(request.courseCount()); + } + + private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) { + if (row.courseNumber() == null || row.courseNumber() <= 0 + || row.date() == null + || row.weekNumber() == null || row.weekNumber() <= 0 + || row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) { + throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны"); + } + if (row.date().isBefore(calendar.getAcademicYear().getStartDate()) + || row.date().isAfter(calendar.getAcademicYear().getEndDate())) { + throw new IllegalArgumentException("Дата выходит за пределы учебного года"); + } + if (row.courseNumber() > calendar.getCourseCount()) { + throw new IllegalArgumentException("Номер курса выходит за пределы графика"); + } + + AcademicCalendarActivityType activityType = resolveActivityType(row); + AcademicCalendarDay day = new AcademicCalendarDay(); + day.setAcademicCalendar(calendar); + day.setCourseNumber(row.courseNumber()); + day.setDate(row.date()); + day.setWeekNumber(row.weekNumber()); + day.setDayOfWeek(row.dayOfWeek()); + day.setActivityType(activityType); + return day; + } + + private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) { + if (row.activityTypeId() != null) { + return activityTypeRepository.findById(row.activityTypeId()) + .orElseThrow(() -> new IllegalArgumentException("Код активности не найден")); + } + if (row.activityCode() != null && !row.activityCode().isBlank()) { + return activityTypeRepository.findByCode(row.activityCode().trim()) + .orElseThrow(() -> new IllegalArgumentException("Код активности не найден")); + } + throw new IllegalArgumentException("Код активности обязателен"); + } + + private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) { + AcademicYear year = calendar.getAcademicYear(); + Speciality speciality = calendar.getSpeciality(); + SpecialtyProfile profile = calendar.getSpecialtyProfile(); + EducationForm studyForm = calendar.getStudyForm(); + return new AcademicCalendarDto( + calendar.getId(), + calendar.getTitle(), + year.getId(), + year.getTitle(), + year.getStartDate(), + year.getEndDate(), + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getId(), + profile.getName(), + studyForm.getId(), + studyForm.getName(), + calendar.getCourseCount() + ); + } + + private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) { + AcademicCalendarActivityType activityType = day.getActivityType(); + return new AcademicCalendarGridDayDto( + day.getId(), + day.getAcademicCalendar().getId(), + day.getCourseNumber(), + day.getDate(), + day.getWeekNumber(), + day.getDayOfWeek(), + activityType.getId(), + activityType.getCode(), + activityType.getName(), + activityType.getAllowSchedule() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 14c7f2a..5e8abd8 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -2,6 +2,8 @@ package com.magistr.app.controller; import com.magistr.app.dto.LoginRequest; import com.magistr.app.dto.LoginResponse; +import com.magistr.app.config.auth.AuthSessionService; +import com.magistr.app.config.auth.AuthContext; import com.magistr.app.model.User; import com.magistr.app.repository.UserRepository; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -10,7 +12,6 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Optional; -import java.util.UUID; @RestController @RequestMapping("/api/auth") @@ -18,16 +19,23 @@ public class AuthController { private final UserRepository userRepository; private final BCryptPasswordEncoder passwordEncoder; + private final AuthSessionService sessionService; private static final Map ROLE_REDIRECTS = Map.of( "ADMIN", "/admin/", + "EDUCATION_OFFICE", "/admin/#schedule-view", + "DEPARTMENT", "/admin/#department-workspace", + "SCHEDULE_VIEWER", "/admin/#schedule-view", "TEACHER", "/teacher/", "STUDENT", "/student/" ); - public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { + public AuthController(UserRepository userRepository, + BCryptPasswordEncoder passwordEncoder, + AuthSessionService sessionService) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + this.sessionService = sessionService; } @PostMapping("/login") @@ -42,11 +50,30 @@ public class AuthController { } User user = userOpt.get(); - String token = UUID.randomUUID().toString(); + if (user.isArchivedRecord()) { + return ResponseEntity + .status(401) + .body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null)); + } + String token = sessionService.createSession(user); String roleName = user.getRole().name(); String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); Long departmentId = user.getDepartmentId(); - return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId)); + return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId())); + } + + @GetMapping("/me") + public ResponseEntity me() { + var user = AuthContext.getCurrentUser(); + if (user == null) { + return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему")); + } + return ResponseEntity.ok(Map.of( + "userId", user.id(), + "username", user.username(), + "role", user.role().name(), + "departmentId", user.departmentId() + )); } } 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 8084f41..89c76eb 100755 --- a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java +++ b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java @@ -1,9 +1,12 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.ClassroomRequest; import com.magistr.app.dto.ClassroomResponse; import com.magistr.app.model.Classroom; import com.magistr.app.model.Equipment; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.repository.ClassroomRepository; import com.magistr.app.repository.EquipmentRepository; import org.springframework.http.ResponseEntity; @@ -15,6 +18,7 @@ import java.util.Optional; @RestController @RequestMapping("/api/classrooms") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public class ClassroomController { private final ClassroomRepository classroomRepository; @@ -26,8 +30,12 @@ public class ClassroomController { } @GetMapping - public List getAllClassrooms() { - return classroomRepository.findAll().stream() + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) { + List classrooms = includeArchived + ? classroomRepository.findAll() + : classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + return classrooms.stream() .map(this::mapToResponse) .toList(); } @@ -47,10 +55,14 @@ public class ClassroomController { Classroom classroom = new Classroom(); classroom.setName(request.getName().trim()); classroom.setCapacity(request.getCapacity()); + classroom.setBuilding(cleanText(request.getBuilding())); + classroom.setFloor(request.getFloor()); classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true); if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) { - List equipments = equipmentRepository.findAllById(request.getEquipmentIds()); + List equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream() + .filter(Equipment::isActiveRecord) + .toList(); classroom.setEquipments(new java.util.HashSet<>(equipments)); } @@ -80,12 +92,23 @@ public class ClassroomController { classroom.setCapacity(request.getCapacity()); } + if (request.getBuilding() != null) { + classroom.setBuilding(cleanText(request.getBuilding())); + } + + if (request.getFloor() != null) { + classroom.setFloor(request.getFloor()); + } + if (request.getIsAvailable() != null) { classroom.setIsAvailable(request.getIsAvailable()); } if (request.getEquipmentIds() != null) { List equipments = equipmentRepository.findAllById(request.getEquipmentIds()); + equipments = equipments.stream() + .filter(Equipment::isActiveRecord) + .toList(); classroom.setEquipments(new java.util.HashSet<>(equipments)); } @@ -95,15 +118,37 @@ public class ClassroomController { @DeleteMapping("/{id}") public ResponseEntity deleteClassroom(@PathVariable Long id) { - if (!classroomRepository.existsById(id)) { + Optional classroomOpt = classroomRepository.findById(id); + if (classroomOpt.isEmpty()) { return ResponseEntity.notFound().build(); } - classroomRepository.deleteById(id); - return ResponseEntity.ok(Map.of("message", "Аудитория удалена")); + Classroom classroom = classroomOpt.get(); + classroom.archive("Выведена из эксплуатации"); + classroom.setIsAvailable(false); + classroomRepository.save(classroom); + return ResponseEntity.ok(Map.of("message", "Аудитория выведена из эксплуатации")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreClassroom(@PathVariable Long id) { + Optional classroomOpt = classroomRepository.findById(id); + if (classroomOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + Classroom classroom = classroomOpt.get(); + classroom.restore(); + classroom.setIsAvailable(true); + classroomRepository.save(classroom); + return ResponseEntity.ok(mapToResponse(classroom)); } private ClassroomResponse mapToResponse(Classroom c) { - return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(), + return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(), + c.getStatus(), c.getArchivedAt(), c.getArchiveReason(), new java.util.ArrayList<>(c.getEquipments())); } + + private String cleanText(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java index adb5727..27e2173 100755 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java @@ -1,10 +1,12 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.tenant.ConfigMapUpdater; import com.magistr.app.config.tenant.TenantConfig; import com.magistr.app.config.tenant.TenantConfigWatcher; import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.model.Role; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,6 +26,7 @@ import java.util.Map; */ @RestController @RequestMapping("/api/database") +@RequireRoles({Role.ADMIN}) public class DatabaseController { private final TenantRoutingDataSource routingDataSource; diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java index 8adfc3b..dfa9a96 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java @@ -1,8 +1,11 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateDepartmentRequest; import com.magistr.app.dto.DepartmentResponse; import com.magistr.app.model.Department; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.repository.DepartmentRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,6 +18,7 @@ import java.util.Map; @RestController @RequestMapping("/api/departments") +@RequireRoles({Role.ADMIN}) public class DepartmentController { private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class); @@ -26,10 +30,13 @@ public class DepartmentController { } @GetMapping - public List getAllDepartments() { + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение списка кафедр"); try { - List departments = departmentRepository.findAll(); + List departments = includeArchived + ? departmentRepository.findAll() + : departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = departments.stream() .map( d -> new Department( d.getId(), @@ -92,15 +99,87 @@ public class DepartmentController { } } - @DeleteMapping("/id") - public ResponseEntity deleteDepartment(@PathVariable Long id) { - logger.info("Получен запрос на удаление кафедры с ID: {}", id); - if (!departmentRepository.existsById(id)) { + @PutMapping("/{id}") + public ResponseEntity updateDepartment(@PathVariable Long id, @RequestBody CreateDepartmentRequest request) { + logger.info("Получен запрос на обновление кафедры с ID: {}", id); + + Department department = departmentRepository.findById(id).orElse(null); + if (department == null) { logger.info("Кафедра с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } - departmentRepository.deleteById(id); - logger.info("Кафедра с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Кафедра удалена")); + + try { + if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()) { + String errorMessage = "Название кафедры обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) { + String errorMessage = "Код кафедры обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + String departmentName = request.getDepartmentName().trim(); + if (departmentRepository.findByDepartmentName(departmentName) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Кафедра с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Кафедра с таким кодом уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + department.setDepartmentName(departmentName); + department.setDepartmentCode(request.getDepartmentCode()); + departmentRepository.save(department); + + logger.info("Кафедра с ID - {} успешно обновлена", id); + return ResponseEntity.ok(new DepartmentResponse( + department.getId(), + department.getDepartmentName(), + department.getDepartmentCode() + )); + } catch (Exception e) { + logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteDepartment(@PathVariable Long id) { + logger.info("Получен запрос на удаление кафедры с ID: {}", id); + Department department = departmentRepository.findById(id).orElse(null); + if (department == null) { + logger.info("Кафедра с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + department.archive("Кафедра архивирована"); + departmentRepository.save(department); + logger.info("Кафедра с ID - {} успешно архивирована", id); + return ResponseEntity.ok(Map.of("message", "Кафедра архивирована")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreDepartment(@PathVariable Long id) { + Department department = departmentRepository.findById(id).orElse(null); + if (department == null) { + return ResponseEntity.notFound().build(); + } + department.restore(); + departmentRepository.save(department); + return ResponseEntity.ok(new DepartmentResponse( + department.getId(), + department.getDepartmentName(), + department.getDepartmentCode() + )); } } diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java new file mode 100644 index 0000000..a59fb43 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -0,0 +1,153 @@ +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.SubjectCommentDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.SubjectCommentRepository; +import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.ScheduleQueryService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/department") +@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) +public class DepartmentWorkspaceController { + + private final SubjectRepository subjectRepository; + private final SubjectCommentRepository subjectCommentRepository; + private final UserRepository userRepository; + private final ScheduleQueryService scheduleQueryService; + + public DepartmentWorkspaceController(SubjectRepository subjectRepository, + SubjectCommentRepository subjectCommentRepository, + UserRepository userRepository, + ScheduleQueryService scheduleQueryService) { + this.subjectRepository = subjectRepository; + this.subjectCommentRepository = subjectCommentRepository; + this.userRepository = userRepository; + this.scheduleQueryService = scheduleQueryService; + } + + @GetMapping("/subjects") + public List getSubjects(@RequestParam(required = false) Long departmentId) { + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + return subjectRepository.findByDepartmentIdAndStatusNot(effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); + } + + @PostMapping("/subjects/import") + public ResponseEntity importSubjects(@RequestBody List requests) { + if (requests == null || requests.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Передайте список дисциплин для загрузки")); + } + Long requestedDepartmentId = requests.stream() + .map(CreateSubjectRequest::getDepartmentId) + .filter(id -> id != null && id > 0) + .findFirst() + .orElse(null); + Long departmentId = effectiveDepartmentId(requestedDepartmentId); + if (departmentId == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна")); + } + List saved = requests.stream() + .filter(request -> request.getName() != null && !request.getName().isBlank()) + .map(request -> { + Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new); + subject.setName(request.getName().trim()); + subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim()); + subject.setDepartmentId(departmentId); + if (subject.isArchivedRecord()) { + subject.restore(); + } + return subjectRepository.save(subject); + }) + .toList(); + return ResponseEntity.ok(Map.of( + "message", "Дисциплины загружены", + "count", saved.size() + )); + } + + @GetMapping("/subjects/{subjectId}/comments") + public ResponseEntity getComments(@PathVariable Long subjectId) { + Subject subject = subjectRepository.findById(subjectId).orElse(null); + if (subject == null || !canAccessDepartment(subject.getDepartmentId())) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(subjectCommentRepository.findBySubjectIdOrderByCreatedAtDesc(subjectId).stream() + .map(this::toCommentDto) + .toList()); + } + + @PostMapping("/subjects/{subjectId}/comments") + public ResponseEntity addComment(@PathVariable Long subjectId, @RequestBody Map request) { + Subject subject = subjectRepository.findById(subjectId).orElse(null); + if (subject == null || !canAccessDepartment(subject.getDepartmentId())) { + return ResponseEntity.notFound().build(); + } + String commentText = request == null ? null : request.get("comment"); + if (commentText == null || commentText.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен")); + } + SubjectComment comment = new SubjectComment(); + comment.setSubject(subject); + comment.setComment(commentText.trim()); + Long currentUserId = AuthContext.currentUserId(); + if (currentUserId != null) { + userRepository.findById(currentUserId).ifPresent(comment::setAuthor); + } + return ResponseEntity.ok(toCommentDto(subjectCommentRepository.save(comment))); + } + + @GetMapping("/teachers") + public List getTeachers(@RequestParam(required = false) Long departmentId) { + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); + } + + @GetMapping("/schedule") + public ResponseEntity getSchedule( + @RequestParam(required = false) Long departmentId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + return ResponseEntity.ok(scheduleQueryService.search(null, null, null, effectiveDepartmentId, + null, null, null, null, startDate, endDate)); + } + + private SubjectCommentDto toCommentDto(SubjectComment comment) { + User author = comment.getAuthor(); + return new SubjectCommentDto( + comment.getId(), + comment.getSubject().getId(), + author == null ? null : author.getId(), + author == null ? null : author.getFullName(), + comment.getComment(), + comment.getCreatedAt() + ); + } + + private Long effectiveDepartmentId(Long requestedDepartmentId) { + var user = AuthContext.getCurrentUser(); + if (user != null && user.role() == Role.DEPARTMENT) { + return user.departmentId(); + } + return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId; + } + + private boolean canAccessDepartment(Long departmentId) { + var user = AuthContext.getCurrentUser(); + return user == null + || user.role() == Role.ADMIN + || (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId())); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java index dd31357..31306db 100755 --- a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java +++ b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java @@ -1,7 +1,10 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.model.EducationForm; +import com.magistr.app.model.Role; import com.magistr.app.model.StudentGroup; +import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.EducationFormRepository; import com.magistr.app.repository.GroupRepository; import org.springframework.http.ResponseEntity; @@ -12,20 +15,25 @@ import java.util.Map; @RestController @RequestMapping("/api/education-forms") +@RequireRoles({Role.ADMIN}) public class EducationFormController { private final EducationFormRepository educationFormRepository; private final GroupRepository groupRepository; + private final AcademicCalendarRepository academicCalendarRepository; public EducationFormController(EducationFormRepository educationFormRepository, - GroupRepository groupRepository) { + GroupRepository groupRepository, + AcademicCalendarRepository academicCalendarRepository) { this.educationFormRepository = educationFormRepository; this.groupRepository = groupRepository; + this.academicCalendarRepository = academicCalendarRepository; } @GetMapping + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public List> getAll() { - return educationFormRepository.findAll().stream() + return educationFormRepository.findAllByOrderByNameAsc().stream() .map(ef -> Map.of("id", ef.getId(), "name", ef.getName())) .toList(); } @@ -54,12 +62,17 @@ public class EducationFormController { return ResponseEntity.notFound().build(); } - // Check if any groups use this education form + // Нельзя удалить форму обучения, пока она используется группами или графиками. List linked = groupRepository.findByEducationFormId(id); if (!linked.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")")); } + long linkedCalendars = academicCalendarRepository.countByStudyFormId(id); + if (linkedCalendars > 0) { + return ResponseEntity.badRequest().body(Map.of( + "message", "Невозможно удалить: есть привязанные календарные графики (" + linkedCalendars + ")")); + } educationFormRepository.deleteById(id); return ResponseEntity.ok(Map.of("message", "Форма обучения удалена")); diff --git a/backend/src/main/java/com/magistr/app/controller/EquipmentController.java b/backend/src/main/java/com/magistr/app/controller/EquipmentController.java index f305efb..dcbb582 100755 --- a/backend/src/main/java/com/magistr/app/controller/EquipmentController.java +++ b/backend/src/main/java/com/magistr/app/controller/EquipmentController.java @@ -1,5 +1,8 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.model.Equipment; import com.magistr.app.repository.EquipmentRepository; import org.springframework.http.ResponseEntity; @@ -10,6 +13,7 @@ import java.util.Map; @RestController @RequestMapping("/api/equipments") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public class EquipmentController { private final EquipmentRepository equipmentRepository; @@ -19,8 +23,8 @@ public class EquipmentController { } @GetMapping - public List getAllEquipments() { - return equipmentRepository.findAll(); + public List getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) { + return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); } @PostMapping @@ -42,10 +46,23 @@ public class EquipmentController { @DeleteMapping("/{id}") public ResponseEntity deleteEquipment(@PathVariable Long id) { - if (!equipmentRepository.existsById(id)) { + Equipment equipment = equipmentRepository.findById(id).orElse(null); + if (equipment == null) { return ResponseEntity.notFound().build(); } - equipmentRepository.deleteById(id); - return ResponseEntity.ok(Map.of("message", "Оборудование удалено")); + equipment.archive("Оборудование архивировано"); + equipmentRepository.save(equipment); + return ResponseEntity.ok(Map.of("message", "Оборудование архивировано")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreEquipment(@PathVariable Long id) { + Equipment equipment = equipmentRepository.findById(id).orElse(null); + if (equipment == null) { + return ResponseEntity.notFound().build(); + } + equipment.restore(); + equipmentRepository.save(equipment); + return ResponseEntity.ok(equipment); } } 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 9fd2578..1d31f25 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -1,11 +1,20 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateGroupRequest; +import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupResponse; +import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.AcademicYear; import com.magistr.app.model.EducationForm; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; +import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.StudentGroup; -import com.magistr.app.repository.EducationFormRepository; -import com.magistr.app.repository.GroupRepository; +import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.utils.CourseAndSemesterCalculator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,49 +22,55 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.Year; import java.util.List; import java.util.Map; import java.util.Optional; @RestController @RequestMapping("/api/groups") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT}) public class GroupController { private static final Logger logger = LoggerFactory.getLogger(GroupController.class); private final GroupRepository groupRepository; private final EducationFormRepository educationFormRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository specialtyProfileRepository; + private final AcademicYearRepository academicYearRepository; + private final AcademicCalendarRepository academicCalendarRepository; + private final StudentGroupCalendarAssignmentRepository assignmentRepository; + private final ScheduleGeneratorService scheduleGeneratorService; public GroupController(GroupRepository groupRepository, - EducationFormRepository educationFormRepository) { + EducationFormRepository educationFormRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository specialtyProfileRepository, + AcademicYearRepository academicYearRepository, + AcademicCalendarRepository academicCalendarRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, + ScheduleGeneratorService scheduleGeneratorService) { this.groupRepository = groupRepository; this.educationFormRepository = educationFormRepository; + this.specialtiesRepository = specialtiesRepository; + this.specialtyProfileRepository = specialtyProfileRepository; + this.academicYearRepository = academicYearRepository; + this.academicCalendarRepository = academicCalendarRepository; + this.assignmentRepository = assignmentRepository; + this.scheduleGeneratorService = scheduleGeneratorService; } @GetMapping - public List getAllGroups() { + public List getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех групп"); try { - List groups = groupRepository.findAll(); + List groups = includeArchived + ? groupRepository.findAll() + : groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = groups.stream() - .map(g -> { - int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); - int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); - return new GroupResponse( - g.getId(), - g.getName(), - g.getGroupSize(), - g.getEducationForm().getId(), - g.getEducationForm().getName(), - g.getDepartmentId(), - course, - semester, - g.getSpecialityCode() - ); - }) + .map(this::mapToResponse) .toList(); logger.info("Получено {} групп", response.size()); return response; @@ -69,7 +84,7 @@ public class GroupController { public ResponseEntity getGroupsByDepartmentId(@PathVariable Long departmentId) { logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); try { - List groups = groupRepository.findByDepartmentId(departmentId); + List groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); if(groups.isEmpty()) { logger.info("Группы для кафедры с ID - {} не найдены", departmentId); @@ -78,21 +93,7 @@ public class GroupController { } List response = groups.stream() - .map(g -> { - int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); - int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); - return new GroupResponse( - g.getId(), - g.getName(), - g.getGroupSize(), - g.getEducationForm().getId(), - g.getEducationForm().getName(), - g.getDepartmentId(), - course, - semester, - g.getSpecialityCode() - ); - }) + .map(this::mapToResponse) .toList(); logger.info("Найдено {} групп для кафедры с ID - {}", response.size(), departmentId); @@ -106,55 +107,30 @@ public class GroupController { } @PostMapping + @RequireRoles({Role.ADMIN}) public ResponseEntity createGroup(@RequestBody CreateGroupRequest request) { logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}", request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy()); try { - if (request.getName() == null || request.getName().isBlank()) { - String errorMessage = "Название группы обязательно"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (groupRepository.findByName(request.getName().trim()).isPresent()) { - String errorMessage = "Группа с таким названием уже существует"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getGroupSize() == null) { - String errorMessage = "Численность группы обязательна"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getEducationFormId() == null) { - String errorMessage = "Форма обучения обязательна"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { - String errorMessage = "ID кафедры обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } -// if (request.getCourse() == null || request.getCourse() == 0) { -// String errorMessage = "Курс обязателен"; -// logger.error("Ошибка валидации: {}", errorMessage); -// return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); -// } - if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { - String errorMessage = "Год начала обучения обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) { - String errorMessage = "Код специальности обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + ResponseEntity validationError = validateGroupRequest(request); + if (validationError != null) { + return validationError; } Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); if (efOpt.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); } + Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) + .orElse(null); + if (speciality == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена")); + } + SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId()) + .orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности")); + } StudentGroup group = new StudentGroup(); group.setName(request.getName().trim()); @@ -162,20 +138,13 @@ public class GroupController { group.setEducationForm(efOpt.get()); group.setDepartmentId(request.getDepartmentId()); group.setYearStartStudy(request.getYearStartStudy()); - group.setSpecialityCode(request.getSpecialityCode()); + group.setSpeciality(speciality); + group.setSpecialtyProfile(profile); groupRepository.save(group); logger.info("Группа успешно создана с ID - {}", group.getId()); - return ResponseEntity.ok(new GroupResponse( - group.getId(), - group.getName(), - group.getGroupSize(), - group.getEducationForm().getId(), - group.getEducationForm().getName(), - group.getDepartmentId(), - group.getYearStartStudy(), - group.getSpecialityCode())); + return ResponseEntity.ok(mapToResponse(group)); } catch (Exception e ) { logger.error("Ошибка при создании группы: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -183,15 +152,208 @@ public class GroupController { } } + @PutMapping("/{id}") + @RequireRoles({Role.ADMIN}) + public ResponseEntity updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) { + logger.info("Получен запрос на обновление группы с ID - {}", id); + try { + Optional groupOpt = groupRepository.findById(id); + if (groupOpt.isEmpty()) { + logger.info("Группа с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + + ResponseEntity validationError = validateGroupRequest(request); + if (validationError != null) { + return validationError; + } + + Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); + if (efOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); + } + Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) + .orElse(null); + if (speciality == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена")); + } + SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId()) + .orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности")); + } + + StudentGroup group = groupOpt.get(); + group.setName(request.getName().trim()); + group.setGroupSize(request.getGroupSize()); + group.setEducationForm(efOpt.get()); + group.setDepartmentId(request.getDepartmentId()); + group.setYearStartStudy(request.getYearStartStudy()); + group.setSpeciality(speciality); + group.setSpecialtyProfile(profile); + groupRepository.save(group); + + logger.info("Группа с ID - {} успешно обновлена", id); + return ResponseEntity.ok(mapToResponse(group)); + } catch (Exception e) { + logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage())); + } + } + @DeleteMapping("/{id}") + @RequireRoles({Role.ADMIN}) public ResponseEntity deleteGroup(@PathVariable Long id) { logger.info("Получен запрос на удаление группы с ID - {}", id); - if (!groupRepository.existsById(id)) { + StudentGroup group = groupRepository.findById(id).orElse(null); + if (group == null) { logger.info("Группа с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } - groupRepository.deleteById(id); - logger.info("Группа с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Группа удалена")); + group.archive("Группа архивирована"); + groupRepository.save(group); + logger.info("Группа с ID - {} успешно архивирована", id); + return ResponseEntity.ok(Map.of("message", "Группа архивирована")); + } + + @PostMapping("/{id}/restore") + @RequireRoles({Role.ADMIN}) + public ResponseEntity restoreGroup(@PathVariable Long id) { + StudentGroup group = groupRepository.findById(id).orElse(null); + if (group == null) { + return ResponseEntity.notFound().build(); + } + group.restore(); + groupRepository.save(group); + return ResponseEntity.ok(mapToResponse(group)); + } + + private ResponseEntity validateGroupRequest(CreateGroupRequest request) { + if (request.getName() == null || request.getName().isBlank()) { + return validationError("Название группы обязательно"); + } + if (request.getGroupSize() == null) { + return validationError("Численность группы обязательна"); + } + if (request.getEducationFormId() == null) { + return validationError("Форма обучения обязательна"); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + return validationError("ID кафедры обязателен"); + } + if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { + return validationError("Год начала обучения обязателен"); + } + if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) { + return validationError("Код специальности обязателен"); + } + if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) { + return validationError("Профиль обучения обязателен"); + } + return null; + } + + private ResponseEntity validationError(String errorMessage) { + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + private GroupResponse mapToResponse(StudentGroup group) { + int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy()); + int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy()); + Speciality speciality = group.getSpeciality(); + SpecialtyProfile profile = group.getSpecialtyProfile(); + return new GroupResponse( + group.getId(), + group.getName(), + group.getGroupSize(), + group.getEducationForm().getId(), + group.getEducationForm().getName(), + group.getDepartmentId(), + group.getYearStartStudy(), + course, + semester, + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getId(), + profile.getName() + ); + } + + @GetMapping("/{id}/calendar-assignments") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + public ResponseEntity getCalendarAssignments(@PathVariable Long id) { + if (!groupRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream() + .map(this::toAssignmentDto) + .toList()); + } + + @PutMapping("/{id}/calendar-assignments") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + public ResponseEntity saveCalendarAssignment(@PathVariable Long id, + @RequestBody GroupCalendarAssignmentDto request) { + if (request == null || request.academicYearId() == null || request.calendarId() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны")); + } + StudentGroup group = groupRepository.findById(id).orElse(null); + if (group == null) { + return ResponseEntity.notFound().build(); + } + AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null); + if (academicYear == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null); + if (calendar == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден")); + } + if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году")); + } + if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId()) + || !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы")); + } + if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы")); + } + + StudentGroupCalendarAssignment assignment = assignmentRepository + .findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId()) + .orElseGet(StudentGroupCalendarAssignment::new); + assignment.setStudentGroup(group); + assignment.setAcademicYear(academicYear); + assignment.setAcademicCalendar(calendar); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment))); + } + + @DeleteMapping("/{id}/calendar-assignments/{assignmentId}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + public ResponseEntity deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) { + StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null); + if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) { + return ResponseEntity.notFound().build(); + } + assignmentRepository.delete(assignment); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено")); + } + + private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) { + return new GroupCalendarAssignmentDto( + assignment.getId(), + assignment.getStudentGroup().getId(), + assignment.getStudentGroup().getName(), + assignment.getAcademicYear().getId(), + assignment.getAcademicYear().getTitle(), + assignment.getAcademicCalendar().getId(), + assignment.getAcademicCalendar().getTitle() + ); } } diff --git a/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java b/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java new file mode 100644 index 0000000..b55b973 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java @@ -0,0 +1,33 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.LessonTypeResponse; +import com.magistr.app.model.LessonType; +import com.magistr.app.model.Role; +import com.magistr.app.repository.LessonTypesRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Comparator; +import java.util.List; + +@RestController +@RequestMapping("/api/lesson-types") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) +public class LessonTypeController { + + private final LessonTypesRepository lessonTypesRepository; + + public LessonTypeController(LessonTypesRepository lessonTypesRepository) { + this.lessonTypesRepository = lessonTypesRepository; + } + + @GetMapping + public List getAllLessonTypes() { + return lessonTypesRepository.findAll().stream() + .sorted(Comparator.comparing(LessonType::getLessonType)) + .map(lessonType -> new LessonTypeResponse(lessonType.getId(), lessonType.getLessonType())) + .toList(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/LessonsController.java b/backend/src/main/java/com/magistr/app/controller/LessonsController.java deleted file mode 100755 index 3fe103c..0000000 --- a/backend/src/main/java/com/magistr/app/controller/LessonsController.java +++ /dev/null @@ -1,507 +0,0 @@ -package com.magistr.app.controller; - -import com.magistr.app.dto.CreateLessonRequest; -import com.magistr.app.dto.LessonResponse; -import com.magistr.app.model.*; -import com.magistr.app.repository.*; -import com.magistr.app.utils.DayAndWeekValidator; -import com.magistr.app.utils.TypeAndFormatLessonValidator; -import org.slf4j.LoggerFactory; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.*; - -import org.slf4j.Logger; -import org.springframework.http.HttpStatus; - -@RestController -@RequestMapping("/api/users/lessons") -public class LessonsController { - - private static final Logger logger = LoggerFactory.getLogger(LessonsController.class); - - private final LessonRepository lessonRepository; - private final UserRepository teacherRepository; - private final GroupRepository groupRepository; - private final SubjectRepository subjectRepository; - private final EducationFormRepository educationFormRepository; - private final ClassroomRepository classroomRepository; - - public LessonsController(LessonRepository lessonRepository, UserRepository teacherRepository, GroupRepository groupRepository, SubjectRepository subjectRepository, EducationFormRepository educationForm, ClassroomRepository classroomRepository) { - this.lessonRepository = lessonRepository; - this.teacherRepository = teacherRepository; - this.groupRepository = groupRepository; - this.subjectRepository = subjectRepository; - this.educationFormRepository = educationForm; - this.classroomRepository = classroomRepository; - } - - //Создание нового занятия - @PostMapping("/create") - public ResponseEntity createLesson(@RequestBody CreateLessonRequest request) { - //Полное логирование входящего запроса - logger.info("Получен запрос на создание занятия: teacherId={}, groupId={}, subjectId={}, day={}, week={}, time={}", - request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getDay(), request.getWeek(), request.getTime()); - - //Проверка teacherId - if (request.getTeacherId() == null || request.getTeacherId() == 0) { - String errorMessage = "ID преподавателя обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка groupId - if (request.getGroupId() == null || request.getGroupId() == 0) { - String errorMessage = "ID группы обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка subjectId - if (request.getSubjectId() == null || request.getSubjectId() == 0) { - String errorMessage = "ID предмета обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка lessonFormat - if (request.getLessonFormat() == null || request.getLessonFormat().isBlank()) { - String errorMessage = "Выбор формата занятия обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())){ - String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage(); - logger.info("Ошибка валидации формата: '{}' - {}", request.getLessonFormat(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка typeLesson - if (request.getTypeLesson() == null || request.getTypeLesson().isBlank()) { - String errorMessage = "Выбор типа занятия обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())){ - String errorMessage = "Некорректный тип занятия. " + TypeAndFormatLessonValidator.getValidTypesMessage(); - logger.info("Ошибка валидации типа: '{}' - {}", request.getTypeLesson(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка classroomId - if (request.getClassroomId() == null || request.getClassroomId() == 0) { - String errorMessage = "ID аудитории обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка day - if (request.getDay() == null || request.getDay().isBlank()) { - String errorMessage = "Выбор дня обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!DayAndWeekValidator.isValidDay(request.getDay())){ - String errorMessage = "Некорректный день недели. " + DayAndWeekValidator.getValidDaysMessage(); - logger.info("Ошибка валидации дня: '{}' - {}", request.getDay(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка week - if (request.getWeek() == null || request.getWeek().isBlank()) { - String errorMessage = "Выбор недели обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!DayAndWeekValidator.isValidWeek(request.getWeek())){ - String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage(); - logger.info("Ошибка валидации недели: '{}' - {}", request.getWeek(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка time - if (request.getTime() == null || request.getTime().isBlank()) { - String errorMessage = "Время обязательно"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Сохранение полученных данных и формирование ответа клиенту - try { - Lesson lesson = new Lesson(); - lesson.setTeacherId(request.getTeacherId()); - lesson.setSubjectId(request.getSubjectId()); - lesson.setGroupId(request.getGroupId()); - lesson.setLessonFormat(request.getLessonFormat()); - lesson.setTypeLesson(request.getTypeLesson()); - lesson.setClassroomId(request.getClassroomId()); - lesson.setDay(request.getDay()); - lesson.setWeek(request.getWeek()); - lesson.setTime(request.getTime()); - - Lesson savedLesson = lessonRepository.save(lesson); - - Map response = new LinkedHashMap<>(); - response.put("id", savedLesson.getId()); - response.put("teacherId", savedLesson.getTeacherId()); - response.put("groupId", savedLesson.getGroupId()); - response.put("subjectId", savedLesson.getSubjectId()); - response.put("formatLesson", savedLesson.getLessonFormat()); - response.put("typeLesson", savedLesson.getTypeLesson()); - response.put("classroomId", savedLesson.getClassroomId()); - response.put("day", savedLesson.getDay()); - response.put("week", savedLesson.getWeek()); - response.put("time", savedLesson.getTime()); - - logger.info("Занятие успешно создано с ID: {}", savedLesson.getId()); - - return ResponseEntity.ok(response); - } catch (Exception e) { - logger.error("Ошибка при сохранении занятия: {}", e.getMessage(),e); - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании занятия: " + e.getMessage())); - } - } - - //Запрос для получения всего списка занятий - @GetMapping - public List getAllLessons() { - logger.info("Запрос на получение всех занятий"); - - try { - List lessons = lessonRepository.findAll(); - - List response = lessons.stream() - .map(lesson -> { - String teacherName = teacherRepository.findById(lesson.getTeacherId()) - .map(User::getUsername) - .orElse("Неизвестно"); - - StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null); - String groupName = groupRepository.findById(lesson.getGroupId()) - .map(StudentGroup::getName) - .orElse("Неизвестно"); - - String educationFormName = "Неизвестно"; - if(group != null && group.getEducationForm() != null) { - Long educationFormId = group.getEducationForm().getId(); - educationFormName = educationFormRepository.findById(educationFormId) - .map(EducationForm::getName) - .orElse("Неизвестно"); - } - - String subjectName = subjectRepository.findById(lesson.getSubjectId()) - .map(Subject::getName) - .orElse("Неизвестно"); - - String classroomName = classroomRepository.findById(lesson.getClassroomId()) - .map(Classroom::getName) - .orElse("Неизвестно"); - - return new LessonResponse( - lesson.getId(), - teacherName, - groupName, - classroomName, - educationFormName, - subjectName, - lesson.getTypeLesson(), - lesson.getLessonFormat(), - lesson.getDay(), - lesson.getWeek(), - lesson.getTime() - ); - }) - .toList(); - - logger.info("Получено {} занятий", lessons.size()); - return response; - } catch (Exception e) { - logger.error("Ошибка при получении списка всех занятий: {}", e.getMessage(), e); - throw e; - } - } - - //Запрос на получение всех занятий для конкретного преподавателя - @GetMapping("/{teacherId}") - public ResponseEntity getLessonsById(@PathVariable Long teacherId) { - logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId); - try { - List lessons = lessonRepository.findByTeacherId(teacherId); - - if(lessons.isEmpty()) { - logger.info("У преподавателя с ID {} нет занятий", teacherId); - return ResponseEntity.ok(Map.of( - "message", "У преподавателя с ID " + teacherId +" нет занятий.", - "lessons", Collections.emptyList() - )); - } - - List lessonResponses = lessons.stream() - .map(lesson -> { - String teacherName = teacherRepository.findById(lesson.getTeacherId()) - .map(User::getUsername) - .orElse("Неизвестно"); - - StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null); - String groupName = groupRepository.findById(lesson.getGroupId()) - .map(StudentGroup::getName) - .orElse("Неизвестно"); - - String educationFormName = "Неизвестно"; - if(group != null && group.getEducationForm() != null) { - Long educationFormId = group.getEducationForm().getId(); - educationFormName = educationFormRepository.findById(educationFormId) - .map(EducationForm::getName) - .orElse("Неизвестно"); - } - - String subjectName = subjectRepository.findById(lesson.getSubjectId()) - .map(Subject::getName) - .orElse("Неизвестно"); - - String classroomName = classroomRepository.findById(lesson.getClassroomId()) - .map(Classroom::getName) - .orElse("Неизвестно"); - - return new LessonResponse( - lesson.getId(), - teacherName, - groupName, - classroomName, - educationFormName, - subjectName, - lesson.getTypeLesson(), - lesson.getLessonFormat(), - lesson.getDay(), - lesson.getWeek(), - lesson.getTime() - ); - }) - .toList(); - - logger.info("Найдено {} занятий для преподавателя с ID: {}", lessonResponses.size(), teacherId); - return ResponseEntity.ok(lessonResponses); - } catch (Exception e ){ - logger.error("Ошибка при получении занятий для преподавателя с ID {}: {}", teacherId, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Ошибка при поиске занятий: " + e.getMessage())); - } - } - - //Тестовый запрос на проверку доступности контроллера - @GetMapping("/ping") - public String ping() { - logger.debug("Получен ping запрос"); - String response = "pong"; - logger.debug("Ответ на ping: {}", response); - return response; - } - - //Удаление занятия по его ID - @DeleteMapping("/delete/{lessonId}") - public ResponseEntity deleteLessonById(@PathVariable Long lessonId){ - logger.info("Запрос на удаление занятия по ID: {}", lessonId); - if(!lessonRepository.existsById(lessonId)) { - return ResponseEntity.badRequest().body(Map.of("message", "Занятие не найдено")); - } - lessonRepository.deleteById(lessonId); - logger.info("Занятие с ID - {} успешно удалено", lessonId); - return ResponseEntity.ok(Map.of("message", "Занятие успешно удалено")); - - } - - //Обновление занятия по его ID - @PutMapping("/update/{lessonId}") - public ResponseEntity updateLessonById(@PathVariable Long lessonId, @RequestBody CreateLessonRequest request) { - logger.info("Получен запрос на обновление занятия с ID - {}", lessonId); - logger.info("Данные для обновления: teacherId={}, groupId={}, subjectId={}, lessonFormat={}, typeLesson={}, classroomId={}, day={}, week={}, time={}", - request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getLessonFormat(), request.getTypeLesson(), request.getClassroomId(), - request.getDay(), request.getWeek(), request.getTime()); - - try { - //Проверка на наличие записи - Lesson existingLesson = lessonRepository.findById(lessonId).orElse(null); - - if(existingLesson == null) { - String errorMessage = "Занятие с ID " + lessonId + " не найдено"; - logger.info("Ошибка: {}", errorMessage); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", errorMessage)); - } - - boolean hasChanges = false; - Map changes = new LinkedHashMap<>(); - - //Проверка и обновление teacherId, если он передан и отличается - if(request.getTeacherId() != null) { - if(!request.getTeacherId().equals(existingLesson.getTeacherId())) { - if(request.getTeacherId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID преподавателя не может быть равен 0")); - } - existingLesson.setTeacherId(request.getTeacherId()); - changes.put("teacherId", request.getTeacherId()); - hasChanges = true; - } - } - - //Проверка и обновление groupId, если он передан и отличается - if(request.getGroupId() != null) { - if(!request.getGroupId().equals(existingLesson.getGroupId())) { - if(request.getGroupId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID группы не может быть равен 0")); - } - existingLesson.setGroupId(request.getGroupId()); - changes.put("groupId", request.getGroupId()); - hasChanges = true; - } - } - - //Проверка и обновление subjectId, если он передан и отличается - if(request.getSubjectId() != null) { - if(!request.getSubjectId().equals(existingLesson.getSubjectId())) { - if(request.getSubjectId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID дисциплины не может быть равен 0")); - } - existingLesson.setSubjectId(request.getSubjectId()); - changes.put("subjectId", request.getSubjectId()); - hasChanges = true; - } - } - - //Проверка и обновление lessonFormat, если он передан и отличается - if(request.getLessonFormat() != null) { - if(!request.getLessonFormat().equals(existingLesson.getLessonFormat())) { - if(request.getLessonFormat().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Формат занятия не может быть пустым")); - } - if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())) { - String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage(); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - existingLesson.setLessonFormat(request.getLessonFormat()); - changes.put("lessonFormat", request.getLessonFormat()); - hasChanges = true; - } - } - - //Проверка и обновление typeLesson, если он передан и отличается - if(request.getTypeLesson() != null) { - if(!request.getTypeLesson().equals(existingLesson.getTypeLesson())) { - if(request.getTypeLesson().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Тип занятия не может быть пустым")); - } - if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())) { - String errorMessage = "Некорректный тип занятий. " + TypeAndFormatLessonValidator.getValidTypesMessage(); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - existingLesson.setLessonFormat(request.getTypeLesson()); - changes.put("typeLesson", request.getTypeLesson()); - hasChanges = true; - } - } - - //Проверка и обновление classroomId, если он передан и отличается - if(request.getClassroomId() != null) { - if(!request.getClassroomId().equals(existingLesson.getClassroomId())) { - if(request.getClassroomId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID аудитории не можеть быть равен 0")); - } - existingLesson.setClassroomId(request.getClassroomId()); - changes.put("classroomId", request.getClassroomId()); - hasChanges = true; - } - } - - //Проверка и обновление day, если он передан и отличается - if(request.getDay() != null){ - if(!request.getDay().equals(existingLesson.getDay())) { - if(request.getDay().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Поле \"День\" не может быть пустым")); - } - if(!DayAndWeekValidator.isValidDay(request.getDay())) { - String errorMessage = "Некорректный день. " + DayAndWeekValidator.getValidDaysMessage(); - return ResponseEntity.badRequest() - .body(Map.of("message", errorMessage)); - } - existingLesson.setDay(request.getDay()); - changes.put("day", request.getDay()); - hasChanges = true; - } - } - - //Проверка и обновление week, если он передан и отличается - if(request.getWeek() != null) { - if(!request.getWeek().equals(existingLesson.getWeek())) { - if (request.getWeek().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Поле \"Неделя\" не может быть пустым")); - } - if (!DayAndWeekValidator.isValidWeek(request.getWeek())) { - String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage(); - return ResponseEntity.badRequest() - .body((Map.of("message", errorMessage))); - } - existingLesson.setWeek(request.getWeek()); - changes.put("week", request.getWeek()); - hasChanges = true; - } - } - - //Проверка и обновление time, если он передан и отличается - if(request.getTime() != null) { - if(!request.getTime().equals(existingLesson.getTime())) { - if(request.getTime().isBlank()){ - return ResponseEntity.badRequest() - .body(Map.of("message", "Поле \"Время\" не может быть пустым")); - } - existingLesson.setTime(request.getTime()); - changes.put("time", request.getTime()); - hasChanges = true; - } - } - - if(!hasChanges) { - logger.info("Обновление не требуется - все полня идентичны существующим для занятия с ID: {}", lessonId); - - Map response = buildResponse(existingLesson); - response.put("message", "Изменений не обнаружено"); - return ResponseEntity.ok(response); - } - - Lesson updatedLesson = lessonRepository.save(existingLesson); - - Map response = buildResponse(updatedLesson); - response.put("updatedFields", changes); - response.put("message", "Занятие успешно обновлено"); - - logger.info("Занятие с ID - {} успешно обновлено. Изменения: {}", lessonId, changes); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - logger.error("Ошибка при обновлении занятия с ID {}: {}", lessonId, e.getMessage(),e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при обновлении занятия: " + e.getMessage())); - } - } - - private Map buildResponse(Lesson lesson) { - Map response = new LinkedHashMap<>(); - response.put("id", lesson.getId()); - response.put("teacherId", lesson.getTeacherId()); - response.put("groupId", lesson.getGroupId()); - response.put("subjectId", lesson.getSubjectId()); - response.put("LessonFormat", lesson.getLessonFormat()); - response.put("typeLesson", lesson.getTypeLesson()); - response.put("classroomId", lesson.getClassroomId()); - response.put("day", lesson.getDay()); - response.put("week", lesson.getWeek()); - response.put("time", lesson.getTime()); - return response; - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java new file mode 100644 index 0000000..defcf6b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java @@ -0,0 +1,68 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.model.Role; +import com.magistr.app.service.ScheduleQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/schedule") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT}) +public class ScheduleController { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class); + + private final ScheduleQueryService scheduleQueryService; + + public ScheduleController(ScheduleQueryService scheduleQueryService) { + this.scheduleQueryService = scheduleQueryService; + } + + @GetMapping + public ResponseEntity getSchedule( + @RequestParam(required = false) Long groupId, + @RequestParam(required = false) Long teacherId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + logger.info("Запрос динамического расписания: groupId={}, teacherId={}, startDate={}, endDate={}", + groupId, teacherId, startDate, endDate); + + if ((groupId == null && teacherId == null) || (groupId != null && teacherId != null)) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId")); + } + + try { + List lessons = scheduleQueryService.search( + groupId, + teacherId, + null, + null, + null, + null, + null, + null, + startDate, + endDate + ); + return ResponseEntity.ok(lessons); + } catch (IllegalArgumentException e) { + logger.info("Ошибка запроса динамического расписания: {}", e.getMessage()); + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } catch (Exception e) { + logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e); + return ResponseEntity.internalServerError() + .body(Map.of("message", "Произошла ошибка при генерации расписания")); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java deleted file mode 100644 index 65f2994..0000000 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java +++ /dev/null @@ -1,283 +0,0 @@ -package com.magistr.app.controller; - -import com.magistr.app.dto.CreateScheduleDataRequest; -import com.magistr.app.dto.ScheduleResponse; -import com.magistr.app.model.*; -import com.magistr.app.repository.*; -import com.magistr.app.utils.CourseAndSemesterCalculator; -import com.magistr.app.utils.SemesterTypeValidator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/department/schedule") -public class ScheduleDataController { - - private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class); - - private final ScheduleDataRepository scheduleDataRepository; - private final GroupRepository groupRepository; - private final SpecialtiesRepository specialtiesRepository; - private final SubjectRepository subjectRepository; - private final LessonTypesRepository lessonTypesRepository; - private final UserRepository userRepository; - - public ScheduleDataController(ScheduleDataRepository scheduleDataRepository, GroupRepository groupRepository, SpecialtiesRepository specialtiesRepository, SubjectRepository subjectRepository, LessonTypesRepository lessonTypesRepository, UserRepository userRepository) { - this.scheduleDataRepository = scheduleDataRepository; - this.groupRepository = groupRepository; - this.specialtiesRepository = specialtiesRepository; - this.subjectRepository = subjectRepository; - this.lessonTypesRepository = lessonTypesRepository; - this.userRepository = userRepository; - } - - @GetMapping("/allList") - public List getAllScheduleDataList() { - logger.info("Получен запрос на получение списка данных расписаний"); - try { - List scheduleData = scheduleDataRepository.findAll(); - List response = scheduleData.stream() - .map(s -> new ScheduleData( - s.getId(), - s.getDepartmentId(), - s.getGroupId(), - s.getSubjectsId(), - s.getLessonTypeId(), - s.getNumberOfHours(), - s.getDivision(), - s.getTeacherId(), - s.getSemesterType(), - s.getPeriod() - )) - .toList(); - logger.info("Получено {} записей", response.size()); - return response; - } catch (Exception e) { - logger.error("Ошибка при получении списка данных расписаний: {}", e.getMessage(), e); - throw e; - } - } - - @GetMapping - public ResponseEntity getSingleScheduleData( - @RequestParam Long departmentId, - @RequestParam SemesterType semesterType, - @RequestParam String period - ) { - logger.info("Получен запрос на получение списка данных расписания по конкретным данным: departmentId = {}, semester = {}, period = {}", - departmentId, semesterType, period); - try { - List scheduleData = scheduleDataRepository.findByDepartmentIdAndSemesterTypeAndPeriod(departmentId, semesterType, period ); - - if(scheduleData.isEmpty()){ - logger.info("По параметрам: departmentId = {}, semester = {}, period = {} не найдено записей", departmentId, semesterType, period); - return ResponseEntity.ok(Map.of( - "message", "Записей не найдено" - )); - } - - List response = scheduleData.stream() - .map( s -> { - String groupName = groupRepository.findById(s.getGroupId()) - .map(StudentGroup::getName) - .orElse("Неизвестно"); - - int groupSemester = 0; - int groupCourse = 0; - String specialityCode = "Неизвестно"; - - StudentGroup group = groupRepository.findById(s.getGroupId()).orElse(null); - - if (group != null) { - groupCourse = CourseAndSemesterCalculator.getFutureCourse(group.getYearStartStudy(), period); - } - - if (group != null) { - groupSemester = CourseAndSemesterCalculator.getFutureSemester(group.getYearStartStudy(), period, semesterType); - } - - if (group != null) { - Long specialityId = group.getSpecialityCode(); - specialityCode = specialtiesRepository.findById(specialityId). - map(Speciality::getSpecialityCode) - .orElse("Неизвестно"); - } - - String subjectName = subjectRepository.findById(s.getSubjectsId()) - .map(Subject::getName) - .orElse("Неизвестно"); - - String lessonType = lessonTypesRepository.findById(s.getLessonTypeId()) - .map(LessonType::getLessonType) - .orElse("Неизвестно"); - - String teacherName = userRepository.findById(s.getTeacherId()) - .map(User::getFullName) - .orElse("Неизвестно"); - - String teacherjobTitle = userRepository.findById(s.getTeacherId()) - .map(User::getJobTitle) - .orElse("Неизвестно"); - - return new ScheduleResponse( - s.getId(), - s.getDepartmentId(), - specialityCode, - groupName, - groupCourse, - groupSemester, - subjectName, - lessonType, - s.getNumberOfHours(), - s.getDivision(), - teacherName, - teacherjobTitle, - s.getSemesterType(), - s.getPeriod()); - } - ) - .toList(); - logger.info("Получено {} записей для кафедры с ID - {}", response.size(), departmentId); - return ResponseEntity.ok(response); - } catch (Exception e) { - logger.error("Ошибка при получении списка данных расписаний для кафедры с ID - {}, semester - {}, period - {}: {}", departmentId, semesterType, period, e.getMessage(), e); - throw e; - } - } - - // Доделать проверки получаемых полей!!! - @PostMapping("/create") - public ResponseEntity createScheduleData(@RequestBody CreateScheduleDataRequest request) { - logger.info("Получен запрос на создание записи данных для расписаний: departmentId={}, groupId={}, subjectsId={}, lessonTypeId={}, numberOfHours={}, division={}, teacherId={}, semesterType={}, period={}", - request.getDepartmentId(), request.getGroupId(), request.getSubjectsId(), request.getLessonTypeId(), request.getNumberOfHours(), request.getDivision(), request.getTeacherId(), request.getSemesterType(), request.getPeriod()); - try { - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { - String errorMessage = "ID кафедры обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!scheduleDataRepository.existsById(request.getDepartmentId())) { - String errorMessage = "Кафедра не найдена"; - logger.info("Кафедра не найдена"); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getGroupId() == null || request.getGroupId() == 0) { - String errorMessage = "ID группы обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getSubjectsId() == null || request.getSubjectsId() == 0) { - String errorMessage = "ID дисциплины обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getLessonTypeId() == null || request.getLessonTypeId() == 0) { - String errorMessage = "ID типа занятия обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getNumberOfHours() == null) { - request.setNumberOfHours(0L); - } - - if (request.getTeacherId() == null || request.getTeacherId() == 0) { - String errorMessage = "ID преподавателя обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getSemesterType() == null) { - String errorMessage = "Семестр обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if (!SemesterTypeValidator.isValidTypeSemester(request.getSemesterType().toString())) { - String errorMessage = "Некорректный формат семестра. Допустимые форматы: " + SemesterTypeValidator.getValidTypes(); - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getPeriod() == null || request.getPeriod().isBlank()) { - String errorMessage = "Период обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - boolean existsRecord = scheduleDataRepository.existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod( - request.getDepartmentId(), - request.getGroupId(), - request.getSubjectsId(), - request.getLessonTypeId(), - request.getNumberOfHours(), - request.getDivision(), - request.getTeacherId(), - request.getSemesterType(), - request.getPeriod() - ); - - if(existsRecord) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("message", "Такая запись уже существует")); - } - - ScheduleData scheduleData = new ScheduleData(); - scheduleData.setDepartmentId(request.getDepartmentId()); - scheduleData.setGroupId(request.getGroupId()); - scheduleData.setSubjectsId(request.getSubjectsId()); - scheduleData.setLessonTypeId(request.getLessonTypeId()); - scheduleData.setNumberOfHours(request.getNumberOfHours()); - scheduleData.setDivision(request.getDivision()); - scheduleData.setTeacherId(request.getTeacherId()); - scheduleData.setSemesterType(request.getSemesterType()); - scheduleData.setPeriod(request.getPeriod()); - - ScheduleData savedSchedule = scheduleDataRepository.save(scheduleData); - - Map response = new LinkedHashMap<>(); - response.put("id", savedSchedule.getId()); - response.put("departmentId", savedSchedule.getDepartmentId()); - response.put("groupId", savedSchedule.getGroupId()); - response.put("subjectId", savedSchedule.getSubjectsId()); - response.put("lessonTypeId", savedSchedule.getLessonTypeId()); - response.put("numberOfHours", savedSchedule.getNumberOfHours()); - response.put("isDivision", savedSchedule.getDivision()); - response.put("teacherId", savedSchedule.getTeacherId()); - response.put("semesterType", savedSchedule.getSemesterType()); - response.put("period", savedSchedule.getPeriod()); - - logger.info("Запись успешно создана с ID: {}", savedSchedule.getId()); - - return ResponseEntity.ok(response); - } catch (org.springframework.dao.DataIntegrityViolationException e) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("message", "Такая запись уже существует")); - } catch (Exception e) { - logger.error("Ошибка при создании записи: {}", e.getMessage(), e); - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании записи: " + e.getMessage())); - } - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteById(@PathVariable Long id) { - logger.info("Получен запрос на удаление записи с ID: {}", id); - if(!scheduleDataRepository.existsById(id)) { - logger.info("Запись с ID - {} не найдена", id); - return ResponseEntity.notFound().build(); - } - scheduleDataRepository.deleteById(id); - logger.info("Запись с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Запись удалена")); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java new file mode 100644 index 0000000..12c2f78 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java @@ -0,0 +1,148 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.ScheduleOverrideDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/edu-office/schedule/overrides") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) +public class ScheduleOverrideController { + + private final ScheduleOverrideRepository scheduleOverrideRepository; + private final ScheduleRuleSlotRepository scheduleRuleSlotRepository; + private final TimeSlotRepository timeSlotRepository; + private final ClassroomRepository classroomRepository; + private final UserRepository userRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository, + ScheduleRuleSlotRepository scheduleRuleSlotRepository, + TimeSlotRepository timeSlotRepository, + ClassroomRepository classroomRepository, + UserRepository userRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleOverrideRepository = scheduleOverrideRepository; + this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; + this.timeSlotRepository = timeSlotRepository; + this.classroomRepository = classroomRepository; + this.userRepository = userRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getAll() { + return scheduleOverrideRepository.findAll().stream() + .map(this::toDto) + .toList(); + } + + @PostMapping + public ResponseEntity create(@RequestBody ScheduleOverrideDto request) { + return save(null, request); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) { + if (!scheduleOverrideRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return save(id, request); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!scheduleOverrideRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + scheduleOverrideRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено")); + } + + private ResponseEntity save(Long id, ScheduleOverrideDto request) { + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + ScheduleOverride override = id == null + ? scheduleOverrideRepository + .findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate()) + .orElseGet(ScheduleOverride::new) + : scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new); + + override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow()); + override.setLessonDate(request.lessonDate()); + override.setAction(request.action()); + override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null)); + override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null)); + override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null)); + override.setNewLessonFormat(clean(request.newLessonFormat())); + override.setComment(clean(request.comment())); + override.setCreatedBy(AuthContext.currentUserId()); + + ScheduleOverride saved = scheduleOverrideRepository.save(override); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } + + private String validate(ScheduleOverrideDto request) { + if (request == null) { + return "Передайте данные изменения расписания"; + } + if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) { + return "Базовый слот расписания не найден"; + } + if (request.lessonDate() == null) { + return "Дата пары обязательна"; + } + if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) { + return "Недопустимое действие изменения расписания"; + } + if (request.newClassroomId() != null) { + Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null); + if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) { + return "Активная аудитория не найдена"; + } + } + if (request.newTeacherId() != null) { + User teacher = userRepository.findById(request.newTeacherId()).orElse(null); + if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) { + return "Активный преподаватель не найден"; + } + } + if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) { + return "Временной слот не найден"; + } + return null; + } + + private ScheduleOverrideDto toDto(ScheduleOverride override) { + return new ScheduleOverrideDto( + override.getId(), + override.getBaseRuleSlot().getId(), + override.getLessonDate(), + override.getAction(), + override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(), + override.getNewClassroom() == null ? null : override.getNewClassroom().getId(), + override.getNewTeacher() == null ? null : override.getNewTeacher().getId(), + override.getNewLessonFormat(), + override.getComment(), + override.getCreatedBy(), + override.getCreatedAt() + ); + } + + private String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java new file mode 100644 index 0000000..b0624b4 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -0,0 +1,405 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.ScheduleRuleDto; +import com.magistr.app.dto.ScheduleRuleSlotDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.time.DayOfWeek; +import java.util.*; + +@RestController +@RequestMapping("/api/admin/schedule-rules") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) +public class ScheduleRuleAdminController { + + private static final String APPLY_MODE_DEFAULT = "DEFAULT"; + + private final ScheduleRuleRepository scheduleRuleRepository; + private final SubjectRepository subjectRepository; + private final SemesterRepository semesterRepository; + private final GroupRepository groupRepository; + private final TimeSlotRepository timeSlotRepository; + private final UserRepository userRepository; + private final ClassroomRepository classroomRepository; + private final LessonTypesRepository lessonTypesRepository; + private final SubgroupRepository subgroupRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository, + SubjectRepository subjectRepository, + SemesterRepository semesterRepository, + GroupRepository groupRepository, + TimeSlotRepository timeSlotRepository, + UserRepository userRepository, + ClassroomRepository classroomRepository, + LessonTypesRepository lessonTypesRepository, + SubgroupRepository subgroupRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleRuleRepository = scheduleRuleRepository; + this.subjectRepository = subjectRepository; + this.semesterRepository = semesterRepository; + this.groupRepository = groupRepository; + this.timeSlotRepository = timeSlotRepository; + this.userRepository = userRepository; + this.classroomRepository = classroomRepository; + this.lessonTypesRepository = lessonTypesRepository; + this.subgroupRepository = subgroupRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getAll(@RequestParam(required = false) Long semesterId, + @RequestParam(required = false) Long groupId) { + return scheduleRuleRepository.findAllWithDetails().stream() + .filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId)) + .filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId))) + .map(this::toDto) + .toList(); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + return scheduleRuleRepository.findByIdWithDetails(id) + .>map(rule -> ResponseEntity.ok(toDto(rule))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + @Transactional + public ResponseEntity create(@RequestBody ScheduleRuleDto request) { + String validationError = validateRule(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + try { + ScheduleRule rule = new ScheduleRule(); + applyRule(rule, request); + ScheduleRule saved = scheduleRuleRepository.save(rule); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @PutMapping("/{id}") + @Transactional + public ResponseEntity update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) { + ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null); + if (rule == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validateRule(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + try { + applyRule(rule, request); + ScheduleRule saved = scheduleRuleRepository.save(rule); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null); + if (rule == null) { + return ResponseEntity.notFound().build(); + } + rule.setStatus(LifecycleEntity.STATUS_ARCHIVED); + scheduleRuleRepository.save(rule); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано")); + } + + private String validateRule(ScheduleRuleDto request) { + if (request.subjectId() == null) { + return "Дисциплина обязательна"; + } + if (request.semesterId() == null) { + return "Семестр обязателен"; + } + String hoursValidationError = validateTypeHours(request); + if (hoursValidationError != null) { + return hoursValidationError; + } + if (request.groupIds() == null || request.groupIds().isEmpty()) { + return "Нужно выбрать хотя бы одну группу"; + } + if (request.slots() == null || request.slots().isEmpty()) { + return "Добавьте хотя бы один слот занятия"; + } + return null; + } + + private void applyRule(ScheduleRule rule, ScheduleRuleDto request) { + Subject subject = subjectRepository.findById(request.subjectId()) + .orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена")); + if (subject.isArchivedRecord()) { + throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах"); + } + Semester semester = semesterRepository.findById(request.semesterId()) + .orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); + List groups = groupRepository.findAllById(request.groupIds()); + if (groups.size() != new HashSet<>(request.groupIds()).size()) { + throw new IllegalArgumentException("Одна или несколько групп не найдены"); + } + if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) { + throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах"); + } + + rule.setSubject(subject); + rule.setSemester(semester); + rule.setLectureAcademicHours(request.lectureAcademicHours()); + rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours()); + rule.setPracticeAcademicHours(request.practiceAcademicHours()); + rule.setLectureStartWeek(request.lectureStartWeek()); + rule.setLaboratoryStartWeek(request.laboratoryStartWeek()); + rule.setPracticeStartWeek(request.practiceStartWeek()); + rule.getGroups().clear(); + rule.getGroups().addAll(groups); + + List slots = new ArrayList<>(); + for (ScheduleRuleSlotDto slotDto : request.slots()) { + slots.add(buildSlot(rule, slotDto)); + } + validateTypeCoverage(rule, slots); + + rule.getSlots().clear(); + rule.getSlots().addAll(slots); + } + + private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) { + if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) { + throw new IllegalArgumentException("День недели должен быть от 1 до 7"); + } + if (slotDto.parity() == null) { + throw new IllegalArgumentException("Чётность недели обязательна"); + } + TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId()) + .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); + if (timeSlot.getTimeSlotScope() == null + || !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) { + throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты"); + } + User teacher = userRepository.findById(slotDto.teacherId()) + .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); + if (teacher.isArchivedRecord()) { + throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание"); + } + Classroom classroom = classroomRepository.findById(slotDto.classroomId()) + .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")); + if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) { + throw new IllegalArgumentException("Аудитория недоступна для новых назначений"); + } + LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) + .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); + ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType); + Set subgroups = resolveSubgroups(rule, slotDto, category); + if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) { + throw new IllegalArgumentException("Формат занятия обязателен"); + } + + ScheduleRuleSlot slot = new ScheduleRuleSlot(); + slot.setScheduleRule(rule); + slot.setDayOfWeek(slotDto.dayOfWeek()); + slot.setParity(slotDto.parity()); + slot.setTimeSlot(timeSlot); + slot.setSubgroups(subgroups); + slot.setTeacher(teacher); + slot.setClassroom(classroom); + slot.setLessonType(lessonType); + slot.setLessonFormat(slotDto.lessonFormat().trim()); + return slot; + } + + private Set resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) { + LinkedHashSet subgroupIds = new LinkedHashSet<>(); + if (slotDto.subgroupIds() != null) { + slotDto.subgroupIds().stream() + .filter(Objects::nonNull) + .forEach(subgroupIds::add); + } + if (slotDto.subgroupId() != null) { + subgroupIds.add(slotDto.subgroupId()); + } + if (subgroupIds.isEmpty()) { + return new LinkedHashSet<>(); + } + + if (category != ScheduleLessonCategory.LABORATORY) { + throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий"); + } + + Set ruleGroupIds = rule.getGroups().stream() + .map(StudentGroup::getId) + .collect(java.util.stream.Collectors.toSet()); + Set subgroupGroupIds = new HashSet<>(); + LinkedHashSet subgroups = new LinkedHashSet<>(); + + for (Long subgroupId : subgroupIds) { + Subgroup subgroup = subgroupRepository.findById(subgroupId) + .orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена")); + Long subgroupGroupId = subgroup.getStudentGroup().getId(); + if (!ruleGroupIds.contains(subgroupGroupId)) { + throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила"); + } + if (!subgroupGroupIds.add(subgroupGroupId)) { + throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы"); + } + subgroups.add(subgroup); + } + + return subgroups; + } + + private String validateTypeHours(ScheduleRuleDto request) { + if (isMissing(request.lectureAcademicHours()) + || isMissing(request.laboratoryAcademicHours()) + || isMissing(request.practiceAcademicHours())) { + return "Укажите количество часов для всех типов занятий"; + } + if (isNegative(request.lectureAcademicHours()) + || isNegative(request.laboratoryAcademicHours()) + || isNegative(request.practiceAcademicHours())) { + return "Количество часов по типам занятий не может быть отрицательным"; + } + if (isInvalidStartWeek(request.lectureStartWeek()) + || isInvalidStartWeek(request.laboratoryStartWeek()) + || isInvalidStartWeek(request.practiceStartWeek())) { + return "Неделя начала занятий должна быть больше нуля"; + } + int totalHours = valueOrZero(request.lectureAcademicHours()) + + valueOrZero(request.laboratoryAcademicHours()) + + valueOrZero(request.practiceAcademicHours()); + if (totalHours <= 0) { + return "Укажите часы хотя бы для одного типа занятий"; + } + return null; + } + + private void validateTypeCoverage(ScheduleRule rule, List slots) { + EnumSet categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class); + for (ScheduleRuleSlot slot : slots) { + categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType())); + } + + for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) { + int hours = rule.academicHoursFor(category); + boolean hasSlots = categoriesWithSlots.contains(category); + if (hours > 0 && !hasSlots) { + throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот"); + } + if (hours == 0 && hasSlots) { + throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа"); + } + } + } + + private boolean isMissing(Integer value) { + return value == null; + } + + private boolean isNegative(Integer value) { + return value < 0; + } + + private boolean isInvalidStartWeek(Integer value) { + return value == null || value <= 0; + } + + private int valueOrZero(Integer value) { + return value == null ? 0 : value; + } + + private ScheduleRuleDto toDto(ScheduleRule rule) { + List groups = rule.getGroups().stream() + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + List slots = rule.getSlots().stream() + .sorted(Comparator + .comparing(ScheduleRuleSlot::getDayOfWeek) + .thenComparing(slot -> slot.getTimeSlot().getOrderNumber()) + .thenComparing(ScheduleRuleSlot::getId)) + .map(this::toSlotDto) + .toList(); + + return new ScheduleRuleDto( + rule.getId(), + rule.getSubject().getId(), + rule.getSubject().getName(), + rule.getSemester().getId(), + rule.getSemester().getAcademicYear().getTitle(), + rule.getSemester().getSemesterType(), + rule.getLectureAcademicHours(), + rule.getLaboratoryAcademicHours(), + rule.getPracticeAcademicHours(), + rule.getLectureStartWeek(), + rule.getLaboratoryStartWeek(), + rule.getPracticeStartWeek(), + groups.stream().map(StudentGroup::getId).toList(), + groups.stream().map(StudentGroup::getName).toList(), + slots + ); + } + + private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) { + TimeSlot timeSlot = slot.getTimeSlot(); + List sortedSubgroups = sortedSubgroups(slot); + return new ScheduleRuleSlotDto( + slot.getId(), + slot.getDayOfWeek(), + dayName(slot.getDayOfWeek()), + slot.getParity(), + timeSlot.getId(), + timeSlot.getOrderNumber(), + formatTimeSlot(timeSlot), + sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(), + sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)), + sortedSubgroups.stream().map(Subgroup::getId).toList(), + sortedSubgroups.stream().map(this::formatSubgroup).toList(), + slot.getTeacher().getId(), + ScheduleGeneratorService.displayUserName(slot.getTeacher()), + slot.getClassroom().getId(), + slot.getClassroom().getName(), + slot.getLessonType().getId(), + slot.getLessonType().getLessonType(), + slot.getLessonFormat() + ); + } + + private String dayName(Integer dayOfWeek) { + if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) { + return "Неизвестно"; + } + return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek)); + } + + private String formatTimeSlot(TimeSlot timeSlot) { + return timeSlot.getStartTime() + " - " + timeSlot.getEndTime(); + } + + private String formatSubgroup(Subgroup subgroup) { + return subgroup.getStudentGroup().getName() + ": " + subgroup.getName(); + } + + private List sortedSubgroups(ScheduleRuleSlot slot) { + return slot.getSubgroups().stream() + .sorted(Comparator + .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName()) + .thenComparing(Subgroup::getName)) + .toList(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java new file mode 100644 index 0000000..5d5c820 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java @@ -0,0 +1,63 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.model.Role; +import com.magistr.app.service.ScheduleQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.Map; + +@RestController +@RequestMapping("/api/schedule/search") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT}) +public class ScheduleSearchController { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleSearchController.class); + + private final ScheduleQueryService scheduleQueryService; + + public ScheduleSearchController(ScheduleQueryService scheduleQueryService) { + this.scheduleQueryService = scheduleQueryService; + } + + @GetMapping + public ResponseEntity search( + @RequestParam(required = false) Long groupId, + @RequestParam(required = false) Long teacherId, + @RequestParam(required = false) Long classroomId, + @RequestParam(required = false) Long departmentId, + @RequestParam(required = false) Long subjectId, + @RequestParam(required = false) Long lessonTypeId, + @RequestParam(required = false) Long timeSlotId, + @RequestParam(required = false) String parity, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}", + groupId, teacherId, classroomId, departmentId, startDate, endDate); + try { + return ResponseEntity.ok(scheduleQueryService.search( + groupId, + teacherId, + classroomId, + departmentId, + subjectId, + lessonTypeId, + timeSlotId, + parity, + startDate, + endDate + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } catch (Exception e) { + logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания")); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java index 92db85a..ed2d9d4 100644 --- a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java +++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java @@ -1,9 +1,15 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateSpecialityRequest; import com.magistr.app.dto.SpecialityResponse; +import com.magistr.app.dto.SpecialtyProfileDto; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.repository.SpecialtiesRepository; +import com.magistr.app.repository.SpecialtyProfileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -15,21 +21,27 @@ import java.util.Map; @RestController @RequestMapping("/api/specialties") +@RequireRoles({Role.ADMIN}) public class SpecialityController { private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class); private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository specialtyProfileRepository; - public SpecialityController(SpecialtiesRepository specialtiesRepository) { + public SpecialityController(SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository specialtyProfileRepository) { this.specialtiesRepository = specialtiesRepository; + this.specialtyProfileRepository = specialtyProfileRepository; } @GetMapping - public List getAllSpecialties() { + public List getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение списка специальностей"); try { - List specialities = specialtiesRepository.findAll(); + List specialities = includeArchived + ? specialtiesRepository.findAll() + : specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = specialities.stream() .map( s -> new Speciality( s.getId(), @@ -75,6 +87,10 @@ public class SpecialityController { speciality.setSpecialityName(request.getSpecialityName()); speciality.setSpecialityCode(request.getSpecialityCode()); specialtiesRepository.save(speciality); + SpecialtyProfile defaultProfile = new SpecialtyProfile(); + defaultProfile.setSpeciality(speciality); + defaultProfile.setName("Без профиля"); + specialtyProfileRepository.save(defaultProfile); logger.info("Специальность успешно создана с ID: {}", speciality.getId()); @@ -92,15 +108,182 @@ public class SpecialityController { } } - @DeleteMapping("/id") - public ResponseEntity deleteSpeciality(@PathVariable Long id) { - logger.info("Получен запрос на удаление специальности с ID: {}", id); - if (!specialtiesRepository.existsById(id)) { + @PutMapping("/{id}") + public ResponseEntity updateSpeciality(@PathVariable Long id, @RequestBody CreateSpecialityRequest request) { + logger.info("Получен запрос на обновление специальности с ID: {}", id); + + Speciality speciality = specialtiesRepository.findById(id).orElse(null); + if (speciality == null) { logger.info("Специальность с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } - specialtiesRepository.deleteById(id); - logger.info("Специальность с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Специальнсть удалена")); + + try { + if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) { + String errorMessage = "Название специальности обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) { + String errorMessage = "Код специальности обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + String specialityName = request.getSpecialityName().trim(); + String specialityCode = request.getSpecialityCode().trim(); + if (specialtiesRepository.findBySpecialityName(specialityName) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Специальность с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (specialtiesRepository.findBySpecialityCode(specialityCode) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Специальность с таким кодом уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + speciality.setSpecialityName(specialityName); + speciality.setSpecialityCode(specialityCode); + specialtiesRepository.save(speciality); + + logger.info("Специальность с ID - {} успешно обновлена", id); + return ResponseEntity.ok(new SpecialityResponse( + speciality.getId(), + speciality.getSpecialityName(), + speciality.getSpecialityCode() + )); + } catch (Exception e) { + logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteSpeciality(@PathVariable Long id) { + logger.info("Получен запрос на удаление специальности с ID: {}", id); + Speciality speciality = specialtiesRepository.findById(id).orElse(null); + if (speciality == null) { + logger.info("Специальность с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + speciality.archive("Специальность архивирована"); + specialtiesRepository.save(speciality); + logger.info("Специальность с ID - {} успешно архивирована", id); + return ResponseEntity.ok(Map.of("message", "Специальность архивирована")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreSpeciality(@PathVariable Long id) { + Speciality speciality = specialtiesRepository.findById(id).orElse(null); + if (speciality == null) { + return ResponseEntity.notFound().build(); + } + speciality.restore(); + specialtiesRepository.save(speciality); + return ResponseEntity.ok(new SpecialityResponse( + speciality.getId(), + speciality.getSpecialityName(), + speciality.getSpecialityCode() + )); + } + + @GetMapping("/{specialtyId}/profiles") + public ResponseEntity getProfiles(@PathVariable Long specialtyId) { + if (!specialtiesRepository.existsById(specialtyId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(specialtyProfileRepository.findBySpecialityIdOrderByNameAsc(specialtyId).stream() + .map(this::toProfileDto) + .toList()); + } + + @PostMapping("/{specialtyId}/profiles") + public ResponseEntity createProfile(@PathVariable Long specialtyId, @RequestBody SpecialtyProfileDto request) { + Speciality speciality = specialtiesRepository.findById(specialtyId).orElse(null); + if (speciality == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validateProfile(specialtyId, null, request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + SpecialtyProfile profile = new SpecialtyProfile(); + profile.setSpeciality(speciality); + profile.setName(request.name().trim()); + profile.setDescription(trimToNull(request.description())); + return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile))); + } + + @PutMapping("/{specialtyId}/profiles/{profileId}") + public ResponseEntity updateProfile(@PathVariable Long specialtyId, + @PathVariable Long profileId, + @RequestBody SpecialtyProfileDto request) { + SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) { + return ResponseEntity.notFound().build(); + } + String validationError = validateProfile(specialtyId, profileId, request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + profile.setName(request.name().trim()); + profile.setDescription(trimToNull(request.description())); + return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile))); + } + + @DeleteMapping("/{specialtyId}/profiles/{profileId}") + public ResponseEntity deleteProfile(@PathVariable Long specialtyId, @PathVariable Long profileId) { + SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) { + return ResponseEntity.notFound().build(); + } + try { + specialtyProfileRepository.delete(profile); + return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён")); + } catch (Exception e) { + logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e); + return ResponseEntity.badRequest() + .body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками")); + } + } + + private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) { + if (request == null) { + return "Передайте данные профиля"; + } + if (request.name() == null || request.name().isBlank()) { + return "Название профиля обязательно"; + } + return specialtyProfileRepository.findBySpecialityIdAndName(specialtyId, request.name().trim()) + .filter(existing -> !existing.getId().equals(profileId)) + .map(existing -> "Профиль с таким названием уже существует") + .orElse(null); + } + + private SpecialtyProfileDto toProfileDto(SpecialtyProfile profile) { + Speciality speciality = profile.getSpeciality(); + return new SpecialtyProfileDto( + profile.getId(), + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getName(), + profile.getDescription() + ); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); } } diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java new file mode 100644 index 0000000..498821b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -0,0 +1,146 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.SubgroupDto; +import com.magistr.app.model.Role; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.model.Subgroup; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.ScheduleRuleSlotRepository; +import com.magistr.app.repository.SubgroupRepository; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT}) +public class SubgroupController { + + private final SubgroupRepository subgroupRepository; + private final GroupRepository groupRepository; + private final ScheduleRuleSlotRepository scheduleRuleSlotRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public SubgroupController(SubgroupRepository subgroupRepository, + GroupRepository groupRepository, + ScheduleRuleSlotRepository scheduleRuleSlotRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.subgroupRepository = subgroupRepository; + this.groupRepository = groupRepository; + this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping("/api/subgroups") + public List getAll() { + return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream() + .map(this::toDto) + .toList(); + } + + @GetMapping("/api/groups/{groupId}/subgroups") + public ResponseEntity getByGroup(@PathVariable Long groupId) { + if (!groupRepository.existsById(groupId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(subgroupRepository.findByStudentGroupIdOrderByNameAsc(groupId).stream() + .map(this::toDto) + .toList()); + } + + @PostMapping("/api/groups/{groupId}/subgroups") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + public ResponseEntity create(@PathVariable Long groupId, @RequestBody SubgroupDto request) { + StudentGroup group = groupRepository.findById(groupId).orElse(null); + if (group == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); + } + + Subgroup subgroup = new Subgroup(); + subgroup.setStudentGroup(group); + subgroup.setName(request.name().trim()); + subgroup.setStudentCapacity(request.studentCapacity()); + Subgroup saved = subgroupRepository.save(subgroup); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } + + @PutMapping("/api/groups/{groupId}/subgroups/{id}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + public ResponseEntity update(@PathVariable Long groupId, + @PathVariable Long id, + @RequestBody SubgroupDto request) { + Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null); + if (subgroup == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + String newName = request.name().trim(); + boolean duplicateName = subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, newName) + && !subgroup.getName().equalsIgnoreCase(newName); + if (duplicateName) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); + } + + subgroup.setName(newName); + subgroup.setStudentCapacity(request.studentCapacity()); + Subgroup saved = subgroupRepository.save(subgroup); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } + + @DeleteMapping("/api/groups/{groupId}/subgroups/{id}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) + public ResponseEntity delete(@PathVariable Long groupId, @PathVariable Long id) { + Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null); + if (subgroup == null) { + return ResponseEntity.notFound().build(); + } + if (scheduleRuleSlotRepository.existsBySubgroupId(id)) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); + } + try { + subgroup.archive("Подгруппа архивирована"); + subgroupRepository.save(subgroup); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована")); + } catch (DataIntegrityViolationException e) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); + } + } + + private String validate(SubgroupDto request) { + if (request == null || request.name() == null || request.name().isBlank()) { + return "Название подгруппы обязательно"; + } + if (request.studentCapacity() != null && request.studentCapacity() < 0) { + return "Численность подгруппы не может быть отрицательной"; + } + return null; + } + + private SubgroupDto toDto(Subgroup subgroup) { + StudentGroup group = subgroup.getStudentGroup(); + return new SubgroupDto( + subgroup.getId(), + group.getId(), + group.getName(), + subgroup.getName(), + subgroup.getStudentCapacity() + ); + } +} 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 078c891..5674bb5 100755 --- a/backend/src/main/java/com/magistr/app/controller/SubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java @@ -1,7 +1,10 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.SubjectResponse; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.model.Subject; import com.magistr.app.repository.SubjectRepository; import org.slf4j.Logger; @@ -16,6 +19,7 @@ import java.util.Map; @RestController @RequestMapping("/api/subjects") +@RequireRoles({Role.ADMIN}) public class SubjectController { private static final Logger logger = LoggerFactory.getLogger(SubjectController.class); @@ -27,10 +31,13 @@ public class SubjectController { } @GetMapping - public List getAllSubjects() { + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех дисциплин"); try { - List subjects = subjectRepository.findAll(); + List subjects = includeArchived + ? subjectRepository.findAll() + : subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = subjects.stream() .map(s -> new Subject( s.getId(), @@ -48,10 +55,11 @@ public class SubjectController { } @GetMapping("/{departmentId}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getSubjectsByDepartmentId(@PathVariable Long departmentId) { logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId); try{ - List subjects = subjectRepository.findByDepartmentId(departmentId); + List subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); if(subjects.isEmpty()){ logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId); @@ -121,12 +129,31 @@ public class SubjectController { @DeleteMapping("/{id}") public ResponseEntity deleteSubject(@PathVariable Long id) { logger.info("Получен запрос на удаление дисциплины с ID: {}", id); - if (!subjectRepository.existsById(id)) { + Subject subject = subjectRepository.findById(id).orElse(null); + if (subject == null) { logger.info("Дисциплина с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } - subjectRepository.deleteById(id); - logger.info("Дисциплина с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Дисциплина удалена")); + subject.archive("Дисциплина архивирована"); + subjectRepository.save(subject); + logger.info("Дисциплина с ID - {} успешно архивирована", id); + return ResponseEntity.ok(Map.of("message", "Дисциплина архивирована")); + } + + @PostMapping("/{id}/restore") + @RequireRoles({Role.ADMIN}) + public ResponseEntity restoreSubject(@PathVariable Long id) { + Subject subject = subjectRepository.findById(id).orElse(null); + if (subject == null) { + return ResponseEntity.notFound().build(); + } + subject.restore(); + subjectRepository.save(subject); + return ResponseEntity.ok(new SubjectResponse( + subject.getId(), + subject.getName(), + subject.getCode(), + subject.getDepartmentId() + )); } } 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 022006f..4ad3985 100755 --- a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java @@ -1,8 +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.TeacherSubjectResponse; +import com.magistr.app.model.Role; +import com.magistr.app.model.Subject; import com.magistr.app.model.TeacherSubject; import com.magistr.app.model.TeacherSubjectId; +import com.magistr.app.model.User; import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.TeacherSubjectRepository; import com.magistr.app.repository.UserRepository; @@ -14,6 +19,7 @@ import java.util.Map; @RestController @RequestMapping("/api/teacher-subjects") +@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) public class TeacherSubjectController { private final TeacherSubjectRepository teacherSubjectRepository; @@ -31,6 +37,7 @@ public class TeacherSubjectController { @GetMapping public List getAll() { return teacherSubjectRepository.findAll().stream() + .filter(ts -> canManage(ts.getUser(), ts.getSubject())) .map(ts -> new TeacherSubjectResponse( ts.getUserId(), ts.getUser().getUsername(), @@ -48,12 +55,17 @@ public class TeacherSubjectController { if (userId == null || subjectId == null) { return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны")); } - if (!userRepository.existsById(userId)) { + User user = userRepository.findById(userId).orElse(null); + if (user == null || user.getRole() != Role.TEACHER || user.isArchivedRecord()) { return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден")); } - if (!subjectRepository.existsById(subjectId)) { + Subject subject = subjectRepository.findById(subjectId).orElse(null); + if (subject == null || subject.isArchivedRecord()) { return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена")); } + if (!canManage(user, subject)) { + return ResponseEntity.status(403).body(Map.of("message", "Кафедра может связывать только своих преподавателей и свои дисциплины")); + } TeacherSubjectId id = new TeacherSubjectId(userId, subjectId); if (teacherSubjectRepository.existsById(id)) { @@ -76,10 +88,30 @@ public class TeacherSubjectController { } TeacherSubjectId id = new TeacherSubjectId(userId, subjectId); - if (!teacherSubjectRepository.existsById(id)) { + TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null); + if (teacherSubject == null) { return ResponseEntity.notFound().build(); } + if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) { + return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки")); + } teacherSubjectRepository.deleteById(id); return ResponseEntity.ok(Map.of("message", "Привязка удалена")); } + + private boolean canManage(User teacher, Subject subject) { + var currentUser = AuthContext.getCurrentUser(); + if (currentUser == null || currentUser.role() == Role.ADMIN) { + return true; + } + if (currentUser.role() != Role.DEPARTMENT) { + return false; + } + Long departmentId = currentUser.departmentId(); + return departmentId != null + && teacher != null + && subject != null + && departmentId.equals(teacher.getDepartmentId()) + && departmentId.equals(subject.getDepartmentId()); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java new file mode 100644 index 0000000..efab2bb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java @@ -0,0 +1,306 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.TimeSlotDateAssignmentDto; +import com.magistr.app.dto.TimeSlotDto; +import com.magistr.app.dto.TimeSlotScopeDto; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.model.TimeSlotDateAssignment; +import com.magistr.app.model.TimeSlotScope; +import com.magistr.app.model.Role; +import com.magistr.app.repository.TimeSlotDateAssignmentRepository; +import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.TimeSlotScopeRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/time-slots") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) +public class TimeSlotAdminController { + + private static final String APPLY_MODE_DEFAULT = "DEFAULT"; + private static final String APPLY_MODE_WEEKDAY = "WEEKDAY"; + private static final String APPLY_MODE_MANUAL = "MANUAL"; + + private final TimeSlotRepository timeSlotRepository; + private final TimeSlotScopeRepository timeSlotScopeRepository; + private final TimeSlotDateAssignmentRepository dateAssignmentRepository; + + public TimeSlotAdminController(TimeSlotRepository timeSlotRepository, + TimeSlotScopeRepository timeSlotScopeRepository, + TimeSlotDateAssignmentRepository dateAssignmentRepository) { + this.timeSlotRepository = timeSlotRepository; + this.timeSlotScopeRepository = timeSlotScopeRepository; + this.dateAssignmentRepository = dateAssignmentRepository; + } + + @GetMapping + public List getAll() { + return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream() + .map(this::toDto) + .toList(); + } + + @GetMapping("/effective") + public ResponseEntity getEffective(@RequestParam LocalDate date) { + return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList()); + } + + @GetMapping("/scopes") + public List getScopes() { + return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream() + .map(this::toScopeDto) + .toList(); + } + + @PostMapping("/scopes") + public ResponseEntity createScope(@RequestBody TimeSlotScopeDto request) { + if (request.name() == null || request.name().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно")); + } + + TimeSlotScope scope = new TimeSlotScope(); + scope.setCode("manual_" + UUID.randomUUID().toString().replace("-", "")); + scope.setName(request.name().trim()); + scope.setApplyMode(APPLY_MODE_MANUAL); + scope.setDayOfWeek(null); + scope.setSystemScope(false); + scope.setDisplayOrder(nextManualDisplayOrder()); + return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope))); + } + + @PutMapping("/scopes/{id}") + public ResponseEntity updateScope(@PathVariable Long id, @RequestBody TimeSlotScopeDto request) { + TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null); + if (scope == null) { + return ResponseEntity.notFound().build(); + } + if (Boolean.TRUE.equals(scope.getSystemScope())) { + return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя изменять")); + } + if (request.name() == null || request.name().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно")); + } + scope.setName(request.name().trim()); + return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope))); + } + + @DeleteMapping("/scopes/{id}") + public ResponseEntity deleteScope(@PathVariable Long id) { + TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null); + if (scope == null) { + return ResponseEntity.notFound().build(); + } + if (Boolean.TRUE.equals(scope.getSystemScope())) { + return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя удалить")); + } + timeSlotScopeRepository.delete(scope); + return ResponseEntity.ok(Map.of("message", "Сетка времени удалена")); + } + + @GetMapping("/date-assignments") + public List getDateAssignments(@RequestParam(required = false) LocalDate startDate, + @RequestParam(required = false) LocalDate endDate) { + List assignments = startDate != null && endDate != null + ? dateAssignmentRepository.findByDateBetweenOrderByDateAsc(startDate, endDate) + : dateAssignmentRepository.findAllByOrderByDateAsc(); + return assignments.stream().map(this::toAssignmentDto).toList(); + } + + @PostMapping("/date-assignments") + public ResponseEntity saveDateAssignment(@RequestBody TimeSlotDateAssignmentDto request) { + if (request.date() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Дата обязательна")); + } + if (request.scopeId() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени обязательна")); + } + TimeSlotScope scope = timeSlotScopeRepository.findById(request.scopeId()).orElse(null); + if (scope == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени не найдена")); + } + if (!APPLY_MODE_MANUAL.equals(scope.getApplyMode())) { + return ResponseEntity.badRequest().body(Map.of("message", "Вручную можно применять только пользовательские сетки")); + } + + TimeSlotDateAssignment assignment = dateAssignmentRepository.findByDate(request.date()) + .orElseGet(TimeSlotDateAssignment::new); + assignment.setDate(request.date()); + assignment.setTimeSlotScope(scope); + return ResponseEntity.ok(toAssignmentDto(dateAssignmentRepository.save(assignment))); + } + + @DeleteMapping("/date-assignments/{id}") + public ResponseEntity deleteDateAssignment(@PathVariable Long id) { + if (!dateAssignmentRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + dateAssignmentRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Ручное назначение удалено")); + } + + @PostMapping + public ResponseEntity create(@RequestBody TimeSlotDto request) { + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (hasDuplicate(request, null)) { + return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары")); + } + + TimeSlot timeSlot = new TimeSlot(); + apply(timeSlot, request); + return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody TimeSlotDto request) { + TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null); + if (timeSlot == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (hasDuplicate(request, id)) { + return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары")); + } + + apply(timeSlot, request); + return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!timeSlotRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + try { + timeSlotRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); + } catch (DataIntegrityViolationException e) { + return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания")); + } + } + + private String validate(TimeSlotDto request) { + if (request.orderNumber() == null || request.orderNumber() <= 0) { + return "Номер пары должен быть больше нуля"; + } + if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) { + return "Выберите сетку времени"; + } + if (request.startTime() == null || request.endTime() == null) { + return "Время начала и окончания обязательно"; + } + if (!request.startTime().isBefore(request.endTime())) { + return "Время начала должно быть раньше времени окончания"; + } + return null; + } + + private void apply(TimeSlot timeSlot, TimeSlotDto request) { + timeSlot.setOrderNumber(request.orderNumber()); + timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId()) + .orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена"))); + timeSlot.setStartTime(request.startTime()); + timeSlot.setEndTime(request.endTime()); + int duration = request.durationMinutes() != null && request.durationMinutes() > 0 + ? request.durationMinutes() + : (int) Duration.between(request.startTime(), request.endTime()).toMinutes(); + timeSlot.setDurationMinutes(duration); + } + + private boolean hasDuplicate(TimeSlotDto request, Long currentId) { + return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber()) + .filter(existing -> currentId == null || !existing.getId().equals(currentId)) + .isPresent(); + } + + private List effectiveSlots(LocalDate date) { + TimeSlotScope defaultScope = defaultScope(); + TimeSlotScope effectiveScope = effectiveScope(date); + List defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId()); + if (effectiveScope.getId().equals(defaultScope.getId())) { + return defaultSlots; + } + + Map slotsByOrder = new LinkedHashMap<>(); + defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot)); + timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId()) + .forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot)); + return new ArrayList<>(slotsByOrder.values()); + } + + private TimeSlotScope effectiveScope(LocalDate date) { + return dateAssignmentRepository.findByDate(date) + .map(TimeSlotDateAssignment::getTimeSlotScope) + .or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek( + APPLY_MODE_WEEKDAY, + date.getDayOfWeek().getValue())) + .orElseGet(this::defaultScope); + } + + private TimeSlotScope defaultScope() { + return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT) + .orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена")); + } + + private int nextManualDisplayOrder() { + return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream() + .map(TimeSlotScope::getDisplayOrder) + .filter(value -> value != null) + .max(Integer::compareTo) + .orElse(100) + 10; + } + + private TimeSlotDto toDto(TimeSlot timeSlot) { + TimeSlotScope scope = timeSlot.getTimeSlotScope(); + return new TimeSlotDto( + timeSlot.getId(), + timeSlot.getOrderNumber(), + scope == null ? null : scope.getId(), + scope == null ? null : scope.getName(), + scope == null ? null : scope.getApplyMode(), + scope == null ? null : scope.getDayOfWeek(), + timeSlot.getStartTime(), + timeSlot.getEndTime(), + timeSlot.getDurationMinutes() + ); + } + + private TimeSlotScopeDto toScopeDto(TimeSlotScope scope) { + return new TimeSlotScopeDto( + scope.getId(), + scope.getCode(), + scope.getName(), + scope.getApplyMode(), + scope.getDayOfWeek(), + scope.getSystemScope(), + scope.getDisplayOrder() + ); + } + + private TimeSlotDateAssignmentDto toAssignmentDto(TimeSlotDateAssignment assignment) { + TimeSlotScope scope = assignment.getTimeSlotScope(); + return new TimeSlotDateAssignmentDto( + assignment.getId(), + assignment.getDate(), + scope.getId(), + scope.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 5f62585..e034fd2 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -1,43 +1,61 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.RequireRoles; 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.model.Department; +import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.Role; +import com.magistr.app.model.TeacherDepartmentAssignment; import com.magistr.app.model.User; import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/users") +@RequireRoles({Role.ADMIN}) public class UserController { private static final Logger logger = LoggerFactory.getLogger(UserController.class); private final UserRepository userRepository; private final DepartmentRepository departmentRepository; + private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository; private final BCryptPasswordEncoder passwordEncoder; - public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) { + public UserController(UserRepository userRepository, + BCryptPasswordEncoder passwordEncoder, + DepartmentRepository departmentRepository, + TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.departmentRepository = departmentRepository; + this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository; } @GetMapping - public List getAllUsers() { + public List getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех пользователей"); try { - List users = userRepository.findAll(); + List users = includeArchived + ? userRepository.findAll() + : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = users.stream() .map(u -> { @@ -45,13 +63,15 @@ public class UserController { .map(Department::getDepartmentName) .orElse("Неизвестно"); - return new UserResponse( + UserResponse userResponse = new UserResponse( u.getId(), u.getUsername(), u.getRole().name(), u.getFullName(), u.getJobTitle(), departmentName); + userResponse.setStatus(u.getStatus()); + return userResponse; }) .toList(); logger.info("Получено {} пользователей", response.size()); @@ -64,11 +84,12 @@ public class UserController { } @GetMapping("/teachers") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public List getTeachers() { logger.info("Запрос на получение пользователей с ролью 'Преподаватель'"); try { - List users = userRepository.findByRole(Role.TEACHER); + List users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED); List response = users.stream() .map(u -> { @@ -76,13 +97,15 @@ public class UserController { .map(Department::getDepartmentName) .orElse("Неизвестно"); - return new UserResponse( + UserResponse userResponse = new UserResponse( u.getId(), u.getUsername(), u.getRole().name(), u.getFullName(), u.getJobTitle(), departmentName); + userResponse.setStatus(u.getStatus()); + return userResponse; }) .toList(); logger.info("Получено {} преподавателей", response.size()); @@ -94,10 +117,15 @@ public class UserController { } @GetMapping("/teachers/{departmentId}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getTeachersByDepartmentId(@PathVariable Long departmentId){ logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); try { - List users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId); + List users = userRepository.findByRoleAndDepartmentIdAndStatusNot( + Role.TEACHER, + departmentId, + LifecycleEntity.STATUS_ARCHIVED + ); if (users.isEmpty()) { logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); @@ -178,6 +206,19 @@ public class UserController { user.setJobTitle(request.getJobTitle()); user.setDepartmentId(request.getDepartmentId()); userRepository.save(user); + if (role == Role.TEACHER) { + Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null); + if (department != null) { + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(user); + assignment.setDepartment(department); + assignment.setValidFrom(LocalDate.now()); + assignment.setPrimaryAssignment(true); + assignment.setComment("Начальная кафедра при создании пользователя"); + assignment.setCreatedBy(AuthContext.currentUserId()); + teacherDepartmentAssignmentRepository.save(assignment); + } + } logger.info("Пользователь успешно создан с ID: {}", user.getId()); @@ -192,12 +233,111 @@ public class UserController { @DeleteMapping("/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { logger.info("Получен запрос на удаление пользователя с ID: {}", id); - if (!userRepository.existsById(id)) { + User user = userRepository.findById(id).orElse(null); + if (user == null) { logger.info("Пользователь с ID - {} не найден", id); return ResponseEntity.notFound().build(); } - userRepository.deleteById(id); - logger.info("Пользователь с ID - {} успешно удалён", id); - return ResponseEntity.ok(Map.of("message", "Пользователь удалён")); + user.archive("Пользователь архивирован"); + userRepository.save(user); + logger.info("Пользователь с ID - {} успешно архивирован", id); + return ResponseEntity.ok(Map.of("message", "Пользователь архивирован")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreUser(@PathVariable Long id) { + User user = userRepository.findById(id).orElse(null); + if (user == null) { + return ResponseEntity.notFound().build(); + } + user.restore(); + userRepository.save(user); + return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), + user.getFullName(), user.getJobTitle(), user.getDepartmentId())); + } + + @GetMapping("/{id}/department-history") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT}) + public ResponseEntity getDepartmentHistory(@PathVariable Long id) { + if (!userRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream() + .map(this::toAssignmentDto) + .toList()); + } + + @PostMapping("/{id}/department-transfer") + @Transactional + public ResponseEntity transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) { + User teacher = userRepository.findById(id).orElse(null); + if (teacher == null || teacher.getRole() != Role.TEACHER) { + return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден")); + } + if (teacher.isArchivedRecord()) { + return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести")); + } + if (request.departmentId() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна")); + } + Department department = departmentRepository.findById(request.departmentId()).orElse(null); + if (department == null || department.isArchivedRecord()) { + return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена")); + } + LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom(); + + teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id) + .ifPresent(current -> { + if (current.getValidFrom().isBefore(validFrom)) { + current.setValidTo(validFrom.minusDays(1)); + teacherDepartmentAssignmentRepository.save(current); + } else { + teacherDepartmentAssignmentRepository.delete(current); + } + }); + + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(teacher); + assignment.setDepartment(department); + assignment.setValidFrom(validFrom); + assignment.setPrimaryAssignment(true); + assignment.setComment(request.comment()); + assignment.setCreatedBy(AuthContext.currentUserId()); + teacherDepartmentAssignmentRepository.save(assignment); + + teacher.setDepartmentId(department.getId()); + userRepository.save(teacher); + + return ResponseEntity.ok(toAssignmentDto(assignment)); + } + + @GetMapping("/teachers/by-department/{departmentId}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT}) + public ResponseEntity getTeachersByDepartmentAtDate(@PathVariable Long departmentId, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + LocalDate effectiveDate = date == null ? LocalDate.now() : date; + return ResponseEntity.ok(teacherDepartmentAssignmentRepository + .findDepartmentTeachersAtDate(departmentId, effectiveDate) + .stream() + .map(TeacherDepartmentAssignment::getTeacher) + .filter(User::isActiveRecord) + .map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), + user.getFullName(), user.getJobTitle(), user.getDepartmentId())) + .toList()); + } + + private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) { + return new TeacherDepartmentAssignmentDto( + assignment.getId(), + assignment.getTeacher().getId(), + assignment.getTeacher().getFullName(), + assignment.getDepartment().getId(), + assignment.getDepartment().getDepartmentName(), + assignment.getValidFrom(), + assignment.getValidTo(), + assignment.getPrimaryAssignment(), + assignment.getComment() + ); } } diff --git a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java new file mode 100644 index 0000000..7d27645 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java @@ -0,0 +1,188 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.ClassroomResponse; +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.dto.WorkloadSummaryDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.ClassroomRepository; +import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.ScheduleQueryService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/workload") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) +public class WorkloadController { + + private static final int ACADEMIC_HOURS_PER_LESSON = 2; + + private final ScheduleQueryService scheduleQueryService; + private final ClassroomRepository classroomRepository; + private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; + + public WorkloadController(ScheduleQueryService scheduleQueryService, + ClassroomRepository classroomRepository, + UserRepository userRepository, + DepartmentRepository departmentRepository) { + this.scheduleQueryService = scheduleQueryService; + this.classroomRepository = classroomRepository; + this.userRepository = userRepository; + this.departmentRepository = departmentRepository; + } + + @GetMapping("/teachers") + public List teacherWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + Map teachersById = userRepository.findAllById(lessons.stream() + .map(RenderedLessonDto::teacherId) + .filter(Objects::nonNull) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName, + teacherId -> { + User teacher = teachersById.get(teacherId); + return teacher == null ? null : teacher.getDepartmentId(); + }); + } + + @GetMapping("/classrooms") + public List classroomWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null); + } + + @GetMapping("/departments") + public List departmentWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + Map departments = departmentRepository.findAll().stream() + .collect(Collectors.toMap(Department::getId, Department::getDepartmentName)); + Map teachersById = userRepository.findAllById(lessons.stream() + .map(RenderedLessonDto::teacherId) + .filter(Objects::nonNull) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + + Map> byDepartment = lessons.stream() + .collect(Collectors.groupingBy(lesson -> { + User teacher = teachersById.get(lesson.teacherId()); + return teacher == null ? 0L : teacher.getDepartmentId(); + })); + + return byDepartment.entrySet().stream() + .map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + @GetMapping("/time-slots") + public List timeSlotWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + return summarize(lessons, RenderedLessonDto::timeSlotId, + lesson -> lesson.timeSlotOrder() == null + ? "Пара" + : lesson.timeSlotOrder() + " пара (" + lesson.startTime() + " - " + lesson.endTime() + ")", + ignored -> null); + } + + @GetMapping("/free-classrooms") + public List freeClassrooms( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam Long timeSlotId + ) { + Set busyClassroomIds = scheduleQueryService.search(null, null, null, null, + null, null, timeSlotId, null, date, date) + .stream() + .map(RenderedLessonDto::classroomId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream() + .filter(Classroom::getIsAvailable) + .filter(classroom -> !busyClassroomIds.contains(classroom.getId())) + .map(this::toClassroomResponse) + .toList(); + } + + private List summarize(List lessons, + Function idFn, + Function nameFn, + Function departmentFn) { + Map> grouped = lessons.stream() + .filter(lesson -> idFn.apply(lesson) != null) + .collect(Collectors.groupingBy(idFn, LinkedHashMap::new, Collectors.toList())); + return grouped.entrySet().stream() + .map(entry -> summary(entry.getKey(), nameFn.apply(entry.getValue().get(0)), departmentFn.apply(entry.getKey()), entry.getValue())) + .sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private WorkloadSummaryDto summary(Long id, String name, Long departmentId, List lessons) { + return new WorkloadSummaryDto( + id, + name, + departmentId, + departmentName(departmentId), + lessons.size(), + lessons.size() * ACADEMIC_HOURS_PER_LESSON, + lessons.stream() + .map(lesson -> lesson.date() + ":" + lesson.timeSlotId()) + .distinct() + .count() + ); + } + + private String departmentName(Long departmentId) { + if (departmentId == null) { + return null; + } + return departmentRepository.findById(departmentId) + .map(Department::getDepartmentName) + .orElse("Кафедра не найдена"); + } + + private ClassroomResponse toClassroomResponse(Classroom classroom) { + return new ClassroomResponse( + classroom.getId(), + classroom.getName(), + classroom.getCapacity(), + classroom.getBuilding(), + classroom.getFloor(), + classroom.getIsAvailable(), + classroom.getStatus(), + classroom.getArchivedAt(), + classroom.getArchiveReason(), + new ArrayList<>(classroom.getEquipments()) + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java new file mode 100644 index 0000000..d5a1fc3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record AcademicCalendarActivityTypeDto( + Long id, + String code, + String name, + Boolean allowSchedule, + String colorCode, + Integer displayOrder, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java new file mode 100644 index 0000000..2f0dd96 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java @@ -0,0 +1,21 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record AcademicCalendarDto( + Long id, + String title, + Long academicYearId, + String academicYearTitle, + LocalDate academicYearStartDate, + LocalDate academicYearEndDate, + Long specialtyId, + String specialtyCode, + String specialtyName, + Long specialtyProfileId, + String specialtyProfileName, + Long studyFormId, + String studyFormName, + Integer courseCount +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java new file mode 100644 index 0000000..29e4509 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java @@ -0,0 +1,17 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record AcademicCalendarGridDayDto( + Long id, + Long calendarId, + Integer courseNumber, + LocalDate date, + Integer weekNumber, + Integer dayOfWeek, + Long activityTypeId, + String activityCode, + String activityName, + Boolean allowSchedule +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java new file mode 100644 index 0000000..5671392 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java @@ -0,0 +1,13 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; +import java.util.List; + +public record AcademicYearDto( + Long id, + String title, + LocalDate startDate, + LocalDate endDate, + List semesters +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java b/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java index 72927e1..d608ca4 100755 --- a/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java @@ -5,6 +5,8 @@ import java.util.List; public class ClassroomRequest { private String name; private Integer capacity; + private String building; + private Integer floor; private Boolean isAvailable; private List equipmentIds; @@ -24,6 +26,22 @@ public class ClassroomRequest { this.capacity = capacity; } + public String getBuilding() { + return building; + } + + public void setBuilding(String building) { + this.building = building; + } + + public Integer getFloor() { + return floor; + } + + public void setFloor(Integer floor) { + this.floor = floor; + } + public Boolean getIsAvailable() { return isAvailable; } diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java index 35b46d5..a587057 100755 --- a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java @@ -1,23 +1,41 @@ package com.magistr.app.dto; import com.magistr.app.model.Equipment; +import java.time.LocalDateTime; import java.util.List; public class ClassroomResponse { private Long id; private String name; private Integer capacity; + private String building; + private Integer floor; private Boolean isAvailable; + private String status; + private LocalDateTime archivedAt; + private String archiveReason; private List equipments; public ClassroomResponse() { } - public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List equipments) { + public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor, + Boolean isAvailable, List equipments) { + this(id, name, capacity, building, floor, isAvailable, null, null, null, equipments); + } + + public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor, + Boolean isAvailable, String status, LocalDateTime archivedAt, + String archiveReason, List equipments) { this.id = id; this.name = name; this.capacity = capacity; + this.building = building; + this.floor = floor; this.isAvailable = isAvailable; + this.status = status; + this.archivedAt = archivedAt; + this.archiveReason = archiveReason; this.equipments = equipments; } @@ -45,6 +63,22 @@ public class ClassroomResponse { this.capacity = capacity; } + public String getBuilding() { + return building; + } + + public void setBuilding(String building) { + this.building = building; + } + + public Integer getFloor() { + return floor; + } + + public void setFloor(Integer floor) { + this.floor = floor; + } + public Boolean getIsAvailable() { return isAvailable; } @@ -53,6 +87,30 @@ public class ClassroomResponse { this.isAvailable = isAvailable; } + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getArchivedAt() { + return archivedAt; + } + + public void setArchivedAt(LocalDateTime archivedAt) { + this.archivedAt = archivedAt; + } + + public String getArchiveReason() { + return archiveReason; + } + + public void setArchiveReason(String archiveReason) { + this.archiveReason = archiveReason; + } + public List getEquipments() { return equipments; } diff --git a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java index 107482f..8f058d3 100755 --- a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java @@ -7,6 +7,8 @@ public class CreateGroupRequest { private Long educationFormId; private Long departmentId; private Integer yearStartStudy; + private Long specialtyId; + private Long specialtyProfileId; private Long specialityCode; public String getName() { @@ -56,4 +58,24 @@ public class CreateGroupRequest { public void setSpecialityCode(Long specialityCode) { this.specialityCode = specialityCode; } + + public Long getSpecialtyId() { + return specialtyId; + } + + public void setSpecialtyId(Long specialtyId) { + this.specialtyId = specialtyId; + } + + public Long getSpecialtyProfileId() { + return specialtyProfileId; + } + + public void setSpecialtyProfileId(Long specialtyProfileId) { + this.specialtyProfileId = specialtyProfileId; + } + + public Long getEffectiveSpecialtyId() { + return specialtyId != null ? specialtyId : specialityCode; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/CreateLessonRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateLessonRequest.java deleted file mode 100755 index 8276857..0000000 --- a/backend/src/main/java/com/magistr/app/dto/CreateLessonRequest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.magistr.app.dto; - -public class CreateLessonRequest { - - private Long teacherId; - private Long groupId; - private Long subjectId; - private String lessonFormat; - private String typeLesson; - private Long classroomId; - private String day; - private String week; - private String time; - - public CreateLessonRequest() { - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectId() { - return subjectId; - } - - public void setSubjectId(Long subjectId) { - this.subjectId = subjectId; - } - - public String getLessonFormat() { - return lessonFormat; - } - - public void setLessonFormat(String lessonFormat) { - this.lessonFormat = lessonFormat; - } - - public String getTypeLesson() { - return typeLesson; - } - - public void setTypeLesson(String typeLesson) { - this.typeLesson = typeLesson; - } - - public Long getClassroomId() { - return classroomId; - } - - public void setClassroomId(Long classroomId) { - this.classroomId = classroomId; - } - - public String getDay() { - return day; - } - - public void setDay(String day) { - this.day = day; - } - - public String getWeek() { - return week; - } - - public void setWeek(String week) { - this.week = week; - } - - public String getTime() { - return time; - } - - public void setTime(String time) { - this.time = time; - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java deleted file mode 100644 index 7160986..0000000 --- a/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.magistr.app.dto; - -import com.magistr.app.model.SemesterType; - -public class CreateScheduleDataRequest { - private Long id; - private Long departmentId; - private Long groupId; - private Long subjectsId; - private Long lessonTypeId; - private Long numberOfHours; - private Boolean division; - private Long teacherId; - private SemesterType semesterType; - private String period; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getDepartmentId() { - return departmentId; - } - - public void setDepartmentId(Long departmentId) { - this.departmentId = departmentId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectsId() { - return subjectsId; - } - - public void setSubjectsId(Long subjectsId) { - this.subjectsId = subjectsId; - } - - public Long getLessonTypeId() { - return lessonTypeId; - } - - public void setLessonTypeId(Long lessonTypeId) { - this.lessonTypeId = lessonTypeId; - } - - public Long getNumberOfHours() { - return numberOfHours; - } - - public void setNumberOfHours(Long numberOfHours) { - this.numberOfHours = numberOfHours; - } - - public Boolean getDivision() { - return division; - } - - public void setDivision(Boolean division) { - this.division = division; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public SemesterType getSemesterType() { - return semesterType; - } - - public void setSemesterType(SemesterType semesterType) { - this.semesterType = semesterType; - } - - public String getPeriod() { - return period; - } - - public void setPeriod(String period) { - this.period = period; - } -} diff --git a/backend/src/main/java/com/magistr/app/dto/DepartmentTransferRequest.java b/backend/src/main/java/com/magistr/app/dto/DepartmentTransferRequest.java new file mode 100644 index 0000000..4dd605a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/DepartmentTransferRequest.java @@ -0,0 +1,10 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record DepartmentTransferRequest( + Long departmentId, + LocalDate validFrom, + String comment +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java new file mode 100644 index 0000000..a949840 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record GroupCalendarAssignmentDto( + Long id, + Long groupId, + String groupName, + Long academicYearId, + String academicYearTitle, + Long calendarId, + String calendarTitle +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java index 8e6f174..4807f35 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -14,21 +14,26 @@ public class GroupResponse { private Integer yearStartStudy; private Integer course; private Integer semester; - private Long specialityCode; + private Long specialtyId; + private String specialtyCode; + private String specialtyName; + private Long specialtyProfileId; + private String specialtyProfileName; - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Integer semester, Long specialityCode) { - this.id = id; - this.name = name; - this.groupSize = groupSize; - this.educationFormId = educationFormId; - this.educationFormName = educationFormName; - this.departmentId = departmentId; - this.course = course; - this.semester = semester; - this.specialityCode = specialityCode; - } - - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer yearStartStudy, Long specialityCode) { + public GroupResponse(Long id, + String name, + Long groupSize, + Long educationFormId, + String educationFormName, + Long departmentId, + Integer yearStartStudy, + Integer course, + Integer semester, + Long specialtyId, + String specialtyCode, + String specialtyName, + Long specialtyProfileId, + String specialtyProfileName) { this.id = id; this.name = name; this.groupSize = groupSize; @@ -36,7 +41,13 @@ public class GroupResponse { this.educationFormName = educationFormName; this.departmentId = departmentId; this.yearStartStudy = yearStartStudy; - this.specialityCode = specialityCode; + this.course = course; + this.semester = semester; + this.specialtyId = specialtyId; + this.specialtyCode = specialtyCode; + this.specialtyName = specialtyName; + this.specialtyProfileId = specialtyProfileId; + this.specialtyProfileName = specialtyProfileName; } public Long getId() { @@ -76,6 +87,26 @@ public class GroupResponse { } public Long getSpecialityCode() { - return specialityCode; + return specialtyId; + } + + public Long getSpecialtyId() { + return specialtyId; + } + + public String getSpecialtyCode() { + return specialtyCode; + } + + public String getSpecialtyName() { + return specialtyName; + } + + public Long getSpecialtyProfileId() { + return specialtyProfileId; + } + + public String getSpecialtyProfileName() { + return specialtyProfileName; } } diff --git a/backend/src/main/java/com/magistr/app/dto/LessonResponse.java b/backend/src/main/java/com/magistr/app/dto/LessonResponse.java deleted file mode 100755 index c1ba061..0000000 --- a/backend/src/main/java/com/magistr/app/dto/LessonResponse.java +++ /dev/null @@ -1,171 +0,0 @@ -package com.magistr.app.dto; - - -import com.fasterxml.jackson.annotation.JsonInclude; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class LessonResponse { - - private Long id; - private Long teacherId; - private String teacherName; - private Long groupId; - private String groupName; - private String educationFormName; - private Long subjectId; - private String subjectName; - private String lessonFormat; - private String typeLesson; - private Long classroomId; - private String classroomName; - private String day; - private String week; - private String time; - - public LessonResponse() { - } - - public LessonResponse(Long id, Long teacherId, Long groupId, Long subjectId, String day, String week, String time) { - this.id = id; - this.teacherId = teacherId; - this.groupId = groupId; - this.subjectId = subjectId; - this.day = day; - this.week = week; - this.time = time; - } - - public LessonResponse(Long id, String teacherName, String groupName, String classroomName, String educationFormName, String subjectName, String typeLesson, String lessonFormat, String day, String week, String time) { - this.id = id; - this.teacherName = teacherName; - this.groupName = groupName; - this.classroomName = classroomName; - this.educationFormName = educationFormName; - this.subjectName = subjectName; - this.typeLesson = typeLesson; - this.lessonFormat = lessonFormat; - this.day = day; - this.week = week; - this.time = time; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public String getTeacherName() { - return teacherName; - } - - public void setTeacherName(String teacherName) { - this.teacherName = teacherName; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public String getGroupName() { - return groupName; - } - - public void setGroupName(String groupName) { - this.groupName = groupName; - } - - public String getTypeLesson() { - return typeLesson; - } - - public void setTypeLesson(String typeLesson) { - this.typeLesson = typeLesson; - } - - public String getLessonFormat() { - return lessonFormat; - } - - public void setLessonFormat(String lessonFormat) { - this.lessonFormat = lessonFormat; - } - - public Long getClassroomId() { - return classroomId; - } - - public void setClassroomId(Long classroomId) { - this.classroomId = classroomId; - } - - public String getClassroomName() { - return classroomName; - } - - public void setClassroomName(String classroomName) { - this.classroomName = classroomName; - } - - public String getEducationFormName() { - return educationFormName; - } - - public void setEducationFormName(String educationFormName) { - this.educationFormName = educationFormName; - } - - public Long getSubjectId() { - return subjectId; - } - - public void setSubjectId(Long subjectId) { - this.subjectId = subjectId; - } - - public String getSubjectName() { - return subjectName; - } - - public void setSubjectName(String subjectName) { - this.subjectName = subjectName; - } - - public String getDay() { - return day; - } - - public void setDay(String day) { - this.day = day; - } - - public String getWeek() { - return week; - } - - public void setWeek(String week) { - this.week = week; - } - - public String getTime() { - return time; - } - - public void setTime(String time) { - this.time = time; - } -} diff --git a/backend/src/main/java/com/magistr/app/dto/LessonTypeResponse.java b/backend/src/main/java/com/magistr/app/dto/LessonTypeResponse.java new file mode 100644 index 0000000..8e44247 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/LessonTypeResponse.java @@ -0,0 +1,7 @@ +package com.magistr.app.dto; + +public record LessonTypeResponse( + Long id, + String name +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java index 851619b..8b8b4e8 100755 --- a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java @@ -8,17 +8,23 @@ public class LoginResponse { private String role; private String redirect; private Long departmentId; + private Long userId; public LoginResponse() { } public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) { + this(success, message, token, role, redirect, departmentId, null); + } + + public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId, Long userId) { this.success = success; this.message = message; this.token = token; this.role = role; this.redirect = redirect; this.departmentId = departmentId; + this.userId = userId; } public boolean isSuccess() { @@ -68,4 +74,12 @@ public class LoginResponse { public void setDepartmentId(Long departmentId) { this.departmentId = departmentId; } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java new file mode 100644 index 0000000..b449fae --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -0,0 +1,41 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.ScheduleParity; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public record RenderedLessonDto( + Long scheduleRuleId, + Long scheduleRuleSlotId, + LocalDate date, + Integer dayOfWeek, + String dayName, + Integer weekNumber, + ScheduleParity parity, + Long timeSlotId, + Integer timeSlotOrder, + LocalTime startTime, + LocalTime endTime, + Long subjectId, + String subjectName, + Long teacherId, + String teacherName, + Long classroomId, + String classroomName, + Long lessonTypeId, + String lessonTypeName, + String lessonFormat, + Long subgroupId, + String subgroupName, + List subgroupIds, + List subgroupNames, + List groupIds, + List groupNames, + String activityType, + Integer lessonTypeAcademicHours, + Integer consumedLessonTypeAcademicHoursBeforeLesson, + Integer remainingLessonTypeAcademicHoursAfterLesson +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java new file mode 100644 index 0000000..86a6f5e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java @@ -0,0 +1,19 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record ScheduleOverrideDto( + Long id, + Long baseRuleSlotId, + LocalDate lessonDate, + String action, + Long newTimeSlotId, + Long newClassroomId, + Long newTeacherId, + String newLessonFormat, + String comment, + Long createdBy, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java deleted file mode 100644 index 36b8f28..0000000 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.magistr.app.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.magistr.app.model.SemesterType; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class ScheduleResponse { - private Long id; - private String specialityCode; - private Long departmentId; - private Long groupId; - private String groupName; - private Integer groupCourse; - private Integer groupSemester; - private Long subjectsId; - private String subjectName; - private Long lessonTypeId; - private String lessonType; - private Long numberOfHours; - private Boolean division; - private Long teacherId; - private String teacherName; - private String teacherJobTitle; - private SemesterType semesterType; - private String period; - - public ScheduleResponse(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, String lessonType, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) { - this.id = id; - this.departmentId = departmentId; - this.groupId = groupId; - this.subjectsId = subjectsId; - this.lessonTypeId = lessonTypeId; - this.numberOfHours = numberOfHours; - this.division = division; - this.teacherId = teacherId; - this.semesterType = semesterType; - this.period = period; - } - - public ScheduleResponse(Long id, Long departmentId, String specialityCode, String groupName, Integer groupCourse, Integer groupSemester, String subjectName, String lessonType, Long numberOfHours, Boolean division, String teacherName, String teacherJobTitle, SemesterType semesterType, String period) { - this.id = id; - this.departmentId = departmentId; - this.specialityCode = specialityCode; - this.groupName = groupName; - this.groupCourse = groupCourse; - this.groupSemester = groupSemester; - this.subjectName = subjectName; - this.lessonType = lessonType; - this.numberOfHours = numberOfHours; - this.division = division; - this.teacherName = teacherName; - this.teacherJobTitle = teacherJobTitle; - this.semesterType = semesterType; - this.period = period; - } - - public Long getId() { - return id; - } - - public String getSpecialityCode() { - return specialityCode; - } - - public Long getDepartmentId() { - return departmentId; - } - - public Long getGroupId() { - return groupId; - } - - public String getGroupName() { - return groupName; - } - - public Integer getGroupCourse() { - return groupCourse; - } - - public Integer getGroupSemester() { - return groupSemester; - } - - public Long getSubjectsId() { - return subjectsId; - } - - public String getSubjectName() { - return subjectName; - } - - public Long getLessonTypeId() { - return lessonTypeId; - } - - public String getLessonType() { - return lessonType; - } - - public Long getNumberOfHours() { - return numberOfHours; - } - - public Boolean getDivision() { - return division; - } - - public Long getTeacherId() { - return teacherId; - } - - public String getTeacherName() { - return teacherName; - } - - public String getTeacherJobTitle() { - return teacherJobTitle; - } - - public SemesterType getSemesterType() { - return semesterType; - } - - public String getPeriod() { - return period; - } -} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java new file mode 100644 index 0000000..95d5ad2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java @@ -0,0 +1,24 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.SemesterType; + +import java.util.List; + +public record ScheduleRuleDto( + Long id, + Long subjectId, + String subjectName, + Long semesterId, + String academicYearTitle, + SemesterType semesterType, + Integer lectureAcademicHours, + Integer laboratoryAcademicHours, + Integer practiceAcademicHours, + Integer lectureStartWeek, + Integer laboratoryStartWeek, + Integer practiceStartWeek, + List groupIds, + List groupNames, + List slots +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java new file mode 100644 index 0000000..bcdfb01 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java @@ -0,0 +1,27 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.ScheduleParity; + +import java.util.List; + +public record ScheduleRuleSlotDto( + Long id, + Integer dayOfWeek, + String dayName, + ScheduleParity parity, + Long timeSlotId, + Integer timeSlotOrder, + String timeSlotLabel, + Long subgroupId, + String subgroupName, + List subgroupIds, + List subgroupNames, + Long teacherId, + String teacherName, + Long classroomId, + String classroomName, + Long lessonTypeId, + String lessonTypeName, + String lessonFormat +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/SemesterDto.java b/backend/src/main/java/com/magistr/app/dto/SemesterDto.java new file mode 100644 index 0000000..16b22d0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SemesterDto.java @@ -0,0 +1,15 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.SemesterType; + +import java.time.LocalDate; + +public record SemesterDto( + Long id, + Long academicYearId, + String academicYearTitle, + SemesterType semesterType, + LocalDate startDate, + LocalDate endDate +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java b/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java new file mode 100644 index 0000000..2452621 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java @@ -0,0 +1,11 @@ +package com.magistr.app.dto; + +public record SpecialtyProfileDto( + Long id, + Long specialtyId, + String specialtyCode, + String specialtyName, + String name, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java b/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java new file mode 100644 index 0000000..b0747ef --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java @@ -0,0 +1,10 @@ +package com.magistr.app.dto; + +public record SubgroupDto( + Long id, + Long groupId, + String groupName, + String name, + Integer studentCapacity +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java b/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java new file mode 100644 index 0000000..73a3777 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java @@ -0,0 +1,13 @@ +package com.magistr.app.dto; + +import java.time.LocalDateTime; + +public record SubjectCommentDto( + Long id, + Long subjectId, + Long authorId, + String authorName, + String comment, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherDepartmentAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherDepartmentAssignmentDto.java new file mode 100644 index 0000000..f51c379 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherDepartmentAssignmentDto.java @@ -0,0 +1,16 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record TeacherDepartmentAssignmentDto( + Long id, + Long teacherId, + String teacherName, + Long departmentId, + String departmentName, + LocalDate validFrom, + LocalDate validTo, + Boolean primaryAssignment, + String comment +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotDateAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotDateAssignmentDto.java new file mode 100644 index 0000000..0448211 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotDateAssignmentDto.java @@ -0,0 +1,11 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record TimeSlotDateAssignmentDto( + Long id, + LocalDate date, + Long scopeId, + String scopeName +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java new file mode 100644 index 0000000..36a586b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java @@ -0,0 +1,16 @@ +package com.magistr.app.dto; + +import java.time.LocalTime; + +public record TimeSlotDto( + Long id, + Integer orderNumber, + Long scopeId, + String scopeName, + String scopeApplyMode, + Integer scopeDayOfWeek, + LocalTime startTime, + LocalTime endTime, + Integer durationMinutes +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotScopeDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotScopeDto.java new file mode 100644 index 0000000..52a653c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotScopeDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record TimeSlotScopeDto( + Long id, + String code, + String name, + String applyMode, + Integer dayOfWeek, + Boolean systemScope, + Integer displayOrder +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java index 2bc5ba7..fbcd2f3 100755 --- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -12,6 +12,7 @@ public class UserResponse { private String jobTitle; private String departmentName; private Long departmentId; + private String status; public UserResponse() { } @@ -69,4 +70,12 @@ public class UserResponse { public Long getDepartmentId() { return departmentId; } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/WorkloadSummaryDto.java b/backend/src/main/java/com/magistr/app/dto/WorkloadSummaryDto.java new file mode 100644 index 0000000..a5cac29 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/WorkloadSummaryDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record WorkloadSummaryDto( + Long id, + String name, + Long departmentId, + String departmentName, + long lessonCount, + int academicHours, + long occupiedSlotCount +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java new file mode 100644 index 0000000..f528be8 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java @@ -0,0 +1,90 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendars") +public class AcademicCalendar { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_profile_id", nullable = false) + private SpecialtyProfile specialtyProfile; + + @ManyToOne(optional = false) + @JoinColumn(name = "study_form_id", nullable = false) + private EducationForm studyForm; + + @Column(name = "course_count", nullable = false) + private Integer courseCount; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public Speciality getSpeciality() { + return speciality; + } + + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public SpecialtyProfile getSpecialtyProfile() { + return specialtyProfile; + } + + public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) { + this.specialtyProfile = specialtyProfile; + } + + public EducationForm getStudyForm() { + return studyForm; + } + + public void setStudyForm(EducationForm studyForm) { + this.studyForm = studyForm; + } + + public Integer getCourseCount() { + return courseCount; + } + + public void setCourseCount(Integer courseCount) { + this.courseCount = courseCount; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java new file mode 100644 index 0000000..826eccd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java @@ -0,0 +1,86 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendar_activity_types") +public class AcademicCalendarActivityType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 10) + private String code; + + @Column(nullable = false) + private String name; + + @Column(name = "allow_schedule", nullable = false) + private Boolean allowSchedule; + + @Column(name = "color_code", nullable = false, length = 7) + private String colorCode; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Boolean getAllowSchedule() { + return allowSchedule; + } + + public void setAllowSchedule(Boolean allowSchedule) { + this.allowSchedule = allowSchedule; + } + + public String getColorCode() { + return colorCode; + } + + public void setColorCode(String colorCode) { + this.colorCode = colorCode; + } + + public Integer getDisplayOrder() { + return displayOrder; + } + + public void setDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java new file mode 100644 index 0000000..b256ccb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java @@ -0,0 +1,90 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "academic_calendar_days") +public class AcademicCalendarDay { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "calendar_id", nullable = false) + private AcademicCalendar academicCalendar; + + @Column(name = "course_number", nullable = false) + private Integer courseNumber; + + @Column(nullable = false) + private LocalDate date; + + @Column(name = "week_number", nullable = false) + private Integer weekNumber; + + @Column(name = "day_of_week", nullable = false) + private Integer dayOfWeek; + + @ManyToOne(optional = false) + @JoinColumn(name = "activity_type_id", nullable = false) + private AcademicCalendarActivityType activityType; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AcademicCalendar getAcademicCalendar() { + return academicCalendar; + } + + public void setAcademicCalendar(AcademicCalendar academicCalendar) { + this.academicCalendar = academicCalendar; + } + + public Integer getCourseNumber() { + return courseNumber; + } + + public void setCourseNumber(Integer courseNumber) { + this.courseNumber = courseNumber; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public Integer getWeekNumber() { + return weekNumber; + } + + public void setWeekNumber(Integer weekNumber) { + this.weekNumber = weekNumber; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public AcademicCalendarActivityType getActivityType() { + return activityType; + } + + public void setActivityType(AcademicCalendarActivityType activityType) { + this.activityType = activityType; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicYear.java b/backend/src/main/java/com/magistr/app/model/AcademicYear.java new file mode 100644 index 0000000..87920c3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicYear.java @@ -0,0 +1,55 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "academic_years") +public class AcademicYear { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 20) + private String title; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Classroom.java b/backend/src/main/java/com/magistr/app/model/Classroom.java index 67b2840..aad7088 100755 --- a/backend/src/main/java/com/magistr/app/model/Classroom.java +++ b/backend/src/main/java/com/magistr/app/model/Classroom.java @@ -6,7 +6,7 @@ import java.util.Set; @Entity @Table(name = "classrooms") -public class Classroom { +public class Classroom extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -18,6 +18,11 @@ public class Classroom { @Column(nullable = false) private Integer capacity; + @Column(length = 50) + private String building; + + private Integer floor; + @Column(name = "is_available", nullable = false) private Boolean isAvailable = true; @@ -52,6 +57,22 @@ public class Classroom { this.capacity = capacity; } + public String getBuilding() { + return building; + } + + public void setBuilding(String building) { + this.building = building; + } + + public Integer getFloor() { + return floor; + } + + public void setFloor(Integer floor) { + this.floor = floor; + } + public Boolean getIsAvailable() { return isAvailable; } diff --git a/backend/src/main/java/com/magistr/app/model/Department.java b/backend/src/main/java/com/magistr/app/model/Department.java index d14905e..bb6584a 100644 --- a/backend/src/main/java/com/magistr/app/model/Department.java +++ b/backend/src/main/java/com/magistr/app/model/Department.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name="departments") -public class Department { +public class Department extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/EducationForm.java b/backend/src/main/java/com/magistr/app/model/EducationForm.java index a2f1c58..f854ead 100755 --- a/backend/src/main/java/com/magistr/app/model/EducationForm.java +++ b/backend/src/main/java/com/magistr/app/model/EducationForm.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "education_forms") -public class EducationForm { +public class EducationForm extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/Equipment.java b/backend/src/main/java/com/magistr/app/model/Equipment.java index a550410..17f4c30 100755 --- a/backend/src/main/java/com/magistr/app/model/Equipment.java +++ b/backend/src/main/java/com/magistr/app/model/Equipment.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "equipments") -public class Equipment { +public class Equipment extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/Lesson.java b/backend/src/main/java/com/magistr/app/model/Lesson.java deleted file mode 100755 index b28d27b..0000000 --- a/backend/src/main/java/com/magistr/app/model/Lesson.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name = "lessons") -public class Lesson { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "teacher_id", nullable = false) - private Long teacherId; - - @Column(name = "group_id", nullable = false) - private Long groupId; - - @Column(name = "subject_id", nullable = false) - private Long subjectId; - - @Column(name = "lesson_format", nullable = false, length = 255) - private String lessonFormat; - - @Column(name = "type_lesson", nullable = false, length = 255) - private String typeLesson; - - @Column(name = "classroom_id", nullable = false) - private Long classroomId; - - @Column(name = "day", nullable = false, length = 255) - private String day; - - @Column(name = "week", nullable = false, length = 255) - private String week; - - @Column(name = "time", nullable = false, length = 255) - private String time; - - public Lesson() { - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectId() { - return subjectId; - } - - public void setSubjectId(Long subjectId) { - this.subjectId = subjectId; - } - - public String getLessonFormat() { - return lessonFormat; - } - - public void setLessonFormat(String lessonFormat) { - this.lessonFormat = lessonFormat; - } - - public String getTypeLesson() { - return typeLesson; - } - - public void setTypeLesson(String typeLesson) { - this.typeLesson = typeLesson; - } - - public Long getClassroomId() { - return classroomId; - } - - public void setClassroomId(Long classroomId) { - this.classroomId = classroomId; - } - - public String getDay() { - return day; - } - - public void setDay(String day) { - this.day = day; - } - - public String getWeek() { - return week; - } - - public void setWeek(String week) { - this.week = week; - } - - public String getTime() { - return time; - } - - public void setTime(String time) { - this.time = time; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java new file mode 100644 index 0000000..4ae5483 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java @@ -0,0 +1,104 @@ +package com.magistr.app.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@MappedSuperclass +public abstract class LifecycleEntity { + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_ARCHIVED = "ARCHIVED"; + + @Column(name = "status", nullable = false, length = 20) + private String status = STATUS_ACTIVE; + + @Column(name = "active_from") + private LocalDate activeFrom; + + @Column(name = "active_to") + private LocalDate activeTo; + + @Column(name = "archived_at") + private LocalDateTime archivedAt; + + @Column(name = "archive_reason") + private String archiveReason; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status == null || status.isBlank() ? STATUS_ACTIVE : status; + } + + public LocalDate getActiveFrom() { + return activeFrom; + } + + public void setActiveFrom(LocalDate activeFrom) { + this.activeFrom = activeFrom; + } + + public LocalDate getActiveTo() { + return activeTo; + } + + public void setActiveTo(LocalDate activeTo) { + this.activeTo = activeTo; + } + + public LocalDateTime getArchivedAt() { + return archivedAt; + } + + public void setArchivedAt(LocalDateTime archivedAt) { + this.archivedAt = archivedAt; + } + + public String getArchiveReason() { + return archiveReason; + } + + public void setArchiveReason(String archiveReason) { + this.archiveReason = archiveReason; + } + + @JsonIgnore + public boolean isActiveRecord() { + return !STATUS_ARCHIVED.equals(status); + } + + @JsonIgnore + public boolean isArchivedRecord() { + return STATUS_ARCHIVED.equals(status); + } + + @JsonIgnore + public boolean isActiveOn(LocalDate date) { + if (date == null) { + return isActiveRecord(); + } + boolean afterStart = activeFrom == null || !date.isBefore(activeFrom); + boolean beforeEnd = activeTo == null || !date.isAfter(activeTo); + return afterStart && beforeEnd; + } + + public void archive(String reason) { + status = STATUS_ARCHIVED; + archivedAt = LocalDateTime.now(); + activeTo = LocalDate.now(); + archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim(); + } + + public void restore() { + status = STATUS_ACTIVE; + archivedAt = null; + archiveReason = null; + activeTo = null; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Role.java b/backend/src/main/java/com/magistr/app/model/Role.java index d2cfc71..72815c5 100755 --- a/backend/src/main/java/com/magistr/app/model/Role.java +++ b/backend/src/main/java/com/magistr/app/model/Role.java @@ -2,6 +2,9 @@ package com.magistr.app.model; public enum Role { ADMIN, + EDUCATION_OFFICE, + DEPARTMENT, + SCHEDULE_VIEWER, TEACHER, STUDENT } diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleData.java b/backend/src/main/java/com/magistr/app/model/ScheduleData.java deleted file mode 100644 index bdd485f..0000000 --- a/backend/src/main/java/com/magistr/app/model/ScheduleData.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name="schedule_data") -public class ScheduleData { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name="department_id", nullable = false) - private Long departmentId; - - @Column(name="group_id", nullable = false) - private Long groupId; - - @Column(name="subjects_id", nullable = false) - private Long subjectsId; - - @Column(name="lesson_type_id", nullable = false) - private Long lessonTypeId; - - @Column(name="number_of_hours", nullable = false) - private Long numberOfHours; - - @Column(name="is_division", nullable = false) - private Boolean division; - - @Column(name="teacher_id", nullable = false) - private Long teacherId; - - @Enumerated(EnumType.STRING) - @Column(name="semester_type", nullable = false) - private SemesterType semesterType; - - @Column(name="period", nullable = false) - private String period; - - public ScheduleData() {} - - public ScheduleData(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) { - this.id = id; - this.departmentId = departmentId; - this.groupId = groupId; - this.subjectsId = subjectsId; - this.lessonTypeId = lessonTypeId; - this.numberOfHours = numberOfHours; - this.division = division; - this.teacherId = teacherId; - this.semesterType = semesterType; - this.period = period; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getDepartmentId() { - return departmentId; - } - - public void setDepartmentId(Long departmentId) { - this.departmentId = departmentId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectsId() { - return subjectsId; - } - - public void setSubjectsId(Long subjectsId) { - this.subjectsId = subjectsId; - } - - public Long getLessonTypeId() { - return lessonTypeId; - } - - public void setLessonTypeId(Long lessonTypeId) { - this.lessonTypeId = lessonTypeId; - } - - public Long getNumberOfHours() { - return numberOfHours; - } - - public void setNumberOfHours(Long numberOfHours) { - this.numberOfHours = numberOfHours; - } - - public Boolean getDivision() { - return division; - } - - public void setDivision(Boolean division) { - this.division = division; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public SemesterType getSemesterType() { - return semesterType; - } - - public void setSemesterType(SemesterType semesterType) { - this.semesterType = semesterType; - } - - public String getPeriod() { - return period; - } - - public void setPeriod(String period) { - this.period = period; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java new file mode 100644 index 0000000..65a9c12 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java @@ -0,0 +1,36 @@ +package com.magistr.app.model; + +import java.util.Locale; + +public enum ScheduleLessonCategory { + LECTURE("Лекции"), + LABORATORY("Лабораторные"), + PRACTICE("Практики"); + + private final String displayName; + + ScheduleLessonCategory(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public static ScheduleLessonCategory fromLessonType(LessonType lessonType) { + if (lessonType == null || lessonType.getLessonType() == null) { + throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); + } + String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU")); + if (name.contains("лекц")) { + return LECTURE; + } + if (name.contains("лаб")) { + return LABORATORY; + } + if (name.contains("практ")) { + return PRACTICE; + } + throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java new file mode 100644 index 0000000..85c9bbb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java @@ -0,0 +1,133 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "schedule_overrides") +public class ScheduleOverride { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "base_rule_slot_id", nullable = false) + private ScheduleRuleSlot baseRuleSlot; + + @Column(name = "lesson_date", nullable = false) + private LocalDate lessonDate; + + @Column(nullable = false, length = 20) + private String action; + + @ManyToOne + @JoinColumn(name = "new_time_slot_id") + private TimeSlot newTimeSlot; + + @ManyToOne + @JoinColumn(name = "new_classroom_id") + private Classroom newClassroom; + + @ManyToOne + @JoinColumn(name = "new_teacher_id") + private User newTeacher; + + @Column(name = "new_lesson_format", length = 30) + private String newLessonFormat; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + public Long getId() { + return id; + } + + public ScheduleRuleSlot getBaseRuleSlot() { + return baseRuleSlot; + } + + public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) { + this.baseRuleSlot = baseRuleSlot; + } + + public LocalDate getLessonDate() { + return lessonDate; + } + + public void setLessonDate(LocalDate lessonDate) { + this.lessonDate = lessonDate; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public TimeSlot getNewTimeSlot() { + return newTimeSlot; + } + + public void setNewTimeSlot(TimeSlot newTimeSlot) { + this.newTimeSlot = newTimeSlot; + } + + public Classroom getNewClassroom() { + return newClassroom; + } + + public void setNewClassroom(Classroom newClassroom) { + this.newClassroom = newClassroom; + } + + public User getNewTeacher() { + return newTeacher; + } + + public void setNewTeacher(User newTeacher) { + this.newTeacher = newTeacher; + } + + public String getNewLessonFormat() { + return newLessonFormat; + } + + public void setNewLessonFormat(String newLessonFormat) { + this.newLessonFormat = newLessonFormat; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public Long getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(Long createdBy) { + this.createdBy = createdBy; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleParity.java b/backend/src/main/java/com/magistr/app/model/ScheduleParity.java new file mode 100644 index 0000000..421a3a3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleParity.java @@ -0,0 +1,7 @@ +package com.magistr.app.model; + +public enum ScheduleParity { + BOTH, + EVEN, + ODD +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java new file mode 100644 index 0000000..c0bbb1c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -0,0 +1,198 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "schedule_rules") +public class ScheduleRule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "subject_id", nullable = false) + private Subject subject; + + @ManyToOne(optional = false) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @Column(nullable = false, length = 20) + private String status = LifecycleEntity.STATUS_ACTIVE; + + @Column(name = "valid_from") + private LocalDate validFrom; + + @Column(name = "valid_to") + private LocalDate validTo; + + @Column(name = "lecture_academic_hours", nullable = false) + private Integer lectureAcademicHours = 0; + + @Column(name = "laboratory_academic_hours", nullable = false) + private Integer laboratoryAcademicHours = 0; + + @Column(name = "practice_academic_hours", nullable = false) + private Integer practiceAcademicHours = 0; + + @Column(name = "lecture_start_week", nullable = false) + private Integer lectureStartWeek = 1; + + @Column(name = "laboratory_start_week", nullable = false) + private Integer laboratoryStartWeek = 1; + + @Column(name = "practice_start_week", nullable = false) + private Integer practiceStartWeek = 1; + + @ManyToMany + @JoinTable( + name = "schedule_rule_groups", + joinColumns = @JoinColumn(name = "schedule_rule_id"), + inverseJoinColumns = @JoinColumn(name = "group_id") + ) + private Set groups = new HashSet<>(); + + @OneToMany(mappedBy = "scheduleRule", cascade = CascadeType.ALL, orphanRemoval = true) + private Set slots = new HashSet<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Subject getSubject() { + return subject; + } + + public void setSubject(Subject subject) { + this.subject = subject; + } + + public Semester getSemester() { + return semester; + } + + public void setSemester(Semester semester) { + this.semester = semester; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDate getValidFrom() { + return validFrom; + } + + public void setValidFrom(LocalDate validFrom) { + this.validFrom = validFrom; + } + + public LocalDate getValidTo() { + return validTo; + } + + public void setValidTo(LocalDate validTo) { + this.validTo = validTo; + } + + public Integer getLectureAcademicHours() { + return lectureAcademicHours; + } + + public void setLectureAcademicHours(Integer lectureAcademicHours) { + this.lectureAcademicHours = lectureAcademicHours; + } + + public Integer getLaboratoryAcademicHours() { + return laboratoryAcademicHours; + } + + public void setLaboratoryAcademicHours(Integer laboratoryAcademicHours) { + this.laboratoryAcademicHours = laboratoryAcademicHours; + } + + public Integer getPracticeAcademicHours() { + return practiceAcademicHours; + } + + public void setPracticeAcademicHours(Integer practiceAcademicHours) { + this.practiceAcademicHours = practiceAcademicHours; + } + + public Integer getLectureStartWeek() { + return lectureStartWeek; + } + + public void setLectureStartWeek(Integer lectureStartWeek) { + this.lectureStartWeek = lectureStartWeek; + } + + public Integer getLaboratoryStartWeek() { + return laboratoryStartWeek; + } + + public void setLaboratoryStartWeek(Integer laboratoryStartWeek) { + this.laboratoryStartWeek = laboratoryStartWeek; + } + + public Integer getPracticeStartWeek() { + return practiceStartWeek; + } + + public void setPracticeStartWeek(Integer practiceStartWeek) { + this.practiceStartWeek = practiceStartWeek; + } + + public int academicHoursFor(ScheduleLessonCategory category) { + return switch (category) { + case LECTURE -> valueOrZero(lectureAcademicHours); + case LABORATORY -> valueOrZero(laboratoryAcademicHours); + case PRACTICE -> valueOrZero(practiceAcademicHours); + }; + } + + public int startWeekFor(ScheduleLessonCategory category) { + return switch (category) { + case LECTURE -> valueOrDefault(lectureStartWeek, 1); + case LABORATORY -> valueOrDefault(laboratoryStartWeek, 1); + case PRACTICE -> valueOrDefault(practiceStartWeek, 1); + }; + } + + private int valueOrZero(Integer value) { + return valueOrDefault(value, 0); + } + + private int valueOrDefault(Integer value, int defaultValue) { + return value == null ? defaultValue : value; + } + + public Set getGroups() { + return groups; + } + + public void setGroups(Set groups) { + this.groups = groups; + } + + public Set getSlots() { + return slots; + } + + public void setSlots(Set slots) { + this.slots = slots; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java new file mode 100644 index 0000000..1404dc8 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java @@ -0,0 +1,164 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "schedule_rule_slots") +public class ScheduleRuleSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "schedule_rule_id", nullable = false) + private ScheduleRule scheduleRule; + + @Column(name = "day_of_week", nullable = false) + private Integer dayOfWeek; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private ScheduleParity parity; + + @ManyToOne(optional = false) + @JoinColumn(name = "time_slot_id", nullable = false) + private TimeSlot timeSlot; + + @ManyToMany + @JoinTable( + name = "schedule_rule_slot_subgroups", + joinColumns = @JoinColumn(name = "schedule_rule_slot_id"), + inverseJoinColumns = @JoinColumn(name = "subgroup_id") + ) + private Set subgroups = new LinkedHashSet<>(); + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @ManyToOne(optional = false) + @JoinColumn(name = "classroom_id", nullable = false) + private Classroom classroom; + + @ManyToOne(optional = false) + @JoinColumn(name = "lesson_type_id", nullable = false) + private LessonType lessonType; + + @Column(name = "lesson_format", nullable = false, length = 30) + private String lessonFormat; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public ScheduleRule getScheduleRule() { + return scheduleRule; + } + + public void setScheduleRule(ScheduleRule scheduleRule) { + this.scheduleRule = scheduleRule; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public ScheduleParity getParity() { + return parity; + } + + public void setParity(ScheduleParity parity) { + this.parity = parity; + } + + public TimeSlot getTimeSlot() { + return timeSlot; + } + + public void setTimeSlot(TimeSlot timeSlot) { + this.timeSlot = timeSlot; + } + + public Long getSubgroupId() { + return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null); + } + + public Subgroup getSubgroup() { + return subgroups.stream().findFirst().orElse(null); + } + + public void setSubgroup(Subgroup subgroup) { + subgroups.clear(); + if (subgroup != null) { + subgroups.add(subgroup); + } + } + + public Set getSubgroups() { + return subgroups; + } + + public void setSubgroups(Set subgroups) { + this.subgroups.clear(); + if (subgroups != null) { + this.subgroups.addAll(subgroups); + } + } + + public List getSubgroupIds() { + return subgroups.stream() + .map(Subgroup::getId) + .toList(); + } + + public List getSubgroupNames() { + return subgroups.stream() + .map(Subgroup::getName) + .toList(); + } + + public User getTeacher() { + return teacher; + } + + public void setTeacher(User teacher) { + this.teacher = teacher; + } + + public Classroom getClassroom() { + return classroom; + } + + public void setClassroom(Classroom classroom) { + this.classroom = classroom; + } + + public LessonType getLessonType() { + return lessonType; + } + + public void setLessonType(LessonType lessonType) { + this.lessonType = lessonType; + } + + public String getLessonFormat() { + return lessonFormat; + } + + public void setLessonFormat(String lessonFormat) { + this.lessonFormat = lessonFormat; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Semester.java b/backend/src/main/java/com/magistr/app/model/Semester.java new file mode 100644 index 0000000..85a79e1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Semester.java @@ -0,0 +1,68 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "semesters") +public class Semester { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @Enumerated(EnumType.STRING) + @Column(name = "semester_type", nullable = false, length = 20) + private SemesterType semesterType; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public SemesterType getSemesterType() { + return semesterType; + } + + public void setSemesterType(SemesterType semesterType) { + this.semesterType = semesterType; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Speciality.java b/backend/src/main/java/com/magistr/app/model/Speciality.java index 327360b..0d2938f 100644 --- a/backend/src/main/java/com/magistr/app/model/Speciality.java +++ b/backend/src/main/java/com/magistr/app/model/Speciality.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name="specialties") -public class Speciality { +public class Speciality extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java new file mode 100644 index 0000000..8c4f143 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java @@ -0,0 +1,54 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "specialty_profiles") +public class SpecialtyProfile extends LifecycleEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @Column(nullable = false, length = 500) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Speciality getSpeciality() { + return speciality; + } + + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroup.java b/backend/src/main/java/com/magistr/app/model/StudentGroup.java index c0f78fd..03a0129 100755 --- a/backend/src/main/java/com/magistr/app/model/StudentGroup.java +++ b/backend/src/main/java/com/magistr/app/model/StudentGroup.java @@ -4,13 +4,13 @@ import jakarta.persistence.*; @Entity @Table(name = "student_groups") -public class StudentGroup { +public class StudentGroup extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(unique = true, nullable = false, length = 100) + @Column(nullable = false, length = 100) private String name; @Column(name = "group_size", nullable = false) @@ -23,8 +23,13 @@ public class StudentGroup { @Column(name = "department_id", nullable = false) private Long departmentId; - @Column(name="specialty_code", nullable = false) - private Long specialityCode; + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_profile_id", nullable = false) + private SpecialtyProfile specialtyProfile; @Column(name="year_start_study", nullable = false) private Integer yearStartStudy; @@ -72,12 +77,20 @@ public class StudentGroup { this.departmentId = departmentId; } - public Long getSpecialityCode() { - return specialityCode; + public Speciality getSpeciality() { + return speciality; } - public void setSpecialityCode(Long specialityCode) { - this.specialityCode = specialityCode; + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public SpecialtyProfile getSpecialtyProfile() { + return specialtyProfile; + } + + public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) { + this.specialtyProfile = specialtyProfile; } public Integer getYearStartStudy() { diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java b/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java new file mode 100644 index 0000000..8076ce1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java @@ -0,0 +1,56 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "student_group_calendar_assignments") +public class StudentGroupCalendarAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "group_id", nullable = false) + private StudentGroup studentGroup; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @ManyToOne(optional = false) + @JoinColumn(name = "calendar_id", nullable = false) + private AcademicCalendar academicCalendar; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public StudentGroup getStudentGroup() { + return studentGroup; + } + + public void setStudentGroup(StudentGroup studentGroup) { + this.studentGroup = studentGroup; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public AcademicCalendar getAcademicCalendar() { + return academicCalendar; + } + + public void setAcademicCalendar(AcademicCalendar academicCalendar) { + this.academicCalendar = academicCalendar; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Subgroup.java b/backend/src/main/java/com/magistr/app/model/Subgroup.java new file mode 100644 index 0000000..e2e5ca0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Subgroup.java @@ -0,0 +1,54 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "subgroups") +public class Subgroup extends LifecycleEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "group_id", nullable = false) + private StudentGroup studentGroup; + + @Column(nullable = false, length = 100) + private String name; + + @Column(name = "student_capacity") + private Integer studentCapacity; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public StudentGroup getStudentGroup() { + return studentGroup; + } + + public void setStudentGroup(StudentGroup studentGroup) { + this.studentGroup = studentGroup; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getStudentCapacity() { + return studentCapacity; + } + + public void setStudentCapacity(Integer studentCapacity) { + this.studentCapacity = studentCapacity; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Subject.java b/backend/src/main/java/com/magistr/app/model/Subject.java index e5e922d..10e1751 100755 --- a/backend/src/main/java/com/magistr/app/model/Subject.java +++ b/backend/src/main/java/com/magistr/app/model/Subject.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "subjects") -public class Subject { +public class Subject extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/SubjectComment.java b/backend/src/main/java/com/magistr/app/model/SubjectComment.java new file mode 100644 index 0000000..b1b8d38 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/SubjectComment.java @@ -0,0 +1,64 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "subject_comments") +public class SubjectComment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "subject_id", nullable = false) + private Subject subject; + + @ManyToOne + @JoinColumn(name = "author_id") + private User author; + + @Column(nullable = false, columnDefinition = "TEXT") + private String comment; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + public Long getId() { + return id; + } + + public Subject getSubject() { + return subject; + } + + public void setSubject(Subject subject) { + this.subject = subject; + } + + public User getAuthor() { + return author; + } + + public void setAuthor(User author) { + this.author = author; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java b/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java new file mode 100644 index 0000000..737d249 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java @@ -0,0 +1,109 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "teacher_department_assignments") +public class TeacherDepartmentAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @ManyToOne(optional = false) + @JoinColumn(name = "department_id", nullable = false) + private Department department; + + @Column(name = "valid_from", nullable = false) + private LocalDate validFrom; + + @Column(name = "valid_to") + private LocalDate validTo; + + @Column(name = "is_primary", nullable = false) + private Boolean primaryAssignment = true; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "created_by") + private Long createdBy; + + public Long getId() { + return id; + } + + public User getTeacher() { + return teacher; + } + + public void setTeacher(User teacher) { + this.teacher = teacher; + } + + public Department getDepartment() { + return department; + } + + public void setDepartment(Department department) { + this.department = department; + } + + public LocalDate getValidFrom() { + return validFrom; + } + + public void setValidFrom(LocalDate validFrom) { + this.validFrom = validFrom; + } + + public LocalDate getValidTo() { + return validTo; + } + + public void setValidTo(LocalDate validTo) { + this.validTo = validTo; + } + + public Boolean getPrimaryAssignment() { + return primaryAssignment; + } + + public void setPrimaryAssignment(Boolean primaryAssignment) { + this.primaryAssignment = primaryAssignment; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public Long getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(Long createdBy) { + this.createdBy = createdBy; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlot.java b/backend/src/main/java/com/magistr/app/model/TimeSlot.java new file mode 100644 index 0000000..6da1c50 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TimeSlot.java @@ -0,0 +1,78 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalTime; + +@Entity +@Table(name = "time_slots") +public class TimeSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_number", nullable = false) + private Integer orderNumber; + + @ManyToOne(optional = false) + @JoinColumn(name = "time_slot_scope_id", nullable = false) + private TimeSlotScope timeSlotScope; + + @Column(name = "start_time", nullable = false) + private LocalTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalTime endTime; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Integer getOrderNumber() { + return orderNumber; + } + + public void setOrderNumber(Integer orderNumber) { + this.orderNumber = orderNumber; + } + + public TimeSlotScope getTimeSlotScope() { + return timeSlotScope; + } + + public void setTimeSlotScope(TimeSlotScope timeSlotScope) { + this.timeSlotScope = timeSlotScope; + } + + public LocalTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalTime startTime) { + this.startTime = startTime; + } + + public LocalTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalTime endTime) { + this.endTime = endTime; + } + + public Integer getDurationMinutes() { + return durationMinutes; + } + + public void setDurationMinutes(Integer durationMinutes) { + this.durationMinutes = durationMinutes; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlotDateAssignment.java b/backend/src/main/java/com/magistr/app/model/TimeSlotDateAssignment.java new file mode 100644 index 0000000..ac41e6a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TimeSlotDateAssignment.java @@ -0,0 +1,45 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "time_slot_date_assignments") +public class TimeSlotDateAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "assignment_date", nullable = false, unique = true) + private LocalDate date; + + @ManyToOne(optional = false) + @JoinColumn(name = "time_slot_scope_id", nullable = false) + private TimeSlotScope timeSlotScope; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public TimeSlotScope getTimeSlotScope() { + return timeSlotScope; + } + + public void setTimeSlotScope(TimeSlotScope timeSlotScope) { + this.timeSlotScope = timeSlotScope; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlotScope.java b/backend/src/main/java/com/magistr/app/model/TimeSlotScope.java new file mode 100644 index 0000000..3a6e3c7 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TimeSlotScope.java @@ -0,0 +1,86 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "time_slot_scopes") +public class TimeSlotScope { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String code; + + @Column(nullable = false, length = 120) + private String name; + + @Column(name = "apply_mode", nullable = false, length = 20) + private String applyMode; + + @Column(name = "day_of_week") + private Integer dayOfWeek; + + @Column(name = "system_scope", nullable = false) + private Boolean systemScope = false; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder = 100; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getApplyMode() { + return applyMode; + } + + public void setApplyMode(String applyMode) { + this.applyMode = applyMode; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public Boolean getSystemScope() { + return systemScope; + } + + public void setSystemScope(Boolean systemScope) { + this.systemScope = systemScope; + } + + public Integer getDisplayOrder() { + return displayOrder; + } + + public void setDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/User.java b/backend/src/main/java/com/magistr/app/model/User.java index 73e41c1..f70a768 100755 --- a/backend/src/main/java/com/magistr/app/model/User.java +++ b/backend/src/main/java/com/magistr/app/model/User.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "users") -public class User { +public class User extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java new file mode 100644 index 0000000..f4a2d8c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java @@ -0,0 +1,14 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarActivityType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarActivityTypeRepository extends JpaRepository { + + List findAllByOrderByDisplayOrderAscCodeAsc(); + + Optional findByCode(String code); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java new file mode 100644 index 0000000..761e1be --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java @@ -0,0 +1,43 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarDay; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarDayRepository extends JpaRepository { + + @Query(""" + select day + from AcademicCalendarDay day + join fetch day.activityType + where day.academicCalendar.id = :calendarId + order by day.courseNumber asc, day.date asc + """) + List findByCalendarIdWithActivity(@Param("calendarId") Long calendarId); + + @Query(""" + select day + from AcademicCalendarDay day + join fetch day.activityType + where day.academicCalendar.id = :calendarId + and day.courseNumber = :courseNumber + and day.date = :date + """) + Optional findByCalendarIdAndCourseNumberAndDate( + @Param("calendarId") Long calendarId, + @Param("courseNumber") Integer courseNumber, + @Param("date") LocalDate date + ); + + boolean existsByActivityTypeId(Long activityTypeId); + + @Modifying + @Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId") + void deleteByCalendarId(@Param("calendarId") Long calendarId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java new file mode 100644 index 0000000..fdb8ec8 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java @@ -0,0 +1,43 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendar; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarRepository extends JpaRepository { + + long countByStudyFormId(Long studyFormId); + + @Query(""" + select calendar + from AcademicCalendar calendar + join fetch calendar.academicYear + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where (:academicYearId is null or calendar.academicYear.id = :academicYearId) + and (:specialtyId is null or calendar.speciality.id = :specialtyId) + and (:profileId is null or calendar.specialtyProfile.id = :profileId) + order by calendar.academicYear.startDate desc, calendar.title asc + """) + List findAllWithDetails( + @Param("academicYearId") Long academicYearId, + @Param("specialtyId") Long specialtyId, + @Param("profileId") Long profileId + ); + + @Query(""" + select calendar + from AcademicCalendar calendar + join fetch calendar.academicYear + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where calendar.id = :id + """) + Optional findByIdWithDetails(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java new file mode 100644 index 0000000..ffc3846 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java @@ -0,0 +1,11 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicYear; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AcademicYearRepository extends JpaRepository { + + Optional findByTitle(String title); +} 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 aee60f2..c96cb49 100755 --- a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java @@ -4,7 +4,10 @@ import com.magistr.app.model.Classroom; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface ClassroomRepository extends JpaRepository { Optional findByName(String name); + + List findByStatusNot(String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java b/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java index 01fc400..3487ffb 100644 --- a/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java @@ -4,10 +4,13 @@ import com.magistr.app.model.Department; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface DepartmentRepository extends JpaRepository { Optional findByDepartmentName(String departmentName); Optional findByDepartmentCode(Long departmentCode); + + List findByStatusNot(String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java b/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java index dd94980..8d87e5a 100755 --- a/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java @@ -3,9 +3,12 @@ package com.magistr.app.repository; import com.magistr.app.model.EducationForm; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface EducationFormRepository extends JpaRepository { Optional findByName(String name); + + List findAllByOrderByNameAsc(); } diff --git a/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java b/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java index 8d5a445..1287b09 100755 --- a/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java @@ -4,7 +4,10 @@ import com.magistr.app.model.Equipment; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface EquipmentRepository extends JpaRepository { Optional findByName(String name); + + List findByStatusNot(String status); } 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 7ab7312..4153fc8 100755 --- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java @@ -4,13 +4,14 @@ import com.magistr.app.model.StudentGroup; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Optional; public interface GroupRepository extends JpaRepository { - Optional findByName(String name); - List findByEducationFormId(Long educationFormId); List findByDepartmentId(Long departmentId); + + List findByStatusNot(String status); + + List findByDepartmentIdAndStatusNot(Long departmentId, String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/LessonRepository.java b/backend/src/main/java/com/magistr/app/repository/LessonRepository.java deleted file mode 100755 index f02e9b5..0000000 --- a/backend/src/main/java/com/magistr/app/repository/LessonRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.Lesson; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface LessonRepository extends JpaRepository { - - Optional findBySubjectId(Long subjectId); - - List findByTeacherId(Long teacherId); -} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java deleted file mode 100644 index e9c0318..0000000 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.ScheduleData; -import com.magistr.app.model.SemesterType; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface ScheduleDataRepository extends JpaRepository { - - List findByDepartmentIdAndSemesterTypeAndPeriod(Long departmentId, SemesterType semesterType, String period); - - boolean existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod( - Long departmentId, - Long groupId, - Long subjectsId, - Long lessonTypeId, - Long numberOfHours, - Boolean division, - Long teacherId, - SemesterType semesterType, - String period - ); -} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java new file mode 100644 index 0000000..2b48bc0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java @@ -0,0 +1,29 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleOverride; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface ScheduleOverrideRepository extends JpaRepository { + + @Query(""" + select o + from ScheduleOverride o + left join fetch o.baseRuleSlot baseSlot + left join fetch o.newTimeSlot + left join fetch o.newClassroom + left join fetch o.newTeacher + where o.lessonDate between :startDate and :endDate + """) + List findByLessonDateBetweenWithDetails( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + Optional findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java new file mode 100644 index 0000000..94ca17a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -0,0 +1,96 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleRule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ScheduleRuleRepository extends JpaRepository { + + @Query(""" + select distinct r + from ScheduleRule r + join r.groups g + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.subgroups slotSubgroups + left join fetch slotSubgroups.studentGroup + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where g.id = :groupId + and sem.id = :semesterId + and r.status <> 'ARCHIVED' + """) + List findByGroupIdAndSemesterId( + @Param("groupId") Long groupId, + @Param("semesterId") Long semesterId + ); + + @Query(""" + select distinct r + from ScheduleRule r + join r.slots teacherSlot + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.subgroups slotSubgroups + left join fetch slotSubgroups.studentGroup + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where teacherSlot.teacher.id = :teacherId + and sem.id = :semesterId + and r.status <> 'ARCHIVED' + """) + List findByTeacherIdAndSemesterId( + @Param("teacherId") Long teacherId, + @Param("semesterId") Long semesterId + ); + + @Query(""" + select distinct r + from ScheduleRule r + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.subgroups slotSubgroups + left join fetch slotSubgroups.studentGroup + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where r.status <> 'ARCHIVED' + order by r.id desc + """) + List findAllWithDetails(); + + @Query(""" + select distinct r + from ScheduleRule r + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.subgroups slotSubgroups + left join fetch slotSubgroups.studentGroup + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where r.id = :id + """) + java.util.Optional findByIdWithDetails(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java new file mode 100644 index 0000000..72f71cb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java @@ -0,0 +1,12 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleRuleSlot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ScheduleRuleSlotRepository extends JpaRepository { + + @Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId") + boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java new file mode 100644 index 0000000..d347f61 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java @@ -0,0 +1,18 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Semester; +import com.magistr.app.model.SemesterType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface SemesterRepository extends JpaRepository { + + Optional findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate); + + List findByAcademicYearIdOrderByStartDateAsc(Long academicYearId); + + Optional findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java b/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java index 7c63217..a3b3c23 100644 --- a/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java @@ -4,10 +4,13 @@ import com.magistr.app.model.Speciality; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface SpecialtiesRepository extends JpaRepository { Optional findBySpecialityName(String specialityName); Optional findBySpecialityCode(String specialityCode); + + List findByStatusNot(String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java b/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java new file mode 100644 index 0000000..931adc3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java @@ -0,0 +1,14 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.SpecialtyProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SpecialtyProfileRepository extends JpaRepository { + + List findBySpecialityIdOrderByNameAsc(Long specialityId); + + Optional findBySpecialityIdAndName(Long specialityId, String name); +} diff --git a/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java new file mode 100644 index 0000000..446c99d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java @@ -0,0 +1,54 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.StudentGroupCalendarAssignment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface StudentGroupCalendarAssignmentRepository extends JpaRepository { + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.studentGroup + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where assignment.studentGroup.id = :groupId + order by assignment.academicYear.startDate desc + """) + List findByGroupIdWithDetails(@Param("groupId") Long groupId); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where assignment.studentGroup.id = :groupId + and assignment.academicYear.id = :academicYearId + """) + Optional findByGroupIdAndAcademicYearIdWithDetails( + @Param("groupId") Long groupId, + @Param("academicYearId") Long academicYearId + ); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.academicCalendar + where assignment.studentGroup.id = :groupId + and assignment.academicYear.id = :academicYearId + """) + Optional findForSchedule( + @Param("groupId") Long groupId, + @Param("academicYearId") Long academicYearId + ); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java new file mode 100644 index 0000000..49715e1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -0,0 +1,49 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Subgroup; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface SubgroupRepository extends JpaRepository { + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup studentGroup + where subgroup.status <> 'ARCHIVED' + order by studentGroup.name, subgroup.name + """) + List findAllWithGroupsOrderByGroupNameAndName(); + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup + where subgroup.studentGroup.id = :groupId + and subgroup.status <> 'ARCHIVED' + order by subgroup.name + """) + List findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId); + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup + where subgroup.id = :id + and subgroup.studentGroup.id = :groupId + """) + Optional findByIdAndStudentGroupId(@Param("id") Long id, @Param("groupId") Long groupId); + + @Query(""" + select case when count(subgroup) > 0 then true else false end + from Subgroup subgroup + where subgroup.studentGroup.id = :groupId + and lower(subgroup.name) = lower(:name) + and subgroup.status <> 'ARCHIVED' + """) + boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectCommentRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectCommentRepository.java new file mode 100644 index 0000000..86fc833 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SubjectCommentRepository.java @@ -0,0 +1,10 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.SubjectComment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SubjectCommentRepository extends JpaRepository { + List findBySubjectIdOrderByCreatedAtDesc(Long subjectId); +} 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 69a759f..dfb4888 100755 --- a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java @@ -10,4 +10,8 @@ public interface SubjectRepository extends JpaRepository { Optional findByName(String name); List findByDepartmentId(Long departmentId); + + List findByStatusNot(String status); + + List findByDepartmentIdAndStatusNot(Long departmentId, String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java new file mode 100644 index 0000000..8caaf02 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java @@ -0,0 +1,50 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherDepartmentAssignment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface TeacherDepartmentAssignmentRepository extends JpaRepository { + + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.teacher + join fetch a.department + where a.teacher.id = :teacherId + order by a.validFrom desc, a.id desc + """) + List findHistoryByTeacherId(@Param("teacherId") Long teacherId); + + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.teacher + join fetch a.department + where a.teacher.id = :teacherId + and a.primaryAssignment = true + and a.validTo is null + """) + Optional findOpenPrimaryByTeacherId(@Param("teacherId") Long teacherId); + + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.teacher + join fetch a.department + where a.department.id = :departmentId + and a.primaryAssignment = true + and a.validFrom <= :date + and (a.validTo is null or a.validTo >= :date) + order by a.teacher.fullName + """) + List findDepartmentTeachersAtDate( + @Param("departmentId") Long departmentId, + @Param("date") LocalDate date + ); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java new file mode 100644 index 0000000..4fd642c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java @@ -0,0 +1,19 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TimeSlotDateAssignment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface TimeSlotDateAssignmentRepository extends JpaRepository { + + Optional findByDate(LocalDate date); + + List findAllByOrderByDateAsc(); + + List findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate); + + boolean existsByTimeSlotScopeId(Long scopeId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java new file mode 100644 index 0000000..dc364e9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java @@ -0,0 +1,18 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TimeSlot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TimeSlotRepository extends JpaRepository { + + List findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc(); + + List findAllByTimeSlotScopeIdOrderByOrderNumberAsc(Long scopeId); + + Optional findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber); + + boolean existsByTimeSlotScopeId(Long scopeId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java new file mode 100644 index 0000000..a03a02c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java @@ -0,0 +1,18 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TimeSlotScope; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TimeSlotScopeRepository extends JpaRepository { + + List findAllByOrderByDisplayOrderAscNameAsc(); + + Optional findByCode(String code); + + Optional findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode); + + Optional findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek); +} 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 35932e4..f0510b2 100755 --- a/backend/src/main/java/com/magistr/app/repository/UserRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java @@ -14,4 +14,10 @@ public interface UserRepository extends JpaRepository { List findByRole(Role role); List findByRoleAndDepartmentId(Role role, Long departmentId); + + List findByStatusNot(String status); + + List findByRoleAndStatusNot(Role role, String status); + + List findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status); } diff --git a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java new file mode 100644 index 0000000..8180415 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java @@ -0,0 +1,89 @@ +package com.magistr.app.service; + +import com.magistr.app.model.*; +import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.SemesterRepository; +import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +@Service +public class AcademicDateService { + + private final SemesterRepository semesterRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final StudentGroupCalendarAssignmentRepository assignmentRepository; + private final boolean evenWeekIsUpper; + + public AcademicDateService(SemesterRepository semesterRepository, + AcademicCalendarDayRepository calendarDayRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, + @Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) { + this.semesterRepository = semesterRepository; + this.calendarDayRepository = calendarDayRepository; + this.assignmentRepository = assignmentRepository; + this.evenWeekIsUpper = evenWeekIsUpper; + } + + public Optional findSemester(LocalDate date) { + return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date); + } + + public int getWeekNumber(Semester semester, LocalDate date) { + long days = ChronoUnit.DAYS.between(semester.getStartDate(), date); + if (days < 0) { + return 0; + } + return (int) (days / 7) + 1; + } + + public ScheduleParity getParity(Semester semester, LocalDate date) { + int weekNumber = getWeekNumber(semester, date); + boolean evenWeek = weekNumber % 2 == 0; + if (evenWeekIsUpper) { + return evenWeek ? ScheduleParity.ODD : ScheduleParity.EVEN; + } + return evenWeek ? ScheduleParity.EVEN : ScheduleParity.ODD; + } + + public int getCourseNumber(StudentGroup group, LocalDate date) { + int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1; + return academicYearStart - group.getYearStartStudy() + 1; + } + + public Optional getActivityType(StudentGroup group, Semester semester, LocalDate date) { + if (group == null || semester == null || semester.getAcademicYear() == null || date == null) { + return Optional.empty(); + } + + int courseNumber = getCourseNumber(group, date); + if (courseNumber < 1) { + return Optional.empty(); + } + + return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()) + .flatMap(assignment -> calendarDayRepository.findByCalendarIdAndCourseNumberAndDate( + assignment.getAcademicCalendar().getId(), + courseNumber, + date + )) + .map(AcademicCalendarDay::getActivityType); + } + + public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { + return getActivityType(group, semester, date) + .map(AcademicCalendarActivityType::getAllowSchedule) + .orElse(false); + } + + public boolean hasCalendarAssignment(StudentGroup group, Semester semester) { + if (group == null || semester == null || semester.getAcademicYear() == null) { + return false; + } + return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent(); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java new file mode 100644 index 0000000..9449bd6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -0,0 +1,456 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.ScheduleRuleRepository; +import com.magistr.app.repository.TimeSlotDateAssignmentRepository; +import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.TimeSlotScopeRepository; +import com.magistr.app.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.TextStyle; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class ScheduleGeneratorService { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class); + private static final int ACADEMIC_HOURS_PER_SLOT = 2; + private static final long MAX_RANGE_DAYS = 120; + private static final String APPLY_MODE_WEEKDAY = "WEEKDAY"; + + private final ScheduleRuleRepository scheduleRuleRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final AcademicDateService academicDateService; + private final TimeSlotRepository timeSlotRepository; + private final TimeSlotScopeRepository timeSlotScopeRepository; + private final TimeSlotDateAssignmentRepository dateAssignmentRepository; + private final Map consumedHoursCache = new ConcurrentHashMap<>(); + + public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, + GroupRepository groupRepository, + UserRepository userRepository, + AcademicDateService academicDateService, + TimeSlotRepository timeSlotRepository, + TimeSlotScopeRepository timeSlotScopeRepository, + TimeSlotDateAssignmentRepository dateAssignmentRepository) { + this.scheduleRuleRepository = scheduleRuleRepository; + this.groupRepository = groupRepository; + this.userRepository = userRepository; + this.academicDateService = academicDateService; + this.timeSlotRepository = timeSlotRepository; + this.timeSlotScopeRepository = timeSlotScopeRepository; + this.dateAssignmentRepository = dateAssignmentRepository; + } + + public List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); + StudentGroup group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("Группа не найдена")); + + Map> rulesBySemester = new HashMap<>(); + List result = new ArrayList<>(); + Set warnedAcademicYears = new HashSet<>(); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty()) { + continue; + } + + Semester semester = semesterOpt.get(); + if (!academicDateService.hasCalendarAssignment(group, semester) + && warnedAcademicYears.add(semester.getAcademicYear().getId())) { + logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}", + group.getName(), semester.getAcademicYear().getTitle()); + } + if (!academicDateService.isTheoryDay(group, semester, date)) { + continue; + } + + List rules = rulesBySemester.computeIfAbsent( + semester.getId(), + semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId) + ); + + for (ScheduleRule rule : sortRules(rules)) { + renderRuleForDate(rule, date, group, null, result); + } + } + + return sortRenderedLessons(result); + } + + public List buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); + if (!userRepository.existsById(teacherId)) { + throw new IllegalArgumentException("Преподаватель не найден"); + } + + Map> rulesBySemester = new HashMap<>(); + List result = new ArrayList<>(); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty()) { + continue; + } + + Semester semester = semesterOpt.get(); + List rules = rulesBySemester.computeIfAbsent( + semester.getId(), + semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId) + ); + + for (ScheduleRule rule : sortRules(rules)) { + renderRuleForDate(rule, date, null, teacherId, result); + } + } + + return sortRenderedLessons(result); + } + + public void clearCache() { + consumedHoursCache.clear(); + } + + private void validateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("Дата начала и дата окончания обязательны"); + } + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); + } + if (java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) { + throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); + } + } + + private List sortRules(List rules) { + return rules.stream() + .sorted(Comparator + .comparing((ScheduleRule rule) -> rule.getSubject().getName(), Comparator.nullsLast(String::compareTo)) + .thenComparing(ScheduleRule::getId)) + .toList(); + } + + private List sortRenderedLessons(List lessons) { + return lessons.stream() + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private void renderRuleForDate(ScheduleRule rule, + LocalDate date, + StudentGroup targetGroup, + Long targetTeacherId, + List result) { + if (rule.getLectureAcademicHours() == null + || rule.getLaboratoryAcademicHours() == null + || rule.getPracticeAcademicHours() == null) { + return; + } + + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) { + return; + } + if (!rule.getSubject().isActiveOn(date)) { + return; + } + + Semester semester = semesterOpt.get(); + List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); + if (eligibleGroups.isEmpty()) { + return; + } + + ScheduleParity dateParity = academicDateService.getParity(semester, date); + int weekNumber = academicDateService.getWeekNumber(semester, date); + int dayOfWeek = date.getDayOfWeek().getValue(); + List slots = rule.getSlots().stream() + .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) + .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) + .filter(slot -> targetTeacherId == null || Objects.equals(slot.getTeacher().getId(), targetTeacherId)) + .sorted(Comparator + .comparing((ScheduleRuleSlot slot) -> slot.getTimeSlot().getOrderNumber(), Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ScheduleRuleSlot::getId)) + .toList(); + + if (slots.isEmpty()) { + return; + } + + Map consumedHoursByBucket = new HashMap<>(); + + for (ScheduleRuleSlot slot : slots) { + if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) { + continue; + } + if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) { + continue; + } + ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType()); + if (weekNumber < rule.startWeekFor(category)) { + continue; + } + int totalHours = rule.academicHoursFor(category); + if (totalHours <= 0) { + continue; + } + List lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups); + if (lessonScopes.isEmpty()) { + continue; + } + + List activeScopes = new ArrayList<>(); + for (LessonScope scope : lessonScopes) { + String consumptionBucket = consumptionBucket(category, scope.subgroupId()); + int consumedHours = consumedHoursByBucket.computeIfAbsent( + consumptionBucket, + key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId()) + ); + if (consumedHours >= totalHours) { + continue; + } + int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT); + activeScopes.add(new ActiveLessonScope(scope, consumedHours, remainingAfterLesson)); + } + + if (activeScopes.isEmpty()) { + continue; + } + + Map lessonGroupById = new LinkedHashMap<>(); + Map lessonSubgroupById = new LinkedHashMap<>(); + activeScopes.forEach(scope -> { + scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group)); + scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup)); + }); + List lessonGroups = new ArrayList<>(lessonGroupById.values()); + List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values()); + int consumedHours = activeScopes.stream() + .mapToInt(ActiveLessonScope::consumedHours) + .min() + .orElse(0); + int remainingAfterLesson = activeScopes.stream() + .mapToInt(ActiveLessonScope::remainingAfterLesson) + .min() + .orElse(0); + + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, + totalHours, consumedHours, remainingAfterLesson)); + for (ActiveLessonScope scope : activeScopes) { + consumedHoursByBucket.put( + consumptionBucket(category, scope.scope().subgroupId()), + scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT + ); + } + } + } + + private List eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) { + if (targetGroup != null) { + return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date) + ? List.of(targetGroup) + : List.of(); + } + + return rule.getGroups().stream() + .filter(group -> group.isActiveOn(date)) + .filter(group -> academicDateService.isTheoryDay(group, semester, date)) + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + } + + private int calculateConsumedHoursBeforeDate(ScheduleRule rule, + StudentGroup targetGroup, + LocalDate targetDate, + ScheduleLessonCategory category, + Long subgroupId) { + Long groupId = targetGroup == null ? null : targetGroup.getId(); + String cacheKey = rule.getId() + + ":" + category.name() + + ":" + (subgroupId == null ? "whole" : subgroupId) + + ":" + (groupId == null ? "all" : groupId) + + ":" + targetDate; + return consumedHoursCache.computeIfAbsent(cacheKey, + ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId)); + } + + private int calculateConsumedHours(ScheduleRule rule, + StudentGroup targetGroup, + LocalDate targetDate, + ScheduleLessonCategory category, + Long subgroupId) { + int consumed = 0; + for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) { + Semester semester = rule.getSemester(); + if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) { + continue; + } + int weekNumber = academicDateService.getWeekNumber(semester, date); + if (weekNumber < rule.startWeekFor(category)) { + continue; + } + List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); + if (eligibleGroups.isEmpty()) { + continue; + } + + int dayOfWeek = date.getDayOfWeek().getValue(); + ScheduleParity dateParity = academicDateService.getParity(semester, date); + long activeSlots = rule.getSlots().stream() + .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) + .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) + .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) + .flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream()) + .filter(scope -> Objects.equals(scope.subgroupId(), subgroupId)) + .count(); + consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; + if (consumed >= rule.academicHoursFor(category)) { + return consumed; + } + } + return consumed; + } + + private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) { + return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId); + } + + private List lessonScopesForSlot(ScheduleRuleSlot slot, + ScheduleLessonCategory category, + List eligibleGroups) { + if (category != ScheduleLessonCategory.LABORATORY || slot.getSubgroups().isEmpty()) { + return eligibleGroups.isEmpty() + ? List.of() + : List.of(new LessonScope(eligibleGroups, List.of(), null)); + } + + Set eligibleGroupIds = eligibleGroups.stream() + .map(StudentGroup::getId) + .collect(Collectors.toSet()); + Map eligibleGroupById = eligibleGroups.stream() + .collect(Collectors.toMap(StudentGroup::getId, group -> group)); + + return slot.getSubgroups().stream() + .filter(subgroup -> eligibleGroupIds.contains(subgroup.getStudentGroup().getId())) + .sorted(Comparator + .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName()) + .thenComparing(Subgroup::getName)) + .map(subgroup -> new LessonScope( + List.of(eligibleGroupById.get(subgroup.getStudentGroup().getId())), + List.of(subgroup), + subgroup.getId() + )) + .toList(); + } + + private record LessonScope(List groups, List subgroups, Long subgroupId) { + } + + private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) { + } + + private RenderedLessonDto toRenderedLesson(ScheduleRule rule, + ScheduleRuleSlot slot, + LocalDate date, + int weekNumber, + ScheduleParity dateParity, + List groups, + List subgroups, + int lessonTypeAcademicHours, + int consumedBeforeLesson, + int remainingAfterLesson) { + TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date); + List sortedGroups = groups.stream() + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + List sortedSubgroups = subgroups.stream() + .sorted(Comparator + .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName()) + .thenComparing(Subgroup::getName)) + .toList(); + + return new RenderedLessonDto( + rule.getId(), + slot.getId(), + date, + date.getDayOfWeek().getValue(), + dayName(date.getDayOfWeek()), + weekNumber, + dateParity, + timeSlot.getId(), + timeSlot.getOrderNumber(), + timeSlot.getStartTime(), + timeSlot.getEndTime(), + rule.getSubject().getId(), + rule.getSubject().getName(), + slot.getTeacher().getId(), + displayUserName(slot.getTeacher()), + slot.getClassroom().getId(), + slot.getClassroom().getName(), + slot.getLessonType().getId(), + slot.getLessonType().getLessonType(), + slot.getLessonFormat(), + sortedSubgroups.size() == 1 ? sortedSubgroups.get(0).getId() : null, + sortedSubgroups.size() == 1 ? formatSubgroup(sortedSubgroups.get(0)) : null, + sortedSubgroups.stream().map(Subgroup::getId).toList(), + sortedSubgroups.stream().map(this::formatSubgroup).toList(), + sortedGroups.stream().map(StudentGroup::getId).toList(), + sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), + "Т", + lessonTypeAcademicHours, + consumedBeforeLesson, + remainingAfterLesson + ); + } + + private String formatSubgroup(Subgroup subgroup) { + return subgroup.getStudentGroup().getName() + ": " + subgroup.getName(); + } + + private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) { + if (baseSlot == null || baseSlot.getOrderNumber() == null) { + return baseSlot; + } + return effectiveScope(date) + .flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber( + scope.getId(), + baseSlot.getOrderNumber())) + .orElse(baseSlot); + } + + private Optional effectiveScope(LocalDate date) { + return dateAssignmentRepository.findByDate(date) + .map(TimeSlotDateAssignment::getTimeSlotScope) + .or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek( + APPLY_MODE_WEEKDAY, + date.getDayOfWeek().getValue())); + } + + public static String dayName(DayOfWeek dayOfWeek) { + String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU")); + return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1); + } + + public static String displayUserName(User user) { + if (user.getFullName() != null && !user.getFullName().isBlank()) { + return user.getFullName(); + } + return user.getUsername(); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java new file mode 100644 index 0000000..348044f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -0,0 +1,246 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.ScheduleOverride; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.ScheduleOverrideRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class ScheduleQueryService { + + private final ScheduleGeneratorService scheduleGeneratorService; + private final GroupRepository groupRepository; + private final ScheduleOverrideRepository scheduleOverrideRepository; + + public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, + GroupRepository groupRepository, + ScheduleOverrideRepository scheduleOverrideRepository) { + this.scheduleGeneratorService = scheduleGeneratorService; + this.groupRepository = groupRepository; + this.scheduleOverrideRepository = scheduleOverrideRepository; + } + + public List search(Long groupId, + Long teacherId, + Long classroomId, + Long departmentId, + Long subjectId, + Long lessonTypeId, + Long timeSlotId, + String parity, + LocalDate startDate, + LocalDate endDate) { + List groups = resolveGroups(groupId, departmentId); + List generatedLessons = groups.stream() + .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) + .toList(); + List lessons = applyOverrides(generatedLessons, startDate, endDate).stream() + .filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId)) + .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId)) + .filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId)) + .filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId)) + .filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId)) + .filter(lesson -> parity == null || parity.isBlank() || lesson.parity().name().equalsIgnoreCase(parity)) + .toList(); + return deduplicate(lessons); + } + + private List applyOverrides(List lessons, LocalDate startDate, LocalDate endDate) { + Map overrides = scheduleOverrideRepository + .findByLessonDateBetweenWithDetails(startDate, endDate) + .stream() + .collect(Collectors.toMap( + override -> overrideKey(override.getBaseRuleSlot().getId(), override.getLessonDate()), + override -> override, + (first, second) -> second + )); + if (overrides.isEmpty()) { + return lessons; + } + return lessons.stream() + .map(lesson -> { + ScheduleOverride override = overrides.get(overrideKey(lesson.scheduleRuleSlotId(), lesson.date())); + return override == null ? lesson : applyOverride(lesson, override); + }) + .filter(Objects::nonNull) + .toList(); + } + + private RenderedLessonDto applyOverride(RenderedLessonDto lesson, ScheduleOverride override) { + if ("CANCEL".equals(override.getAction())) { + return null; + } + Long timeSlotId = lesson.timeSlotId(); + Integer timeSlotOrder = lesson.timeSlotOrder(); + java.time.LocalTime startTime = lesson.startTime(); + java.time.LocalTime endTime = lesson.endTime(); + if (override.getNewTimeSlot() != null) { + timeSlotId = override.getNewTimeSlot().getId(); + timeSlotOrder = override.getNewTimeSlot().getOrderNumber(); + startTime = override.getNewTimeSlot().getStartTime(); + endTime = override.getNewTimeSlot().getEndTime(); + } + + Long classroomId = override.getNewClassroom() == null ? lesson.classroomId() : override.getNewClassroom().getId(); + String classroomName = override.getNewClassroom() == null ? lesson.classroomName() : override.getNewClassroom().getName(); + Long teacherId = override.getNewTeacher() == null ? lesson.teacherId() : override.getNewTeacher().getId(); + String teacherName = override.getNewTeacher() == null ? lesson.teacherName() : ScheduleGeneratorService.displayUserName(override.getNewTeacher()); + String lessonFormat = override.getNewLessonFormat() == null ? lesson.lessonFormat() : override.getNewLessonFormat(); + + return new RenderedLessonDto( + lesson.scheduleRuleId(), + lesson.scheduleRuleSlotId(), + lesson.date(), + lesson.dayOfWeek(), + lesson.dayName(), + lesson.weekNumber(), + lesson.parity(), + timeSlotId, + timeSlotOrder, + startTime, + endTime, + lesson.subjectId(), + lesson.subjectName(), + teacherId, + teacherName, + classroomId, + classroomName, + lesson.lessonTypeId(), + lesson.lessonTypeName(), + lessonFormat, + lesson.subgroupId(), + lesson.subgroupName(), + lesson.subgroupIds(), + lesson.subgroupNames(), + lesson.groupIds(), + lesson.groupNames(), + lesson.activityType(), + lesson.lessonTypeAcademicHours(), + lesson.consumedLessonTypeAcademicHoursBeforeLesson(), + lesson.remainingLessonTypeAcademicHoursAfterLesson() + ); + } + + private String overrideKey(Long scheduleRuleSlotId, LocalDate date) { + return scheduleRuleSlotId + ":" + date; + } + + private List resolveGroups(Long groupId, Long departmentId) { + if (groupId != null) { + return groupRepository.findById(groupId) + .filter(StudentGroup::isActiveRecord) + .map(List::of) + .orElse(List.of()); + } + if (departmentId != null) { + return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); + } + return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + } + + private List deduplicate(List lessons) { + Map unique = new LinkedHashMap<>(); + for (RenderedLessonDto lesson : lessons) { + String key = lessonKey(lesson); + unique.computeIfAbsent(key, ignored -> new MutableLesson(lesson)).merge(lesson); + } + return unique.values().stream() + .map(MutableLesson::toDto) + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private String lessonKey(RenderedLessonDto lesson) { + return String.join(":", + String.valueOf(lesson.date()), + String.valueOf(lesson.scheduleRuleSlotId()), + String.valueOf(lesson.classroomId()), + String.valueOf(lesson.timeSlotOrder()), + String.valueOf(lesson.teacherId()), + String.valueOf(lesson.subjectId()), + sortedSignature(lesson.subgroupIds()) + ); + } + + private String sortedSignature(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + return values.stream() + .map(String::valueOf) + .sorted() + .collect(Collectors.joining(",")); + } + + private static class MutableLesson { + private final RenderedLessonDto source; + private final LinkedHashSet groupIds = new LinkedHashSet<>(); + private final LinkedHashSet groupNames = new LinkedHashSet<>(); + private final LinkedHashSet subgroupIds = new LinkedHashSet<>(); + private final LinkedHashSet subgroupNames = new LinkedHashSet<>(); + + MutableLesson(RenderedLessonDto source) { + this.source = source; + } + + void merge(RenderedLessonDto lesson) { + if (lesson.groupIds() != null) { + groupIds.addAll(lesson.groupIds()); + } + if (lesson.groupNames() != null) { + groupNames.addAll(lesson.groupNames()); + } + if (lesson.subgroupIds() != null) { + subgroupIds.addAll(lesson.subgroupIds()); + } + if (lesson.subgroupNames() != null) { + subgroupNames.addAll(lesson.subgroupNames()); + } + } + + RenderedLessonDto toDto() { + return new RenderedLessonDto( + source.scheduleRuleId(), + source.scheduleRuleSlotId(), + source.date(), + source.dayOfWeek(), + source.dayName(), + source.weekNumber(), + source.parity(), + source.timeSlotId(), + source.timeSlotOrder(), + source.startTime(), + source.endTime(), + source.subjectId(), + source.subjectName(), + source.teacherId(), + source.teacherName(), + source.classroomId(), + source.classroomName(), + source.lessonTypeId(), + source.lessonTypeName(), + source.lessonFormat(), + subgroupIds.size() == 1 ? subgroupIds.iterator().next() : null, + subgroupNames.size() == 1 ? subgroupNames.iterator().next() : null, + List.copyOf(subgroupIds), + List.copyOf(subgroupNames), + List.copyOf(groupIds), + List.copyOf(groupNames), + source.activityType(), + source.lessonTypeAcademicHours(), + source.consumedLessonTypeAcademicHoursBeforeLesson(), + source.remainingLessonTypeAcademicHoursAfterLesson() + ); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/utils/DayAndWeekValidator.java b/backend/src/main/java/com/magistr/app/utils/DayAndWeekValidator.java deleted file mode 100755 index b4461f9..0000000 --- a/backend/src/main/java/com/magistr/app/utils/DayAndWeekValidator.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.magistr.app.utils; - -import java.util.Set; - -public class DayAndWeekValidator { - - private static final Set VALID_DAYS = Set.of( - "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота" - ); - - private static final Set VALID_WEEKS = Set.of( - "Верхняя", "Нижняя", "Обе" - ); - - public static boolean isValidDay(String day) { - return day != null && VALID_DAYS.contains(day); - } - - public static boolean isValidWeek(String week) { - return week != null && VALID_WEEKS.contains(week); - } - - public static String getValidDaysMessage() { - return "Допустимые дни: " + String.join(", ", VALID_DAYS); - } - - public static String getValidWeekMessage() { - return "Допустимы для выбора: " + String.join(", ", VALID_WEEKS); - } -} diff --git a/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java b/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java deleted file mode 100644 index f4b53d7..0000000 --- a/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.magistr.app.utils; - -import com.magistr.app.model.SemesterType; - -import java.util.Arrays; - -public class SemesterTypeValidator { - - public static boolean isValidTypeSemester(String semesterType) { - if (semesterType == null) { - return false; - } - try { - SemesterType.valueOf(semesterType); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - - public static String getValidTypes() { - return String.join(", ", Arrays.stream(SemesterType.values()) - .map(Enum::name) - .toArray(String[]::new)); - } -} diff --git a/backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java b/backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java deleted file mode 100755 index 21e3146..0000000 --- a/backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.magistr.app.utils; - -import java.util.Set; - -public class TypeAndFormatLessonValidator { - - private static final Set VALID_TYPES = Set.of( - "Лекция", "Лабораторная работа", "Практическая работа" - ); - - private static final Set VALID_FORMATS = Set.of( - "Онлайн", "Очно" - ); - - public static boolean isValidType(String type) { - return type != null && VALID_TYPES.contains(type); - } - - public static boolean isValidFormat(String format) { - return format != null && VALID_FORMATS.contains(format); - } - - public static String getValidTypesMessage() { - return "Допустимые типы: " + String.join(", ", VALID_TYPES); - } - - public static String getValidFormatsMessage() { - return "Допустимые форматы: " + String.join(", ", VALID_FORMATS); - } -} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 7416e4d..45fb370 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -9,7 +9,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS departments ( id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL, - code BIGINT UNIQUE NOT NULL + code BIGINT UNIQUE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); INSERT INTO departments (name, code) VALUES @@ -20,13 +26,40 @@ INSERT INTO departments (name, code) VALUES CREATE TABLE IF NOT EXISTS specialties ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, - specialty_code VARCHAR(255) NOT NULL + specialty_code VARCHAR(255) UNIQUE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); INSERT INTO specialties (name, specialty_code) VALUES ('Информационная безопасность', '10.03.01'), ('Информатика и вычислительная техника', '09.03.01'), - ('Программная инженерия', '09.03.04'); + ('Программная инженерия', '09.03.04') +ON CONFLICT (specialty_code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS specialty_profiles ( + id BIGSERIAL PRIMARY KEY, + specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE, + name VARCHAR(500) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT, + CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name) +); + +INSERT INTO specialty_profiles (specialty_id, name) +SELECT id, 'Без профиля' +FROM specialties +ON CONFLICT (specialty_id, name) DO NOTHING; -- ========================================== -- Пользователи и роли @@ -35,20 +68,62 @@ CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, - role VARCHAR(20) NOT NULL DEFAULT 'STUDENT', + role VARCHAR(30) NOT NULL DEFAULT 'STUDENT', full_name VARCHAR(255) NOT NULL, job_title VARCHAR(255) NOT NULL, department_id BIGINT NOT NULL REFERENCES departments(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); -- Админ по умолчанию: admin / admin (bcrypt через pgcrypto) INSERT INTO users (username, password, role, full_name, job_title, department_id) VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1), - ('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2) + ('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2), + ('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1), + ('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1), + ('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1) ON CONFLICT (username) DO NOTHING; +CREATE TABLE IF NOT EXISTS teacher_department_assignments ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id), + department_id BIGINT NOT NULL REFERENCES departments(id), + valid_from DATE NOT NULL, + valid_to DATE, + is_primary BOOLEAN NOT NULL DEFAULT TRUE, + comment TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT REFERENCES users(id), + CONSTRAINT chk_teacher_department_dates CHECK (valid_to IS NULL OR valid_to >= valid_from) +); + +CREATE INDEX IF NOT EXISTS idx_teacher_department_teacher_dates + ON teacher_department_assignments(teacher_id, valid_from, valid_to); + +CREATE INDEX IF NOT EXISTS idx_teacher_department_department_dates + ON teacher_department_assignments(department_id, valid_from, valid_to); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary + ON teacher_department_assignments(teacher_id) + WHERE is_primary = TRUE AND valid_to IS NULL; + +INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment) +SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id' +FROM users u +WHERE u.role = 'TEACHER' + AND NOT EXISTS ( + SELECT 1 + FROM teacher_department_assignments existing + WHERE existing.teacher_id = u.id + ); + -- ========================================== -- Образовательные формы -- ========================================== @@ -56,7 +131,13 @@ CREATE TABLE IF NOT EXISTS education_forms ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); INSERT INTO education_forms (name) VALUES @@ -70,20 +151,34 @@ ON CONFLICT (name) DO NOTHING; -- ========================================== CREATE TABLE IF NOT EXISTS student_groups ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, group_size BIGINT NOT NULL, education_form_id BIGINT NOT NULL REFERENCES education_forms(id), department_id BIGINT NOT NULL REFERENCES departments(id), - specialty_code INT NOT NULL REFERENCES specialties(id), + specialty_id BIGINT NOT NULL REFERENCES specialties(id), + specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), year_start_study BIGINT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); -- Тестовая базовая группа для работы -INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_code, year_start_study) -VALUES ('ИВТ-21-1', 25, 1, 1, 2, 2025), - ('ИБ-41м', 15, 2, 1, 1, 2024) -ON CONFLICT (name) DO NOTHING; +INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study) +SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study +FROM (VALUES + ('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT), + ('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT) +) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study) +JOIN education_forms ef ON ef.name = data.education_form_name +JOIN departments dept ON dept.name = data.department_name +JOIN specialties specialty ON specialty.specialty_code = data.specialty_code +JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' +WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name); -- ========================================== -- Подгруппы (например: "ИВТ-21-1 Подгруппа 1") @@ -93,9 +188,31 @@ CREATE TABLE IF NOT EXISTS subgroups ( group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, student_capacity INT, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT, UNIQUE(group_id, name) ); +INSERT INTO subgroups (group_id, name, student_capacity) +SELECT + sg.id, + data.name, + CASE + WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT + ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT + END +FROM student_groups sg +CROSS JOIN (VALUES + ('Подгруппа 1', 1), + ('Подгруппа 2', 2) +) AS data(name, part_number) +WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м') +ON CONFLICT (group_id, name) DO NOTHING; + -- ========================================== -- Справочники -- ========================================== @@ -107,7 +224,13 @@ CREATE TABLE IF NOT EXISTS subjects ( code VARCHAR(20), department_id BIGINT NOT NULL REFERENCES departments(id), description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); INSERT INTO subjects (name, department_id) VALUES @@ -118,6 +241,17 @@ INSERT INTO subjects (name, department_id) VALUES ('Английский язык', 1) ON CONFLICT (name) DO NOTHING; +CREATE TABLE IF NOT EXISTS subject_comments ( + id BIGSERIAL PRIMARY KEY, + subject_id BIGINT NOT NULL REFERENCES subjects(id), + author_id BIGINT REFERENCES users(id), + comment TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_subject_comments_subject + ON subject_comments(subject_id, created_at DESC); + -- Типы занятий CREATE TABLE IF NOT EXISTS lesson_types ( id BIGSERIAL PRIMARY KEY, @@ -137,7 +271,13 @@ CREATE TABLE IF NOT EXISTS equipments ( id BIGSERIAL PRIMARY KEY, name VARCHAR(50) UNIQUE NOT NULL, description TEXT, - inventory_number VARCHAR(50) + inventory_number VARCHAR(50), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); INSERT INTO equipments (name) VALUES @@ -158,7 +298,13 @@ CREATE TABLE IF NOT EXISTS classrooms ( floor INT, is_available BOOLEAN DEFAULT TRUE, description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); INSERT INTO classrooms (name, capacity, building, floor) VALUES @@ -190,6 +336,17 @@ WHERE OR (c.name = '303 Обычная' AND e.name IN ('Проектор')) ON CONFLICT (classroom_id, equipment_id) DO NOTHING; +CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status); +CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status); +CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status); +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); +CREATE INDEX IF NOT EXISTS idx_education_forms_status ON education_forms(status); +CREATE INDEX IF NOT EXISTS idx_student_groups_status ON student_groups(status); +CREATE INDEX IF NOT EXISTS idx_subgroups_status ON subgroups(status); +CREATE INDEX IF NOT EXISTS idx_subjects_status ON subjects(status); +CREATE INDEX IF NOT EXISTS idx_equipments_status ON equipments(status); +CREATE INDEX IF NOT EXISTS idx_classrooms_status ON classrooms(status); + -- ========================================== -- Связи для преподавателей -- ========================================== @@ -210,56 +367,515 @@ CREATE TABLE IF NOT EXISTS teacher_lesson_types ( ); -- ========================================== --- Основная таблица Расписания (Lessons) +-- Динамическое расписание: календарь, слоты и правила -- ========================================== -CREATE TABLE IF NOT EXISTS lessons ( + +CREATE TABLE IF NOT EXISTS time_slot_scopes ( id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + apply_mode VARCHAR(20) NOT NULL, + day_of_week INT NULL, + system_scope BOOLEAN NOT NULL DEFAULT FALSE, + display_order INT NOT NULL DEFAULT 100, + CONSTRAINT chk_time_slot_scopes_mode CHECK (apply_mode IN ('DEFAULT', 'WEEKDAY', 'MANUAL')), + CONSTRAINT chk_time_slot_scopes_day CHECK (day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7), + CONSTRAINT chk_time_slot_scopes_weekday CHECK ( + (apply_mode = 'WEEKDAY' AND day_of_week IS NOT NULL) + OR (apply_mode <> 'WEEKDAY' AND day_of_week IS NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_default + ON time_slot_scopes(apply_mode) + WHERE apply_mode = 'DEFAULT'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday + ON time_slot_scopes(day_of_week) + WHERE apply_mode = 'WEEKDAY'; + +INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES + ('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10), + ('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20) +ON CONFLICT (code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS time_slots ( + id BIGSERIAL PRIMARY KEY, + time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE, + order_number INT NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + duration_minutes INT NOT NULL, + CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0), + CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time), + CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order + ON time_slots(time_slot_scope_id, order_number); + +CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number); + +INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes) +SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes +FROM (VALUES + (1, '08:00', '09:30', 90), + (2, '09:40', '11:10', 90), + (3, '11:40', '13:10', 90), + (4, '13:20', '14:50', 90), + (5, '15:00', '16:30', 90), + (6, '16:50', '18:20', 90), + (7, '18:30', '20:00', 90) +) AS data(order_number, start_time, end_time, duration_minutes) +CROSS JOIN time_slot_scopes scope +WHERE scope.code IN ('default', 'saturday') +ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS time_slot_date_assignments ( + id BIGSERIAL PRIMARY KEY, + assignment_date DATE NOT NULL UNIQUE, + time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_time_slot_date_assignments_scope + ON time_slot_date_assignments(time_slot_scope_id); + +CREATE TABLE IF NOT EXISTS academic_years ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(20) NOT NULL UNIQUE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date) +); + +INSERT INTO academic_years (title, start_date, end_date) VALUES + ('2024-2025', '2024-09-01', '2025-06-30'), + ('2025-2026', '2025-09-01', '2026-06-30') +ON CONFLICT (title) DO NOTHING; + +CREATE TABLE IF NOT EXISTS semesters ( + id BIGSERIAL PRIMARY KEY, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + semester_type VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + CONSTRAINT chk_semesters_type CHECK (semester_type IN ('autumn', 'spring')), + CONSTRAINT chk_semesters_dates CHECK (start_date <= end_date), + CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type) +); + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31) +FROM academic_years +ON CONFLICT (academic_year_id, semester_type) DO NOTHING; + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date +FROM academic_years +ON CONFLICT (academic_year_id, semester_type) DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date); + +CREATE TABLE IF NOT EXISTS academic_calendar_activity_types ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(10) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + allow_schedule BOOLEAN NOT NULL DEFAULT FALSE, + color_code VARCHAR(7) NOT NULL DEFAULT '#64748b', + display_order INT NOT NULL DEFAULT 100, + description TEXT +); + +INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES + ('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'), + ('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'), + ('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'), + ('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'), + ('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'), + ('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'), + ('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'), + ('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'), + ('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'), + ('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'), + ('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'), + ('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'), + ('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания') +ON CONFLICT (code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendars ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + specialty_id BIGINT NOT NULL REFERENCES specialties(id), + specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), + study_form_id BIGINT NOT NULL REFERENCES education_forms(id), + course_count INT NOT NULL DEFAULT 4, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_academic_calendars_course_count CHECK (course_count > 0), + CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title) +); + +CREATE INDEX IF NOT EXISTS idx_academic_calendars_context + ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id); + +INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count) +SELECT + specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title, + ay.id, + specialty.id, + profile.id, + study_form.id, + 4 +FROM academic_years ay +CROSS JOIN education_forms study_form +JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01') +JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' +WHERE ay.title = '2025-2026' +ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendar_days ( + id BIGSERIAL PRIMARY KEY, + calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, + course_number INT NOT NULL, + date DATE NOT NULL, + week_number INT NOT NULL, + day_of_week INT NOT NULL, + activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id), + CONSTRAINT chk_calendar_days_course_positive CHECK (course_number > 0), + CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0), + CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7), + CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup + ON academic_calendar_days(calendar_id, course_number, date); + +INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id) +SELECT + calendar.id, + course.course_number, + days.date::DATE, + ((days.date::DATE - ay.start_date) / 7) + 1, + EXTRACT(ISODOW FROM days.date::DATE)::INT, + activity.id +FROM academic_calendars calendar +JOIN academic_years ay ON ay.id = calendar.academic_year_id +JOIN academic_calendar_activity_types activity ON activity.code = 'Т' +CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number) +CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date) +ON CONFLICT (calendar_id, course_number, date) DO NOTHING; + +CREATE TABLE IF NOT EXISTS student_group_calendar_assignments ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_group_calendar_year UNIQUE (group_id, academic_year_id) +); + +CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup + ON student_group_calendar_assignments(group_id, academic_year_id); + +INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id) +SELECT + sg.id, + ay.id, + calendar.id +FROM student_groups sg +JOIN academic_years ay ON ay.title = '2025-2026' +JOIN academic_calendars calendar + ON calendar.academic_year_id = ay.id + AND calendar.specialty_profile_id = sg.specialty_profile_id +WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м') +ON CONFLICT (group_id, academic_year_id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS schedule_rules ( + id BIGSERIAL PRIMARY KEY, + subject_id BIGINT NOT NULL REFERENCES subjects(id), + semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, + lecture_academic_hours INT NOT NULL, + laboratory_academic_hours INT NOT NULL, + practice_academic_hours INT NOT NULL, + lecture_start_week INT NOT NULL, + laboratory_start_week INT NOT NULL, + practice_start_week INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + valid_from DATE, + valid_to DATE, + version_group_id BIGINT, + change_reason TEXT, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK ( + lecture_academic_hours >= 0 + AND laboratory_academic_hours >= 0 + AND practice_academic_hours >= 0 + ), + CONSTRAINT chk_schedule_rules_has_type_hours CHECK ( + lecture_academic_hours + laboratory_academic_hours + practice_academic_hours > 0 + ), + CONSTRAINT chk_schedule_rules_start_weeks_positive CHECK ( + lecture_start_week > 0 + AND laboratory_start_week > 0 + AND practice_start_week > 0 + ), + CONSTRAINT chk_schedule_rules_dates CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rules_subject ON schedule_rules(subject_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rules_status_dates + ON schedule_rules(status, valid_from, valid_to); + +CREATE TABLE IF NOT EXISTS schedule_rule_groups ( + schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, + PRIMARY KEY (schedule_rule_id, group_id) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rule_groups_group ON schedule_rule_groups(group_id); + +CREATE TABLE IF NOT EXISTS schedule_rule_slots ( + id BIGSERIAL PRIMARY KEY, + schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE, + day_of_week INT NOT NULL, + parity VARCHAR(10) NOT NULL, + time_slot_id BIGINT NOT NULL REFERENCES time_slots(id), teacher_id BIGINT NOT NULL REFERENCES users(id), - group_id BIGINT NOT NULL REFERENCES student_groups(id), - subject_id BIGINT NOT NULL REFERENCES subjects(id), - lesson_format VARCHAR(255) NOT NULL, - type_lesson VARCHAR(255) NOT NULL, classroom_id BIGINT NOT NULL REFERENCES classrooms(id), - day VARCHAR(255) NOT NULL, - week VARCHAR(255) NOT NULL, - time VARCHAR(255) NOT NULL -); - -INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES - (2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'), - (1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'), - (2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'), - (1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'), - (2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'), - (1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30'); - --- =============================== --- Создание таблицы данных расписания (schedule_data) --- =============================== -CREATE TABLE IF NOT EXISTS schedule_data ( - id BIGSERIAL PRIMARY KEY, - department_id BIGINT NOT NULL REFERENCES departments(id), - group_id BIGINT NOT NULL REFERENCES student_groups(id), - subjects_id BIGINT NOT NULL REFERENCES subjects(id), lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), - number_of_hours INT NOT NULL, - is_division BOOLEAN NOT NULL DEFAULT FALSE, - teacher_id BIGINT NOT NULL REFERENCES users(id), - semester_type VARCHAR(255) NOT NULL, - period VARCHAR(255) NOT NULL + lesson_format VARCHAR(30) NOT NULL, + time_locked BOOLEAN NOT NULL DEFAULT FALSE, + classroom_locked BOOLEAN NOT NULL DEFAULT FALSE, + teacher_locked BOOLEAN NOT NULL DEFAULT FALSE, + locked_by BIGINT REFERENCES users(id), + locked_at TIMESTAMP, + lock_comment TEXT, + CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7), + CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')), + CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн')) ); -INSERT INTO schedule_data (department_id, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) -VALUES (1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (3, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'), - (1, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'), - (1, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'), - (1, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025'); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id); + +CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups ( + schedule_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id) ON DELETE CASCADE, + subgroup_id BIGINT NOT NULL REFERENCES subgroups(id) ON DELETE CASCADE, + PRIMARY KEY (schedule_rule_slot_id, subgroup_id) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id); + +CREATE TABLE IF NOT EXISTS schedule_overrides ( + id BIGSERIAL PRIMARY KEY, + base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id), + lesson_date DATE NOT NULL, + action VARCHAR(20) NOT NULL, + new_time_slot_id BIGINT REFERENCES time_slots(id), + new_classroom_id BIGINT REFERENCES classrooms(id), + new_teacher_id BIGINT REFERENCES users(id), + new_lesson_format VARCHAR(30), + comment TEXT, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_schedule_overrides_action CHECK (action IN ('MOVE', 'CANCEL', 'REPLACE')), + CONSTRAINT uq_schedule_overrides_slot_date UNIQUE (base_rule_slot_id, lesson_date) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date + ON schedule_overrides(lesson_date); + +CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher + ON schedule_overrides(new_teacher_id); + +CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups() +RETURNS TRIGGER AS $$ +DECLARE + lesson_type_name VARCHAR(50); + rule_id BIGINT; + subgroup_group_id BIGINT; + has_group_duplicate BOOLEAN; +BEGIN + SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name + FROM schedule_rule_slots slot + JOIN lesson_types type ON type.id = slot.lesson_type_id + WHERE slot.id = NEW.schedule_rule_slot_id; + + IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN + RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий'; + END IF; + + SELECT group_id INTO subgroup_group_id + FROM subgroups + WHERE id = NEW.subgroup_id; + + IF subgroup_group_id IS NULL THEN + RAISE EXCEPTION 'Подгруппа не найдена'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM schedule_rule_groups rule_group + WHERE rule_group.schedule_rule_id = rule_id + AND rule_group.group_id = subgroup_group_id + ) THEN + RAISE EXCEPTION 'Подгруппа должна относиться к одной из групп правила'; + END IF; + + IF TG_OP = 'UPDATE' THEN + SELECT EXISTS ( + SELECT 1 + FROM schedule_rule_slot_subgroups slot_subgroup + JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id + WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id + AND subgroup.group_id = subgroup_group_id + AND NOT ( + slot_subgroup.schedule_rule_slot_id = OLD.schedule_rule_slot_id + AND slot_subgroup.subgroup_id = OLD.subgroup_id + ) + ) INTO has_group_duplicate; + ELSE + SELECT EXISTS ( + SELECT 1 + FROM schedule_rule_slot_subgroups slot_subgroup + JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id + WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id + AND subgroup.group_id = subgroup_group_id + ) INTO has_group_duplicate; + END IF; + + IF has_group_duplicate THEN + RAISE EXCEPTION 'В одном слоте можно выбрать не больше одной подгруппы каждой группы'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER validate_schedule_rule_slot_subgroups + BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups + FOR EACH ROW + EXECUTE FUNCTION validate_schedule_rule_slot_subgroups(); + +CREATE TEMP TABLE tmp_default_rules AS +SELECT + NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, + rule_key, + subject_id, + semester_id, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week +FROM ( + SELECT + data.rule_key, + subj.id AS subject_id, + sem.id AS semester_id, + data.lecture_academic_hours, + data.laboratory_academic_hours, + data.practice_academic_hours, + data.lecture_start_week, + data.laboratory_start_week, + data.practice_start_week + FROM (VALUES + ('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1), + ('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1), + ('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1), + ('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1) + ) AS data( + rule_key, + subject_name, + year_title, + semester_type, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week + ) + JOIN subjects subj ON subj.name = data.subject_name + JOIN academic_years ay ON ay.title = data.year_title + JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type +) prepared_rules; + +INSERT INTO schedule_rules ( + id, + subject_id, + semester_id, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week +) +SELECT + schedule_rule_id, + subject_id, + semester_id, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week +FROM tmp_default_rules; + +INSERT INTO schedule_rule_groups (schedule_rule_id, group_id) +SELECT + rules.schedule_rule_id, + sg.id +FROM (VALUES + ('stream_math', 'ИВТ-21-1'), + ('stream_math', 'ИБ-41м'), + ('ivt_informatics_lab', 'ИВТ-21-1'), + ('ivt_databases_practice', 'ИВТ-21-1'), + ('ib_philosophy', 'ИБ-41м') +) AS data(rule_key, group_name) +JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key +JOIN student_groups sg ON sg.name = data.group_name +ON CONFLICT DO NOTHING; + +INSERT INTO schedule_rule_slots ( + schedule_rule_id, + day_of_week, + parity, + time_slot_id, + teacher_id, + classroom_id, + lesson_type_id, + lesson_format +) +SELECT + rules.schedule_rule_id, + data.day_of_week, + data.parity, + ts.id, + teacher.id, + classroom.id, + lt.id, + data.lesson_format +FROM (VALUES + ('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'), + ('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'), + ('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'), + ('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн') +) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format) +JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key +JOIN time_slot_scopes scope ON scope.code = 'default' +JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order +JOIN users teacher ON teacher.username = data.teacher_username +JOIN classrooms classroom ON classroom.name = data.classroom_name +JOIN lesson_types lt ON lt.name = data.lesson_type_name; -- ========================================== -- Функция обновления timestamp @@ -277,23 +893,42 @@ CREATE TRIGGER update_users_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_academic_calendars_updated_at + BEFORE UPDATE ON academic_calendars + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + -- ========================================== -- Комментарии к таблицам и полям (для документации) -- ========================================== COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)'; -COMMENT ON TABLE lessons IS 'Основное расписание занятий'; COMMENT ON TABLE departments IS 'Кафедры'; COMMENT ON TABLE specialties IS 'Специальности'; -COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания'; -COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры'; -COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; -COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; -COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия'; -COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов'; -COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным'; -COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя'; -COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)'; -COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)'; +COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности'; +COMMENT ON TABLE time_slot_scopes IS 'Именованные сетки времени: базовая, автоматическая субботняя и ручные сетки'; +COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; +COMMENT ON TABLE time_slot_date_assignments IS 'Ручное назначение сетки времени на конкретную дату'; +COMMENT ON TABLE academic_years IS 'Учебные годы'; +COMMENT ON TABLE semesters IS 'Семестры учебного года'; +COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; +COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; +COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; +COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году'; +COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; +COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; +COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; +COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания'; +COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам'; +COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам'; +COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар'; +COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций'; +COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ'; +COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик'; +COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре'; +COMMENT ON COLUMN schedule_rules.laboratory_start_week IS 'Неделя начала лабораторных работ в семестре'; +COMMENT ON COLUMN schedule_rules.practice_start_week IS 'Неделя начала практик в семестре'; +COMMENT ON COLUMN schedule_rules.valid_from IS 'Дата начала действия версии правила расписания'; +COMMENT ON COLUMN schedule_rules.valid_to IS 'Дата окончания действия версии правила расписания'; COMMENT ON TABLE education_forms IS 'Формы обучения'; COMMENT ON TABLE subgroups IS 'Подгруппы'; @@ -308,6 +943,7 @@ COMMENT ON COLUMN users.id IS 'ID пользователя'; COMMENT ON COLUMN users.username IS 'Логин пользователя'; COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя'; COMMENT ON COLUMN users.role IS 'Роль пользователя'; +COMMENT ON COLUMN users.status IS 'Жизненный цикл пользователя: ACTIVE или ARCHIVED'; COMMENT ON COLUMN users.created_at IS 'Дата и время создания'; COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления'; COMMENT ON COLUMN users.full_name IS 'ФИО пользователя'; @@ -324,6 +960,9 @@ COMMENT ON COLUMN student_groups.name IS 'Название группы'; COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов'; COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа'; COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; +COMMENT ON COLUMN student_groups.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN student_groups.specialty_profile_id IS 'ID профиля обучения'; +COMMENT ON COLUMN student_groups.year_start_study IS 'Год начала обучения'; COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; COMMENT ON COLUMN subgroups.id IS 'ID подгруппы'; @@ -356,10 +995,11 @@ COMMENT ON COLUMN classrooms.floor IS 'Этаж'; COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории'; COMMENT ON COLUMN classrooms.description IS 'Описание аудитории'; COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания'; +COMMENT ON COLUMN classrooms.status IS 'Жизненный цикл аудитории: ACTIVE или ARCHIVED'; COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории'; COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования'; -COMMENT ON COLUMN classroom_equipments.quantity IS 'Дата и время создания'; -- Так было в V2 +COMMENT ON COLUMN classroom_equipments.quantity IS 'Количество единиц оборудования'; COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи'; COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя'; @@ -367,17 +1007,6 @@ COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета'; COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя'; COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания'; -COMMENT ON COLUMN lessons.id IS 'ID урока'; -COMMENT ON COLUMN lessons.teacher_id IS 'Идентификатор преподавателя, который проводит урок'; -COMMENT ON COLUMN lessons.group_id IS 'ID группы, в которой проходит урок'; -COMMENT ON COLUMN lessons.subject_id IS 'ID предмета, который преподается'; -COMMENT ON COLUMN lessons.lesson_format IS 'Формат урока'; -COMMENT ON COLUMN lessons.type_lesson IS 'Тип урока'; -COMMENT ON COLUMN lessons.classroom_id IS 'ID аудитории, в которой проходит урок'; -COMMENT ON COLUMN lessons.day IS 'День недели, в который проходит урок'; -COMMENT ON COLUMN lessons.week IS 'Номер недели, в которой проходит урок'; -COMMENT ON COLUMN lessons.time IS 'Время урока'; - COMMENT ON COLUMN departments.id IS 'ID кафедры'; COMMENT ON COLUMN departments.name IS 'Название кафедры'; COMMENT ON COLUMN departments.code IS 'Код кафедры'; @@ -386,6 +1015,33 @@ COMMENT ON COLUMN specialties.id IS 'ID специальности'; COMMENT ON COLUMN specialties.name IS 'Название специальности'; COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности'; +COMMENT ON COLUMN specialty_profiles.id IS 'ID профиля обучения'; +COMMENT ON COLUMN specialty_profiles.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN specialty_profiles.name IS 'Название профиля обучения'; +COMMENT ON COLUMN specialty_profiles.description IS 'Описание профиля обучения'; + +COMMENT ON COLUMN academic_calendar_activity_types.code IS 'Код активности из календарного графика'; +COMMENT ON COLUMN academic_calendar_activity_types.name IS 'Название активности'; +COMMENT ON COLUMN academic_calendar_activity_types.allow_schedule IS 'Разрешает ли код генерацию обычного расписания'; + +COMMENT ON COLUMN academic_calendars.title IS 'Название календарного учебного графика'; +COMMENT ON COLUMN academic_calendars.academic_year_id IS 'ID учебного года'; +COMMENT ON COLUMN academic_calendars.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля обучения'; +COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms'; +COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике'; + +COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика'; +COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса'; +COMMENT ON COLUMN academic_calendar_days.date IS 'Дата'; +COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году'; +COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO'; +COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности'; + +COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы'; +COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года'; +COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика'; + COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя'; COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета'; -COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; \ No newline at end of file +COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md new file mode 100644 index 0000000..9225edb --- /dev/null +++ b/docs/ACADEMIC_CALENDAR.md @@ -0,0 +1,78 @@ +# Календарный учебный график + +## Назначение + +Календарный учебный график хранит правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. Динамическое расписание использует этот график как фильтр: обычные пары выводятся только в дни теоретического обучения. + +График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели. + +## Связи данных + +Основная цепочка выглядит так: + +```text +специальность -> профиль обучения -> календарный учебный график -> назначение группе +``` + +- `specialties` хранит код и название специальности, например `09.03.04`. +- `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей. +- `academic_calendars` хранит график профиля, формы обучения и учебного года. +- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет. +- `academic_calendar_days` хранит дневную сетку графика по курсу и дате. +- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения. +- `student_group_calendar_assignments` назначает группе конкретный график на учебный год. + +Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам. + +## Коды активностей + +| Код | Значение | Обычные пары | +|-----|----------|--------------| +| `Т` | Теоретическое обучение | Да | +| `Э` | Экзаменационная сессия | Нет | +| `К` | Каникулы | Нет | +| `У` | Учебная практика | Нет | +| `П` | Производственная практика | Нет | +| `Пд` | Преддипломная практика | Нет | +| `Н` | Научно-исследовательская работа | Нет | +| `Г` | Государственная итоговая аттестация | Нет | +| `Д` | Подготовка выпускной квалификационной работы | Нет | +| `ПА` | Промежуточная аттестация | Нет | +| `С` | Самостоятельная работа или специальный период | Нет | +| `*` | Праздничный или нерабочий день | Нет | +| `=` | День вне учебного года или пустая ячейка графика | Нет | + +Технически разрешение генерации задаётся полем `academic_calendar_activity_types.allow_schedule`. В seed-данных `true` установлен только для `Т`. + +## Правило генерации расписания + +`ScheduleGeneratorService` при запросе `GET /api/schedule` проверяет календарный график до вывода занятия: + +1. Определяется учебный год и семестр даты. +2. Для группы находится назначение графика на этот учебный год. +3. По году начала обучения группы вычисляется текущий курс. +4. В `academic_calendar_days` ищется ячейка для курса и даты. +5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`. +6. Если код не разрешает пары, день пропускается и академические часы не списываются. + +Если график группе не назначен, расписание группы возвращается пустым списком без ошибки. Это сделано намеренно: отсутствие календаря означает, что нет разрешённых дат для генерации. + +## Работа администратора + +1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. +2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. +3. В разделе календарного графика администратор создаёт учебный год и семестры. +4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. +5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. +6. В разделе групп администратор назначает календарный график группе на нужный учебный год. + +После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря. + +## Особенности модели + +- Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке. +- Дни вне учебного года или пустые периоды Excel хранятся кодом `=`. +- График привязан к профилю обучения, а не только к коду специальности. +- Форма обучения графика должна совпадать с формой обучения группы при назначении графика. +- Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры. +- Excel-импорт в первой реализации не предусмотрен: редактор заполняет данные через сайт. diff --git a/docs/API.md b/docs/API.md index 675c31c..85bd686 100644 --- a/docs/API.md +++ b/docs/API.md @@ -25,7 +25,9 @@ "message": "OK", "token": "550e8400-e29b-41d4-a716-446655440000", "role": "ADMIN", - "redirect": "/admin/" + "redirect": "/admin/", + "departmentId": 1, + "userId": 1 } ``` @@ -36,12 +38,40 @@ "message": "Неверное имя пользователя или пароль", "token": null, "role": null, - "redirect": null + "redirect": null, + "departmentId": null, + "userId": null } ``` > После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer ` +Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. + +Redirect по ролям: + +| Роль | Redirect | +|------|----------| +| `ADMIN` | `/admin/` | +| `EDUCATION_OFFICE` | `/admin/#schedule-view` | +| `DEPARTMENT` | `/admin/#department-workspace` | +| `SCHEDULE_VIEWER` | `/admin/#schedule-view` | +| `TEACHER` | `/teacher/` | +| `STUDENT` | `/student/` | + +### `GET /api/auth/me` + +Возвращает текущего пользователя по bearer-токену. + +```json +{ + "userId": 1, + "username": "admin", + "role": "ADMIN", + "departmentId": 1 +} +``` + --- ## Пользователи @@ -85,115 +115,430 @@ **Валидация:** - `username` — обязателен и уникален - `password` — минимум 4 символа -- `role` — `ADMIN`, `TEACHER` или `STUDENT` +- `role` — `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT` - `fullName` — обязателен - `departmentId` — обязателен ### `DELETE /api/users/{id}` -Удаление пользователя. +Архивирование пользователя. Исторические связи и расписание остаются в БД, но пользователь больше не может войти. + +### `POST /api/users/{id}/restore` + +Восстановление архивного пользователя. + +### `GET /api/users/{id}/department-history` + +История переводов преподавателя между кафедрами. + +### `POST /api/users/{id}/department-transfer` + +Перевод преподавателя на другую кафедру без потери прошлых связей. + +```json +{ + "departmentId": 2, + "validFrom": "2026-06-01", + "comment": "Перевод на кафедру ВТ" +} +``` + +### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01` + +Список преподавателей кафедры на конкретную дату по таблице истории. --- -## Расписание (Lessons) +## Права ролей на API -### `GET /api/users/lessons` +Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`. -Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория). +Важные ограничения: -**Ответ:** +- `DEPARTMENT` не может создавать аудитории, кафедры, специальности, группы, пользователей или правила расписания через API; +- `DEPARTMENT` создаёт и комментирует дисциплины через `/api/department/*`, где кафедра берётся из текущего пользователя; +- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только преподавателей и дисциплины своей кафедры; +- `SCHEDULE_VIEWER` имеет read-only доступ к просмотру расписаний, справочникам-фильтрам и загруженности. + +--- + +## Динамическое расписание + +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Лабораторные слоты можно назначать на подгруппы, лекции и практики всегда проводятся для всей выбранной группы или потока. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. + +### `GET /api/schedule` + +Получение расписания группы или преподавателя за период. + +**Параметры:** + +| Параметр | Обязателен | Описание | +|----------|------------|----------| +| `groupId` | Да, если нет `teacherId` | ID учебной группы | +| `teacherId` | Да, если нет `groupId` | ID преподавателя | +| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | +| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | + +Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/api/admin/time-slots`. + +**Пример:** +```http +GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 +``` + +**Ответ:** ```json [ { - "id": 1, - "teacherName": "Тестовый преподаватель", - "groupName": "ИВТ-21-1", - "classroomName": "101 Ленинская", - "educationFormName": "Бакалавриат", + "scheduleRuleId": 10, + "scheduleRuleSlotId": 31, + "date": "2026-04-27", + "dayOfWeek": 1, + "dayName": "Понедельник", + "weekNumber": 13, + "parity": "ODD", + "timeSlotId": 3, + "timeSlotOrder": 3, + "startTime": "11:40:00", + "endTime": "13:10:00", + "subjectId": 1, "subjectName": "Высшая математика", - "typeLesson": "Лекция", + "teacherId": 2, + "teacherName": "Петров Препод Петрович", + "classroomId": 1, + "classroomName": "101 Ленинская", + "lessonTypeId": 1, + "lessonTypeName": "Лекция", "lessonFormat": "Очно", - "day": "Понедельник", - "week": "Верхняя", - "time": "11:40 - 13:10" + "subgroupId": null, + "subgroupName": null, + "subgroupIds": [], + "subgroupNames": [], + "groupIds": [1], + "groupNames": ["ИВТ-21-1"], + "activityType": "Т", + "lessonTypeAcademicHours": 32, + "consumedLessonTypeAcademicHoursBeforeLesson": 12, + "remainingLessonTypeAcademicHoursAfterLesson": 18 } ] ``` -### `GET /api/users/lessons/{teacherId}` +### `GET /api/admin/time-slots` -Занятия конкретного преподавателя. +Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной. -### `POST /api/users/lessons/create` +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/time-slots` | Список слотов | +| `GET` | `/api/admin/time-slots/effective?date=2026-05-02` | Эффективные слоты для даты | +| `POST` | `/api/admin/time-slots` | Создать слот | +| `PUT` | `/api/admin/time-slots/{id}` | Обновить слот | +| `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот | +| `GET` | `/api/admin/time-slots/scopes` | Список сеток времени | +| `POST` | `/api/admin/time-slots/scopes` | Создать ручную сетку | +| `PUT` | `/api/admin/time-slots/scopes/{id}` | Переименовать ручную сетку | +| `DELETE` | `/api/admin/time-slots/scopes/{id}` | Удалить ручную сетку | +| `GET` | `/api/admin/time-slots/date-assignments` | Ручные назначения дат | +| `POST` | `/api/admin/time-slots/date-assignments` | Применить ручную сетку к дате | +| `DELETE` | `/api/admin/time-slots/date-assignments/{id}` | Убрать ручное назначение | + +**Тело создания/обновления:** +```json +{ + "orderNumber": 1, + "scopeId": 1, + "startTime": "08:00:00", + "endTime": "09:30:00", + "durationMinutes": 90 +} +``` + +**Создание ручной сетки:** +```json +{ + "name": "Праздничная сетка" +} +``` + +**Ручное применение сетки к дате:** +```json +{ + "date": "2026-05-08", + "scopeId": 3 +} +``` + +Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments`, имеют приоритет над автоматической субботней сеткой и назначаются пользователем из модального окна ячейки редактора календарного графика. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации. + +### Учебные годы, семестры и коды календарного графика + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/calendar/years` | Учебные годы с семестрами | +| `POST` | `/api/admin/calendar/years` | Создать учебный год | +| `PUT` | `/api/admin/calendar/years/{id}` | Обновить учебный год | +| `DELETE` | `/api/admin/calendar/years/{id}` | Удалить учебный год | +| `GET` | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | +| `POST` | `/api/admin/calendar/years/{academicYearId}/semesters` | Создать семестр | +| `PUT` | `/api/admin/calendar/semesters/{id}` | Обновить семестр | +| `GET` | `/api/admin/calendar/activity-types` | Справочник кодов активностей графика | +| `POST` | `/api/admin/calendar/activity-types` | Создать код активности | +| `PUT` | `/api/admin/calendar/activity-types/{id}` | Обновить код активности | +| `DELETE` | `/api/admin/calendar/activity-types/{id}` | Удалить код активности | + +**Тело создания/обновления кода активности:** +```json +{ + "code": "Т", + "name": "Теоретическое обучение", + "allowSchedule": true, + "colorCode": "#22c55e", + "displayOrder": 10, + "description": "Обычные пары разрешены" +} +``` + +### Календарные учебные графики + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | +| `GET` | `/api/admin/academic-calendars/{id}` | Один график | +| `POST` | `/api/admin/academic-calendars` | Создать график | +| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | +| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график | +| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | +| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки | + +**Тело создания/обновления графика:** +```json +{ + "title": "09.03.04 очная форма 2025-2026", + "academicYearId": 1, + "specialtyId": 2, + "specialtyProfileId": 3, + "studyFormId": 1, + "courseCount": 4 +} +``` + +`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. + +**Ячейка сетки графика:** +```json +{ + "calendarId": 1, + "courseNumber": 1, + "date": "2025-09-01", + "weekNumber": 1, + "dayOfWeek": 1, + "activityTypeId": 1 +} +``` + +### `POST /api/admin/schedule-rules` + +Создание правила динамического расписания. -Создание занятия. - -**Тело запроса:** ```json { - "teacherId": 2, - "groupId": 1, "subjectId": 1, - "lessonFormat": "Очно", - "typeLesson": "Лекция", - "classroomId": 1, - "day": "Понедельник", - "week": "Верхняя", - "time": "11:40 - 13:10" + "semesterId": 1, + "lectureAcademicHours": 32, + "laboratoryAcademicHours": 16, + "practiceAcademicHours": 24, + "lectureStartWeek": 1, + "laboratoryStartWeek": 3, + "practiceStartWeek": 2, + "groupIds": [1, 2], + "slots": [ + { + "dayOfWeek": 1, + "parity": "BOTH", + "timeSlotId": 3, + "subgroupId": null, + "subgroupIds": [], + "teacherId": 2, + "classroomId": 1, + "lessonTypeId": 1, + "lessonFormat": "Очно" + } + ] } ``` -**Валидация:** +CRUD доступен по: -| Поле | Правило | -|------|---------| -| `teacherId` | Обязателен, ≠ 0 | -| `groupId` | Обязателен, ≠ 0 | -| `subjectId` | Обязателен, ≠ 0 | -| `lessonFormat` | `Очно` или `Онлайн` | -| `typeLesson` | `Лекция`, `Практическая работа`, `Лабораторная работа` | -| `classroomId` | Обязателен, ≠ 0 | -| `day` | Пн–Сб (на русском) | -| `week` | `Верхняя`, `Нижняя`, `Обе` | -| `time` | Обязателен | +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` | +| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило | +| `POST` | `/api/admin/schedule-rules` | Создать правило | +| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | +| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило | +`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. -### `PUT /api/users/lessons/update/{lessonId}` +`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы. -Обновление занятия. Поддерживает partial update — передаются только изменённые поля. +Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. + +### `GET /api/lesson-types` + +Справочник типов занятий для конструктора правил расписания. + +**Ответ:** +```json +[ + { "id": 1, "name": "Лекция" }, + { "id": 2, "name": "Практика" }, + { "id": 3, "name": "Лабораторная работа" } +] +``` + +### `GET /api/schedule/search` + +Расширенный поиск расписания. В отличие от `GET /api/schedule`, принимает несколько фильтров одновременно. + +| Параметр | Описание | +|----------|----------| +| `startDate` / `endDate` | Обязательный период | +| `groupId` | Учебная группа | +| `teacherId` | Преподаватель | +| `classroomId` | Аудитория | +| `departmentId` | Кафедра | +| `subjectId` | Дисциплина | +| `lessonTypeId` | Тип занятия | +| `timeSlotId` | Временной слот | +| `parity` | `BOTH`, `ODD`, `EVEN` | + +Пример: + +```http +GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27 +``` + +Ответ совпадает со структурой `RenderedLessonDto` из `GET /api/schedule`. + +### Точечные изменения расписания учебного отдела + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/edu-office/schedule/overrides` | Список точечных изменений | +| `POST` | `/api/edu-office/schedule/overrides` | Создать перенос, отмену или замену | +| `PUT` | `/api/edu-office/schedule/overrides/{id}` | Обновить изменение | +| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение | -**Тело ответа:** ```json { - "id": 5, - "teacherId": 1, - "groupId": 1, - "subjectId": 2, - "LessonFormat": "Онлайн", - "typeLesson": "Практическая работа", - "classroomId": 3, - "day": "Понедельник", - "week": "Верхняя", - "time": "9:40 - 11:10", - "updatedFields": { - "teacherId": 1, - "subjectId": 2, - "lessonFormat": "Онлайн", - "classroomId": 3, - "day": "Понедельник", - "time": "9:40 - 11:10" - }, - "message": "Занятие успешно обновлено" + "baseRuleSlotId": 31, + "lessonDate": "2026-05-21", + "action": "REPLACE", + "newClassroomId": 2, + "newTeacherId": 5, + "comment": "Замена аудитории и преподавателя" } ``` -### `DELETE /api/users/lessons/delete/{lessonId}` +`action=CANCEL` отменяет конкретную пару. `MOVE` и `REPLACE` могут менять аудиторию, преподавателя, формат и временной слот. -Удаление занятия. +## Загруженность -### `GET /api/users/lessons/ping` +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/workload/teachers` | Загруженность преподавателей | +| `GET` | `/api/workload/classrooms` | Загруженность аудиторий | +| `GET` | `/api/workload/departments` | Загруженность кафедр | +| `GET` | `/api/workload/time-slots` | Загруженность по парам | +| `GET` | `/api/workload/free-classrooms` | Свободные аудитории на дату и пару | -Проверка доступности контроллера. Возвращает строку `pong`. +Общие параметры для отчётов: `startDate`, `endDate`, опционально `departmentId`. + +Пример: + +```http +GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-01 +``` + +Ответ: + +```json +[ + { + "id": 2, + "name": "Петров Препод Петрович", + "departmentId": 1, + "departmentName": "Кафедра ИБ", + "lessonCount": 8, + "academicHours": 16, + "occupiedSlotCount": 8 + } +] +``` + +## Кабинет кафедры + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/department/subjects` | Дисциплины текущей кафедры | +| `POST` | `/api/department/subjects/import` | Загрузка списка дисциплин | +| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины | +| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий | +| `GET` | `/api/department/teachers` | Преподаватели кафедры | +| `GET` | `/api/department/schedule` | Расписание кафедры | + +--- + +## Кафедры и специальности + +### Кафедры + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/departments` | Список кафедр | +| `POST` | `/api/departments` | Создать кафедру | +| `PUT` | `/api/departments/{id}` | Обновить кафедру | +| `DELETE` | `/api/departments/{id}` | Удалить кафедру | + +**Тело создания/обновления:** +```json +{ + "departmentName": "Кафедра ИБ", + "departmentCode": 1 +} +``` + +### Специальности + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/specialties` | Список специальностей | +| `POST` | `/api/specialties` | Создать специальность | +| `PUT` | `/api/specialties/{id}` | Обновить специальность | +| `DELETE` | `/api/specialties/{id}` | Удалить специальность | +| `GET` | `/api/specialties/{id}/profiles` | Профили выбранной специальности | +| `POST` | `/api/specialties/{id}/profiles` | Создать профиль | +| `PUT` | `/api/specialties/{id}/profiles/{profileId}` | Обновить профиль | +| `DELETE` | `/api/specialties/{id}/profiles/{profileId}` | Удалить профиль | + +**Тело создания/обновления:** +```json +{ + "specialityName": "Программная инженерия", + "specialityCode": "09.03.04" +} +``` + +При создании специальности автоматически создаётся профиль `Без профиля`. + +**Тело создания/обновления профиля:** +```json +{ + "name": "Безопасность автоматизированных систем", + "description": "Необязательное описание" +} +``` --- @@ -213,7 +558,14 @@ "educationFormId": 1, "educationFormName": "Бакалавриат", "departmentId": 1, + "yearStartStudy": 2023, "course": 3, + "semester": 6, + "specialtyId": 1, + "specialtyCode": "09.03.04", + "specialtyName": "Программная инженерия", + "specialtyProfileId": 2, + "specialtyProfileName": "Без профиля", "specialityCode": 1 } ] @@ -233,8 +585,29 @@ "groupSize": 12, "educationFormId": 1, "departmentId": 1, - "course": 2, - "specialityCode": 1 + "yearStartStudy": 2026, + "specialtyId": 1, + "specialtyProfileId": 2 +} +``` + +`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`. + +Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`. + +### `PUT /api/groups/{id}` + +Редактирование группы. + +```json +{ + "name": "ИВТ-11", + "groupSize": 24, + "educationFormId": 1, + "departmentId": 1, + "yearStartStudy": 2026, + "specialtyId": 1, + "specialtyProfileId": 2 } ``` @@ -242,6 +615,55 @@ Удаление группы. +### Подгруппы группы + +Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`. + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/subgroups` | Список всех подгрупп | +| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы | +| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу | +| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу | +| `DELETE` | `/api/groups/{groupId}/subgroups/{id}` | Удалить подгруппу, если она не используется в расписании | + +**Тело создания/обновления:** +```json +{ + "name": "Подгруппа 1", + "studentCapacity": 12 +} +``` + +**Ответ:** +```json +{ + "id": 1, + "groupId": 1, + "groupName": "ИВТ-21-1", + "name": "Подгруппа 1", + "studentCapacity": 12 +} +``` + +### Календарные графики группы + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/groups/{id}/calendar-assignments` | Список назначений графиков группе | +| `PUT` | `/api/groups/{id}/calendar-assignments` | Создать или заменить назначение на учебный год | +| `DELETE` | `/api/groups/{id}/calendar-assignments/{assignmentId}` | Удалить назначение | + +**Тело назначения графика:** +```json +{ + "academicYearId": 1, + "calendarId": 5 +} +``` + +Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. + --- ## Аудитории @@ -257,6 +679,8 @@ "id": 1, "name": "101 Ленинская", "capacity": 120, + "building": "Главный корпус", + "floor": 2, "isAvailable": true, "equipments": [ { "id": 1, "name": "Проектор" }, @@ -274,6 +698,8 @@ { "name": "404 Лаборатория", "capacity": 30, + "building": "Лабораторный корпус", + "floor": 4, "isAvailable": true, "equipmentIds": [1, 2, 3] } @@ -285,7 +711,11 @@ ### `DELETE /api/classrooms/{id}` -Удаление аудитории. +Архивирование аудитории. Архивная аудитория остаётся в историческом расписании, но не выбирается в новых назначениях. + +### `POST /api/classrooms/{id}/restore` + +Восстановление архивной аудитории. --- @@ -363,7 +793,7 @@ ### `DELETE /api/education-forms/{id}` -Удаление формы обучения. **Невозможно**, если к ней привязаны группы. +Удаление формы обучения. **Невозможно**, если к ней привязаны группы или календарные учебные графики. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f4e3cf9..1c5a700 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,7 @@ graph TD - **Порт:** 8080 (внутренний) - **ORM:** Hibernate (JPA), `ddl-auto=none` - **Миграции:** Flyway (программный запуск при подключении тенанта) -- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены +- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей ### PostgreSQL - **Версия:** `postgres:alpine3.23` @@ -131,12 +131,16 @@ sequenceDiagram ## Аутентификация -Система использует **простую модель аутентификации** без JWT или Spring Security фильтров: +Система использует простую модель аутентификации без JWT и без полноценного Spring Security: 1. Клиент отправляет `POST /api/auth/login` с `username` и `password` 2. Backend проверяет пароль через `BCryptPasswordEncoder` 3. При успехе возвращается: - UUID-токен (для заголовка `Authorization: Bearer`) - - Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`) - - Redirect URL (`/admin/`, `/teacher/`, `/student/`) + - Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`) + - Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`) 4. Токен хранится в `localStorage` на клиенте + +Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно. + +`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 08cf64e..2fa695c 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -2,19 +2,33 @@ ## Ролевая модель -Система поддерживает три роли пользователей: +Система поддерживает шесть ролей пользователей: | Роль | Enum | Возможности | |------|------|------------| -| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). | +| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | +| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | +| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | +| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | После авторизации пользователь перенаправляется на свой интерфейс: - `ADMIN` → `/admin/` +- `EDUCATION_OFFICE` → `/admin/#schedule-view` +- `DEPARTMENT` → `/admin/#department-workspace` +- `SCHEDULE_VIEWER` → `/admin/#schedule-view` - `TEACHER` → `/teacher/` - `STUDENT` → `/student/` +Bearer-токен проверяется на backend. Frontend-скрытие пунктов меню является только удобством, а не источником прав. + +Для роли `DEPARTMENT` backend дополнительно ограничивает изменения рамками кафедры текущего пользователя: + +- загрузка и комментарии дисциплин идут через `/api/department/*`; +- общий `/api/subjects` доступен кафедре только на чтение; +- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя. + --- ## Управление ресурсами @@ -28,27 +42,31 @@ ### Специальности (Specialties) -Учебные направления с кодом по ФГОС. +Учебные направления с кодом по ФГОС. У одной специальности может быть несколько профилей обучения, например базовый профиль и профиль конкретной образовательной программы. - Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04) +- При создании специальности автоматически создаётся профиль `Без профиля` ### Формы обучения (Education Forms) -Уровни/формы обучения для привязки к группам. +Уровни/формы обучения для привязки к группам и календарным учебным графикам. - Предзаполнены: Бакалавриат, Магистратура, Специалитет -- Нельзя удалить форму обучения, если к ней привязаны группы +- Нельзя удалить форму обучения, если к ней привязаны группы или календарные графики ### Учебные группы (Student Groups) -- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (1–6) +- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения +- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) +- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график ### Аудитории (Classrooms) - **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность - **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества - **Статус:** Флаг `is_available` для блокирования назначения пар +- **Жизненный цикл:** `status=ARCHIVED` означает вывод из эксплуатации. Такая аудитория отображается в прошлом расписании, но запрещена для новых назначений. ### Оборудование (Equipments) @@ -61,65 +79,105 @@ - **Поля:** Название (уникальное), код, кафедра, описание - Привязка преподавателей через `teacher_subjects` (Many-to-Many) +- Кафедра может добавлять комментарии к дисциплине и загружать список дисциплин через кабинет кафедры + +### Жизненный цикл справочников + +Справочники, которые участвуют в расписании и отчётах, не удаляются физически. Для них используется архивирование: + +- `ACTIVE` — запись доступна для выбора; +- `ARCHIVED` — запись остаётся в истории, но не используется в будущих назначениях. + +Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия. + +### Перевод преподавателей между кафедрами + +Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`. + +Правила: + +- у преподавателя должна быть одна открытая основная кафедра; +- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`; +- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой. --- ## Логика расписания -### Сущность «Занятие» (Lesson) +### Динамическая модель расписания -Каждая запись в расписании содержит: +Основная модель расписания строится из правил, а не из отдельных статических пар. -| Поле | Описание | Пример | -|------|----------|--------| -| `teacher_id` | Преподаватель | 2 | -| `group_id` | Учебная группа | 1 | -| `subject_id` | Дисциплина | 3 | -| `lesson_format` | Формат проведения | `Очно`, `Онлайн` | -| `type_lesson` | Тип занятия | `Лекция`, `Практическая работа`, `Лабораторная работа` | -| `classroom_id` | Аудитория | 1 | -| `day` | День недели | `Понедельник` ... `Суббота` | -| `week` | Чётность недели | `Верхняя`, `Нижняя`, `Обе` | -| `time` | Временной слот | `8:00 - 9:30` | +| Компонент | Назначение | +|-----------|------------| +| `academic_years` / `semesters` | Учебные годы и семестры. Неделя 1 считается от `semesters.start_date` | +| `specialty_profiles` | Профили обучения внутри специальности | +| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года | +| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | +| `academic_calendar_days` | Дневная сетка по курсу и дате | +| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | +| `time_slots` | Настраиваемая сетка пар для тенанта | +| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | +| `schedule_rule_groups` | Группы правила, включая потоковые лекции | +| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | +| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | +| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар | -### Временны́е слоты +Генератор `ScheduleGeneratorService` рендерит расписание по запросу: +1. Определяет семестр для каждой даты диапазона. +2. Вычисляет номер недели и чётность. +3. Находит назначенный группе календарный учебный график на учебный год даты. +4. Определяет код активности по курсу группы и конкретной дате. +5. Пропускает день, если код активности не разрешает обычные пары. +6. Загружает правила группы или преподавателя. +7. Для каждого типа занятия проверяет свою неделю начала в семестре. +8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую. +9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы. +10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. +11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах. -Система использует 7 фиксированных слотов по 90 минут: +Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. + +Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. + +### Временные слоты + +Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. Ручное назначение выполняется из ячейки редактора календарного графика, потому что оно относится к конкретной учебной дате. + +Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание. + +При миграции одинаковые стартовые слоты создаются для базовой и субботней сетки: | № | Время | |---|-------| | 1 | 08:00 – 09:30 | | 2 | 09:40 – 11:10 | | 3 | 11:40 – 13:10 | -| 4 | 13:30 – 15:00 | +| 4 | 13:20 – 14:50 | | 5 | 15:00 – 16:30 | -| 6 | 16:40 – 18:10 | +| 6 | 16:50 – 18:20 | | 7 | 18:30 – 20:00 | -### Валидация при создании/обновлении +### Валидация правил расписания -- **Дни:** только `Понедельник` – `Суббота` (`DayAndWeekValidator`) -- **Недели:** только `Верхняя`, `Нижняя`, `Обе` -- **Формат:** только `Очно`, `Онлайн` (`TypeAndFormatLessonValidator`) -- **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа` -- Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0 +- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа. +- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. +- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. +- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. +- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы. +- **Формат:** `lessonFormat` обязателен и хранится в слоте правила. +- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах. +- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю. -### Данные к составлению расписания (Schedule Data) +### Точечные изменения расписания -Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания: +Учебный отдел может создать изменение конкретной пары: -| Поле | Описание | -|------|----------| -| `department_id` | Кафедра | -| `semester` | Номер семестра | -| `group_id` | Учебная группа | -| `subjects_id` | Дисциплина | -| `lesson_type_id` | Тип занятия | -| `number_of_hours` | Количество часов | -| `is_division` | Деление на подгруппы | -| `teacher_id` | Преподаватель | -| `semester_type` | Тип семестра (Весенний / Осенний) | -| `period` | Учебный год (напр. `2024/2025`) | +- `CANCEL` — отменить пару; +- `MOVE` — перенести пару на другой временной слот или в другую аудиторию; +- `REPLACE` — заменить преподавателя, аудиторию или формат. + +Изменения не переписывают базовое правило, а накладываются поверх сгенерированного расписания на конкретную дату. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c025547..9e3b82d 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -19,11 +19,18 @@ erDiagram VARCHAR name BIGINT code UK } - + specialties { BIGSERIAL id PK VARCHAR name - VARCHAR specialty_code + VARCHAR specialty_code UK + } + + specialty_profiles { + BIGSERIAL id PK + BIGINT specialty_id FK + VARCHAR name + TEXT description } users { @@ -34,6 +41,10 @@ erDiagram VARCHAR full_name VARCHAR job_title BIGINT department_id FK + VARCHAR status + DATE active_from + DATE active_to + TIMESTAMP archived_at TIMESTAMP created_at TIMESTAMP updated_at } @@ -47,11 +58,13 @@ erDiagram student_groups { BIGSERIAL id PK - VARCHAR name UK + VARCHAR name BIGINT group_size BIGINT education_form_id FK BIGINT department_id FK - INT course + BIGINT specialty_id FK + BIGINT specialty_profile_id FK + BIGINT year_start_study TIMESTAMP created_at } @@ -92,6 +105,10 @@ erDiagram VARCHAR building INT floor BOOLEAN is_available + VARCHAR status + DATE active_from + DATE active_to + TIMESTAMP archived_at TEXT description TIMESTAMP created_at } @@ -109,6 +126,24 @@ erDiagram VARCHAR qualification_level INT experience_years } + + teacher_department_assignments { + BIGSERIAL id PK + BIGINT teacher_id FK + BIGINT department_id FK + DATE valid_from + DATE valid_to + BOOLEAN is_primary + TEXT comment + } + + subject_comments { + BIGSERIAL id PK + BIGINT subject_id FK + BIGINT author_id FK + TEXT comment + TIMESTAMP created_at + } teacher_lesson_types { BIGINT user_id FK,PK @@ -116,54 +151,182 @@ erDiagram BIGINT lesson_type_id FK,PK } - lessons { + time_slot_scopes { BIGSERIAL id PK - BIGINT teacher_id FK - BIGINT group_id FK - BIGINT subject_id FK - VARCHAR lesson_format - VARCHAR type_lesson - BIGINT classroom_id FK - VARCHAR day - VARCHAR week - VARCHAR time + VARCHAR code UK + VARCHAR name + VARCHAR apply_mode + INT day_of_week + BOOLEAN system_scope + INT display_order } - - schedule_data { + + time_slots { BIGSERIAL id PK - BIGINT department_id FK - INT semester - BIGINT group_id FK - BIGINT subjects_id FK - BIGINT lesson_type_id FK - INT number_of_hours - BOOLEAN is_division - BIGINT teacher_id FK + BIGINT time_slot_scope_id FK + INT order_number + TIME start_time + TIME end_time + INT duration_minutes + } + + time_slot_date_assignments { + BIGSERIAL id PK + DATE assignment_date UK + BIGINT time_slot_scope_id FK + } + + academic_years { + BIGSERIAL id PK + VARCHAR title UK + DATE start_date + DATE end_date + } + + semesters { + BIGSERIAL id PK + BIGINT academic_year_id FK VARCHAR semester_type - VARCHAR period + DATE start_date + DATE end_date + } + + academic_calendar_activity_types { + BIGSERIAL id PK + VARCHAR code UK + VARCHAR name + BOOLEAN allow_schedule + VARCHAR color_code + INT display_order + } + + academic_calendars { + BIGSERIAL id PK + VARCHAR title + BIGINT academic_year_id FK + BIGINT specialty_id FK + BIGINT specialty_profile_id FK + BIGINT study_form_id FK + INT course_count + TIMESTAMP created_at + TIMESTAMP updated_at + } + + academic_calendar_days { + BIGSERIAL id PK + BIGINT calendar_id FK + INT course_number + DATE date + INT week_number + INT day_of_week + BIGINT activity_type_id FK + } + + student_group_calendar_assignments { + BIGSERIAL id PK + BIGINT group_id FK + BIGINT academic_year_id FK + BIGINT calendar_id FK + } + + schedule_rules { + BIGSERIAL id PK + BIGINT subject_id FK + BIGINT semester_id FK + VARCHAR status + DATE valid_from + DATE valid_to + INT lecture_academic_hours + INT laboratory_academic_hours + INT practice_academic_hours + INT lecture_start_week + INT laboratory_start_week + INT practice_start_week + } + + schedule_rule_groups { + BIGINT schedule_rule_id FK,PK + BIGINT group_id FK,PK + } + + schedule_rule_slots { + BIGSERIAL id PK + BIGINT schedule_rule_id FK + INT day_of_week + VARCHAR parity + BIGINT time_slot_id FK + BIGINT teacher_id FK + BIGINT classroom_id FK + BIGINT lesson_type_id FK + VARCHAR lesson_format + BOOLEAN time_locked + BOOLEAN classroom_locked + BOOLEAN teacher_locked + } + + schedule_overrides { + BIGSERIAL id PK + BIGINT base_rule_slot_id FK + DATE lesson_date + VARCHAR action + BIGINT new_time_slot_id FK + BIGINT new_classroom_id FK + BIGINT new_teacher_id FK + VARCHAR new_lesson_format + TEXT comment + } + + schedule_rule_slot_subgroups { + BIGINT schedule_rule_slot_id FK,PK + BIGINT subgroup_id FK,PK } departments ||--o{ users : "department_id" departments ||--o{ student_groups : "department_id" departments ||--o{ subjects : "department_id" - departments ||--o{ schedule_data : "department_id" education_forms ||--o{ student_groups : "education_form_id" + specialties ||--o{ specialty_profiles : "specialty_id" + specialties ||--o{ student_groups : "specialty_id" + specialty_profiles ||--o{ student_groups : "specialty_profile_id" student_groups ||--o{ subgroups : "group_id" - student_groups ||--o{ lessons : "group_id" - student_groups ||--o{ schedule_data : "group_id" - users ||--o{ lessons : "teacher_id" + student_groups ||--o{ schedule_rule_groups : "group_id" + student_groups ||--o{ student_group_calendar_assignments : "group_id" users ||--o{ teacher_subjects : "user_id" + users ||--o{ teacher_department_assignments : "teacher_id" + departments ||--o{ teacher_department_assignments : "department_id" users ||--o{ teacher_lesson_types : "user_id" - users ||--o{ schedule_data : "teacher_id" - subjects ||--o{ lessons : "subject_id" + users ||--o{ subject_comments : "author_id" subjects ||--o{ teacher_subjects : "subject_id" + subjects ||--o{ subject_comments : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id" - subjects ||--o{ schedule_data : "subjects_id" + subjects ||--o{ schedule_rules : "subject_id" lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" - lesson_types ||--o{ schedule_data : "lesson_type_id" - classrooms ||--o{ lessons : "classroom_id" + lesson_types ||--o{ schedule_rule_slots : "lesson_type_id" + classrooms ||--o{ schedule_rule_slots : "classroom_id" classrooms ||--o{ classroom_equipments : "classroom_id" equipments ||--o{ classroom_equipments : "equipment_id" + academic_years ||--o{ semesters : "academic_year_id" + academic_years ||--o{ academic_calendars : "academic_year_id" + academic_years ||--o{ student_group_calendar_assignments : "academic_year_id" + semesters ||--o{ schedule_rules : "semester_id" + specialties ||--o{ academic_calendars : "specialty_id" + specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" + education_forms ||--o{ academic_calendars : "study_form_id" + academic_calendars ||--o{ academic_calendar_days : "calendar_id" + academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" + academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" + schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" + schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" + schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id" + schedule_rule_slots ||--o{ schedule_overrides : "base_rule_slot_id" + time_slots ||--o{ schedule_overrides : "new_time_slot_id" + classrooms ||--o{ schedule_overrides : "new_classroom_id" + users ||--o{ schedule_overrides : "new_teacher_id" + time_slot_scopes ||--o{ time_slots : "time_slot_scope_id" + time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id" + time_slots ||--o{ schedule_rule_slots : "time_slot_id" + subgroups ||--o{ schedule_rule_slot_subgroups : "subgroup_id" + users ||--o{ schedule_rule_slots : "teacher_id" ``` --- @@ -184,7 +347,15 @@ erDiagram |---------|-----|----------| | `id` | BIGSERIAL PK | ID специальности | | `name` | VARCHAR(255) | Название специальности | -| `specialty_code` | VARCHAR(255) | Код ФГОС (напр. `10.03.01`) | +| `specialty_code` | VARCHAR(255) UNIQUE | Код ФГОС (напр. `10.03.01`) | + +#### `specialty_profiles` — Профили обучения +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID профиля | +| `specialty_id` | BIGINT FK → specialties | Специальность, внутри которой существует профиль | +| `name` | VARCHAR(255) | Название профиля, уникально внутри специальности | +| `description` | TEXT | Описание профиля | ### Пользователи @@ -198,6 +369,11 @@ erDiagram | `full_name` | VARCHAR(255) | ФИО | | `job_title` | VARCHAR(255) | Должность | | `department_id` | BIGINT FK → departments | Кафедра | +| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти | +| `active_from` | DATE | Дата начала действия записи | +| `active_to` | DATE | Дата окончания действия записи | +| `archived_at` | TIMESTAMP | Когда пользователь архивирован | +| `archive_reason` | TEXT | Причина архивирования | | `created_at` | TIMESTAMP | Дата создания | | `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) | @@ -212,15 +388,19 @@ erDiagram | `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) | | `description` | TEXT | Описание | +Эта таблица используется и группами (`student_groups.education_form_id`), и календарными учебными графиками (`academic_calendars.study_form_id`). Отдельного справочника форм обучения для календарей нет. + #### `student_groups` — Учебные группы | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `name` | VARCHAR(100) UNIQUE | Название группы (напр. `ИВТ-21-1`) | +| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное | | `group_size` | BIGINT | Количество студентов | | `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `department_id` | BIGINT FK → departments | Кафедра | -| `course` | INT CHECK(1–6) | Курс | +| `specialty_id` | BIGINT FK → specialties | Специальность | +| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы | +| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | #### `subgroups` — Подгруппы | Колонка | Тип | Описание | @@ -230,6 +410,8 @@ erDiagram | `name` | VARCHAR(100) | Название подгруппы | | `student_capacity` | INT | Количество студентов | +Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий. + #### `subjects` — Дисциплины | Колонка | Тип | Описание | |---------|-----|----------| @@ -250,6 +432,11 @@ erDiagram | `building` | VARCHAR(50) | Корпус | | `floor` | INT | Этаж | | `is_available` | BOOLEAN | Доступна для назначения пар | +| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях | +| `active_from` | DATE | Дата начала действия записи | +| `active_to` | DATE | Дата окончания действия записи | +| `archived_at` | TIMESTAMP | Когда аудитория выведена из эксплуатации | +| `archive_reason` | TEXT | Причина архивирования | | `description` | TEXT | Описание | #### `equipments` — Оборудование @@ -270,20 +457,6 @@ erDiagram ### Расписание -#### `lessons` — Основное расписание занятий -| Колонка | Тип | Описание | -|---------|-----|----------| -| `id` | BIGSERIAL PK | ID | -| `teacher_id` | BIGINT FK → users | Преподаватель | -| `group_id` | BIGINT FK → student_groups | Группа | -| `subject_id` | BIGINT FK → subjects | Дисциплина | -| `lesson_format` | VARCHAR(255) | `Очно` / `Онлайн` | -| `type_lesson` | VARCHAR(255) | `Лекция` / `Практическая работа` / `Лабораторная работа` | -| `classroom_id` | BIGINT FK → classrooms | Аудитория | -| `day` | VARCHAR(255) | День недели | -| `week` | VARCHAR(255) | `Верхняя` / `Нижняя` / `Обе` | -| `time` | VARCHAR(255) | Временной слот | - #### `lesson_types` — Типы занятий (справочник) | Колонка | Тип | Описание | |---------|-----|----------| @@ -309,20 +482,189 @@ erDiagram | `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | | `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия | -#### `schedule_data` — Данные к составлению расписания +#### `teacher_department_assignments` — История кафедр преподавателя +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID исторической записи | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `department_id` | BIGINT FK → departments | Кафедра | +| `valid_from` | DATE | Дата начала принадлежности | +| `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры | +| `is_primary` | BOOLEAN | Основная кафедра преподавателя | +| `comment` | TEXT | Комментарий к переводу | +| `created_at` | TIMESTAMP | Дата создания записи | +| `created_by` | BIGINT FK → users | Кто оформил перевод | + +Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. + +#### `subject_comments` — Комментарии к дисциплинам +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID комментария | +| `subject_id` | BIGINT FK → subjects | Дисциплина | +| `author_id` | BIGINT FK → users | Автор комментария | +| `comment` | TEXT | Текст комментария | +| `created_at` | TIMESTAMP | Дата создания | + +#### `time_slot_scopes` — Сетки времени | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `department_id` | BIGINT FK → departments | Кафедра | -| `semester` | INT | Номер семестра | -| `group_id` | BIGINT FK → student_groups | Группа | -| `subjects_id` | BIGINT FK → subjects | Дисциплина | -| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | -| `number_of_hours` | INT | Количество часов | -| `is_division` | BOOLEAN | Деление на подгруппы | +| `code` | VARCHAR(50) UNIQUE | Системный код сетки | +| `name` | VARCHAR(120) | Название в интерфейсе | +| `apply_mode` | VARCHAR(20) | `DEFAULT`, `WEEKDAY`, `MANUAL` | +| `day_of_week` | INT NULL | День недели для автоматической сетки | +| `system_scope` | BOOLEAN | Защищает базовую и субботнюю сетки от удаления | +| `display_order` | INT | Порядок в списках | + +Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботняя сетка` (`WEEKDAY`, `day_of_week = 6`). Дополнительные сетки создаются как `MANUAL` и применяются только через ручные назначения дат. + +#### `time_slots` — Временные слоты занятий +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Сетка времени | +| `order_number` | INT | Номер пары в дне | +| `start_time` | TIME | Время начала | +| `end_time` | TIME | Время окончания | +| `duration_minutes` | INT | Длительность в минутах | + +Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может быть только один слот с номером пары. + +#### `time_slot_date_assignments` — Ручные назначения сеток времени +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `assignment_date` | DATE UNIQUE | Дата ручного применения | +| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Ручная сетка времени | + +#### `academic_years` — Учебные годы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `title` | VARCHAR(20) UNIQUE | Название, например `2025-2026` | +| `start_date` | DATE | Дата начала | +| `end_date` | DATE | Дата окончания | + +#### `semesters` — Семестры +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | +| `semester_type` | VARCHAR(20) | `autumn` или `spring` | +| `start_date` | DATE | Дата начала, от неё считается неделя 1 | +| `end_date` | DATE | Дата окончания | + +#### `academic_calendar_activity_types` — Коды активностей графика +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `code` | VARCHAR(10) UNIQUE | Код из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | +| `name` | VARCHAR(100) | Расшифровка кода | +| `allow_schedule` | BOOLEAN | Разрешает генерацию обычных пар | +| `color_code` | VARCHAR(7) | HEX-цвет для редактора | +| `display_order` | INT | Порядок вывода в UI | +| `description` | TEXT | Дополнительное описание | + +#### `academic_calendars` — Календарные учебные графики +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `title` | VARCHAR(255) | Название графика | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | +| `specialty_id` | BIGINT FK → specialties | Специальность | +| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | +| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника | +| `course_count` | INT CHECK(> 0) | Количество курсов в сетке | +| `created_at` | TIMESTAMP | Дата создания | +| `updated_at` | TIMESTAMP | Дата обновления | + +#### `academic_calendar_days` — Дневная сетка календарного графика +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | +| `course_number` | INT | Номер курса | +| `date` | DATE | Дата учебного года | +| `week_number` | INT | Номер недели от начала учебного года | +| `day_of_week` | INT CHECK(1–7) | День недели ISO | +| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | + +#### `student_group_calendar_assignments` — Назначения графиков группам +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `group_id` | BIGINT FK → student_groups (CASCADE) | Учебная группа | +| `academic_year_id` | BIGINT FK → academic_years (CASCADE) | Учебный год | +| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Назначенный график | + +#### `schedule_rules` — Правила расписания +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `subject_id` | BIGINT FK → subjects | Дисциплина | +| `semester_id` | BIGINT FK → semesters | Семестр | +| `lecture_academic_hours` | INT | Лимит академических часов лекций | +| `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ | +| `practice_academic_hours` | INT | Лимит академических часов практик | +| `lecture_start_week` | INT | Неделя семестра, с которой начинаются лекции | +| `laboratory_start_week` | INT | Неделя семестра, с которой начинаются лабораторные | +| `practice_start_week` | INT | Неделя семестра, с которой начинаются практики | +| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED` | +| `valid_from` | DATE | Начало действия версии правила | +| `valid_to` | DATE | Окончание действия версии правила | +| `version_group_id` | BIGINT | Группа версий одного правила | +| `change_reason` | TEXT | Причина изменения | + +#### `schedule_rule_groups` — Группы правила +| Колонка | Тип | Описание | +|---------|-----|----------| +| `schedule_rule_id` | BIGINT PK, FK → schedule_rules | Правило | +| `group_id` | BIGINT PK, FK → student_groups | Группа | + +#### `schedule_rule_slots` — Слоты правила +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило | +| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник | +| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | +| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот | | `teacher_id` | BIGINT FK → users | Преподаватель | -| `semester_type` | VARCHAR(255) | Весенний / Осенний | -| `period` | VARCHAR(255) | Учебный год | +| `classroom_id` | BIGINT FK → classrooms | Аудитория | +| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | +| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | +| `time_locked` | BOOLEAN | Время закреплено учебным отделом | +| `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом | +| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом | +| `locked_by` | BIGINT FK → users | Кто выполнил закрепление | +| `locked_at` | TIMESTAMP | Когда выполнено закрепление | +| `lock_comment` | TEXT | Комментарий к закреплению | + +#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота +| Колонка | Тип | Описание | +|---------|-----|----------| +| `schedule_rule_slot_id` | BIGINT PK, FK → schedule_rule_slots (CASCADE) | Слот правила | +| `subgroup_id` | BIGINT PK, FK → subgroups (CASCADE) | Подгруппа | + +Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `validate_schedule_rule_slot_subgroups` проверяет тип занятия, принадлежность подгруппы к группам правила и запрет на две подгруппы одной группы в одном слоте; для быстрых выборок есть индекс `idx_schedule_rule_slot_subgroups_subgroup`. + +#### `schedule_overrides` — Точечные изменения пар +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID изменения | +| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот правила | +| `lesson_date` | DATE | Дата конкретной пары | +| `action` | VARCHAR(20) | `MOVE`, `CANCEL`, `REPLACE` | +| `new_time_slot_id` | BIGINT FK → time_slots | Новый временной слот | +| `new_classroom_id` | BIGINT FK → classrooms | Новая аудитория | +| `new_teacher_id` | BIGINT FK → users | Новый преподаватель | +| `new_lesson_format` | VARCHAR(30) | Новый формат занятия | +| `comment` | TEXT | Причина изменения | +| `created_by` | BIGINT FK → users | Автор изменения | +| `created_at` | TIMESTAMP | Дата создания | + +Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для одной и той же пары. --- @@ -336,15 +678,17 @@ erDiagram 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline +> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных. + ### Текущие миграции | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов -Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend: +Для применения новой базовой схемы к уже существующим тенантам нужен полный сброс БД. Перезапуск backend без сброса не изменит уже применённую `V1__init.sql`. ```bash # Kubernetes diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a0ba3a2..f7727e9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -50,8 +50,8 @@ docker compose up -d # Пересоздаёт с нуля | Категория | Стиль | Пример | |-----------|-------|--------| -| Классы | PascalCase | `LessonsController`, `LessonResponse` | -| Методы и переменные | camelCase | `getAllLessons()`, `teacherId` | +| Классы | PascalCase | `ScheduleController`, `RenderedLessonDto` | +| Методы и переменные | camelCase | `buildScheduleForTeacher()`, `teacherId` | | Константы | UPPER_SNAKE_CASE | `ROLE_REDIRECTS` | | Пакеты | lowercase | `com.magistr.app.controller` | @@ -79,7 +79,7 @@ logger.error("Ошибка при сохранении: {}", e.getMessage(), e); #### Валидация -- Для сложных правил — отдельные классы-валидаторы (`DayAndWeekValidator`, `TypeAndFormatLessonValidator`) +- Для сложных правил — отдельные сервисы или приватные методы в контроллере с понятным сообщением об ошибке - Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()` #### Импорты @@ -257,7 +257,7 @@ com.magistr.app/ │ └── ConfigMapUpdater.java # Обновление K8s ConfigMap ├── controller/ # REST-контроллеры │ ├── AuthController.java -│ ├── LessonsController.java +│ ├── ScheduleController.java │ ├── ClassroomController.java │ ├── DatabaseController.java │ ├── UserController.java @@ -269,7 +269,5 @@ com.magistr.app/ ├── dto/ # Data Transfer Objects ├── model/ # JPA-сущности ├── repository/ # Spring Data JPA -└── utils/ # Валидаторы - ├── DayAndWeekValidator.java - └── TypeAndFormatLessonValidator.java +└── utils/ # Общие утилиты ``` diff --git a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md new file mode 100644 index 0000000..3987c1a --- /dev/null +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -0,0 +1,110 @@ +# Реализация динамического расписания + +Файл фиксирует текущий результат внедрения динамического расписания и календарных учебных графиков. + +## Статус + +Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД. + +Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы. + +## База данных + +`V1__init.sql` создаёт и заполняет: + +| Таблица | Назначение | +|---------|------------| +| `specialties` | Коды и названия специальностей | +| `specialty_profiles` | Профили обучения внутри специальности | +| `student_groups` | Группы со ссылками на специальность и профиль | +| `education_forms` | Общий справочник форм обучения для групп и календарных графиков | +| `academic_calendar_activity_types` | Коды активностей из Excel-графика | +| `academic_calendars` | Календарные графики профиля, формы обучения и учебного года | +| `academic_calendar_days` | Дневная сетка графика по курсу и дате | +| `student_group_calendar_assignments` | Назначения графиков группам по учебному году | +| `time_slots` | Настраиваемая сетка пар | +| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | +| `schedule_rule_groups` | Группы правила | +| `schedule_rule_slots` | Шаблонные слоты правила | +| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | + +Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. + +## Backend + +Добавлены сущности и репозитории для профилей, календарей, кодов активностей, дневной сетки и назначений группам. + +Ключевые сервисы: + +| Сервис | Назначение | +|--------|------------| +| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | +| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | + +`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; один лабораторный слот может включать разные подгруппы разных групп. Лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. + +## API + +Просмотр расписания: + +```http +GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 +GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 +``` + +Административные API: + +| API | Назначение | +|-----|------------| +| `/api/admin/time-slots` | CRUD временных слотов | +| `/api/admin/calendar/years` | CRUD учебных годов | +| `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | +| `/api/admin/calendar/semesters/{id}` | Обновление семестра | +| `/api/admin/calendar/activity-types` | CRUD кодов активностей | +| `/api/admin/academic-calendars` | CRUD календарных учебных графиков | +| `/api/admin/academic-calendars/{id}/grid` | Получение и сохранение дневной сетки | +| `/api/specialties/{id}/profiles` | CRUD профилей специальности | +| `/api/groups/{id}/calendar-assignments` | Назначения графиков группе | +| `/api/subgroups`, `/api/groups/{id}/subgroups` | Управление подгруппами для лабораторных | +| `/api/admin/schedule-rules` | CRUD правил расписания | + +## Frontend + +Админский экран `schedule` содержит: + +- конструктор правил динамического расписания; +- отдельные поля часов и недель начала для лекций, лабораторных и практик; +- выбор подгрупп только для лабораторного слота; +- управление временными слотами; +- учебные годы и семестры; +- CRUD календарных графиков; +- Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей. + +Экран `groups` создаёт и редактирует группы с профилем обучения, управляет подгруппами для лабораторных и назначает календарный график группе на учебный год. + +Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей. + +## Проверки + +Доступные локальные проверки: + +```bash +node --check frontend/admin/js/views/schedule.js +node --check frontend/admin/js/views/groups.js +node --check frontend/admin/js/views/departments-data.js +git diff --check +``` + +Полная проверка требует окружения с Maven и доступом к Docker: + +```bash +mvn -DskipTests compile +docker compose down -v +docker compose up -d --build +``` + +## Следующие этапы + +- Добавить интеграционные тесты генерации по кодам `Т`, `Э`, `К`, `*`, `=`. +- Добавить строгую backend-валидацию конфликтов преподавателей, аудиторий и групп при сохранении правил. +- Добавить связь пользователя-студента с учебной группой, чтобы кабинет студента не требовал ручного выбора группы. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 2a224ca..a8aa0f1 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -7,7 +7,7 @@ | **Фреймворк** | Нет (Vanilla JavaScript) | | **Модульная система** | ES6 Modules (`import`/`export`) | | **Стили** | CSS (модульный подход) | -| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) | +| **Шрифт** | Inter на странице входа, системный шрифт в кабинетах расписания | | **Веб-сервер** | Apache httpd:alpine | --- @@ -29,7 +29,7 @@ frontend/ │ │ ├── layout.css # Раскладка (sidebar, topbar, content) │ │ ├── components.css # Кнопки, таблицы, карточки, формы │ │ ├── modals.css # Модальные окна -│ │ ├── department.css # Стили кафедры +│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности │ │ └── departments-data.css # Стили создания кафедры/специальности │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация @@ -43,9 +43,13 @@ frontend/ │ │ ├── subjects.js # Управление дисциплинами │ │ ├── equipments.js # Управление оборудованием │ │ ├── edu-forms.js # Формы обучения -│ │ ├── schedule.js # Расписание занятий +│ │ ├── profiles.js # Профили обучения специальностей +│ │ ├── department-workspace.js # Кабинет кафедры в общей панели +│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах +│ │ ├── schedule.js # Конструктор правил расписания +│ │ ├── academic-calendar.js # Календарные учебные графики +│ │ ├── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр │ │ ├── database.js # Управление тенантами -│ │ ├── department.js # Кафедры │ │ └── departments-data.js # Создание кафедры/специальности │ ├── views/ # HTML-шаблоны представлений │ │ ├── users.html @@ -54,9 +58,13 @@ frontend/ │ │ ├── subjects.html │ │ ├── equipments.html │ │ ├── edu-forms.html +│ │ ├── profiles.html +│ │ ├── department-workspace.html +│ │ ├── schedule-view.html │ │ ├── schedule.html +│ │ ├── academic-calendar.html +│ │ ├── auditorium-workload.html │ │ ├── database.html -│ │ ├── department.html │ │ └── departments-data.html │ │ │ └── settings/ # ⚙️ Страница настроек (отдельный SPA) @@ -65,15 +73,22 @@ frontend/ │ │ ├── main.css # CSS-переменные, базовые стили │ │ └── layout.css # Sidebar, topbar, content │ ├── js/ -│ │ └── main.js # Навигация по вкладкам настроек +│ │ ├── main.js # Навигация по вкладкам настроек +│ │ └── views/ +│ │ └── time-slots.js # Настройка временных слотов │ └── views/ -│ └── general.html # Общие настройки (заглушка) +│ ├── general.html # Общие настройки (заглушка) +│ └── time-slots.html # Базовая, субботняя и ручные сетки времени │ ├── teacher/ # 👩‍🏫 Интерфейс преподавателя -│ └── index.html # Просмотр расписания +│ └── index.html # Недельный просмотр динамического расписания преподавателя +├── department/ # 🏛 Кабинет кафедры +│ └── index.html # Redirect в `/admin/#department-workspace` +├── edu-office/ # 🗓 Кабинет учебного отдела +│ └── index.html # Redirect в `/admin/#schedule-view` │ └── student/ # 🎓 Интерфейс студента - └── index.html # Просмотр расписания (read-only) + └── index.html # Недельный просмотр динамического расписания группы ``` --- @@ -88,6 +103,7 @@ frontend/ Пользователи Группы Расписание занятий +Календарный график ``` При клике на пункт меню `main.js`: @@ -96,29 +112,60 @@ frontend/ 3. Подключает соответствующий JS-модуль из `js/views/{tab}.js` 4. Обновляет заголовок страницы (`#page-title`) +`main.js` также фильтрует вкладки по роли: + +| Роль | Доступные вкладки | +|------|-------------------| +| `ADMIN` | Все вкладки | +| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории, оборудование | +| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний | +| `SCHEDULE_VIEWER` | Только просмотр расписаний | + +Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели. + +Пункты меню, недоступные роли, не отображаются в sidebar: `main.js` выставляет `hidden`, а `layout.css` явно скрывает такие элементы, чтобы базовый `display: flex` у `.nav-item` не возвращал их на экран. + ### Разделы админ-панели | Tab | Описание | API | |-----|----------|-----| | `users` | CRUD пользователей | `/api/users` | -| `groups` | CRUD групп | `/api/groups` | +| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `edu-forms` | Формы обучения | `/api/education-forms` | +| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Расписание | `/api/users/lessons` | +| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | +| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | +| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | +| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | +| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `database` | Тенанты | `/api/database` | -| `department` | Кафедры | `/api/departments` | -| `departments-data` | Создание кафедры/специальности | `/api/departments` | +| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | + +### Особенности админских вкладок + +- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. +- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. +- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. +- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. +- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. +- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. +- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. +- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. +- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. ### Страница настроек (`/admin/settings/`) Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью. -- Доступ: через dropdown «Настройки» в footer боковой панели админки +- Доступ: через dropdown «Настройки» в footer боковой панели админки для `ADMIN` и `EDUCATION_OFFICE` - Кнопка «Назад в панель» для возврата в `/admin/` - Текущие вкладки: - - **Общие настройки** — заглушка (в разработке) + - **Общие настройки** — заглушка (только `ADMIN`) + - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика. --- @@ -166,8 +213,13 @@ export const api = { 3. При успехе сохраняет в `localStorage`: - `token` — UUID-токен - `role` — роль пользователя + - `departmentId` — кафедра пользователя + - `userId` — ID пользователя для личного расписания преподавателя 4. Перенаправляет на соответствующий интерфейс: - `ADMIN` → `/admin/` + - `EDUCATION_OFFICE` → `/admin/#schedule-view` + - `DEPARTMENT` → `/admin/#department-workspace` + - `SCHEDULE_VIEWER` → `/admin/#schedule-view` - `TEACHER` → `/teacher/` - `STUDENT` → `/student/` @@ -188,6 +240,56 @@ export function isAuthenticatedAsAdmin() { --- +## Кабинеты расписания + +### Учебный отдел (`/admin/#schedule-view`) + +Учебный отдел работает в общей админ-панели с ограниченным набором вкладок: + +- расширенный поиск `/api/schedule/search` по дате в периоде, преподавателю, аудитории и кафедре; +- конструктор расписания через `/api/admin/schedule-rules`; +- создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API; +- матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`; +- аудитории, оборудование, календарный график и загруженность. + +### Кафедра (`/admin/#department-workspace`) + +Кабинет кафедры встроен в общую панель и работает в контексте `departmentId` текущего пользователя: + +- список дисциплин кафедры через `/api/department/subjects`; +- загрузка дисциплин из текстового списка в формате `код; название`; +- комментарии к дисциплинам; +- просмотр расписания кафедры через вкладку `schedule-view`; +- загруженность преподавателей кафедры. + +### Просмотр расписаний (`/admin/#schedule-view`) + +Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам. + +### Преподаватель (`/teacher/`) + +Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`. + +Основные элементы: +- навигация по неделям: предыдущая, текущая, следующая; +- выбор даты через `input[type="date"]`; +- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; +- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила. + +Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. + +### Студент (`/student/`) + +В текущей модели студент не связан с конкретной группой, поэтому страница использует выбор группы из `/api/groups`. + +Основные элементы: +- селект группы с сохранением выбора в `localStorage.studentGroupId`; +- недельная сетка по дням; +- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; +- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп. + +--- + ## CSS-архитектура ### Модульный подход @@ -198,8 +300,7 @@ export function isAuthenticatedAsAdmin() { 2. **`layout.css`** — Sidebar, topbar, content area, dropdown настроек, responsive 3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы, theme-toggle 4. **`modals.css`** — Модальные окна -5. **`department.css`** — Стили страницы кафедр -6. **`departments-data.css`** — Стили создания кафедры/специальности +5. **`departments-data.css`** — Стили создания кафедры/специальности ### Темизация diff --git a/docs/LOGGING.md b/docs/LOGGING.md index 41ad688..f26455a 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -94,7 +94,7 @@ MDC.remove("tenant.id"); | `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции | | `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s | | `DataInitializer` | INFO | Инициализация БД при старте | -| `LessonsController` | INFO, DEBUG, ERROR | CRUD-операции с занятиями, валидация | +| `ScheduleController` | INFO, ERROR | Запросы динамического расписания и ошибки генерации | ### Паттерн использования diff --git a/docs/README.md b/docs/README.md index 3775cc5..1cb77ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ ## Обзор -**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент) и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. +**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент), календарные учебные графики и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. - **Продакшн:** [https://magistr.zuev.company](https://magistr.zuev.company) - **Локальная разработка:** [http://localhost:80](http://localhost:80) @@ -90,6 +90,8 @@ magistr/ │ ├── admin/ # Админ-панель (деканат) │ │ ├── js/views/ # Модули представлений │ │ └── css/ # Стили +│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA +│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA │ ├── teacher/ # Интерфейс преподавателя │ └── student/ # Интерфейс студента ├── docs/ # 📖 Документация (вы здесь) @@ -106,6 +108,7 @@ magistr/ |----------|-----------| | [Архитектура](ARCHITECTURE.md) | Общая архитектура, мультитенантность, взаимодействие компонентов | | [Бизнес-логика](BUSINESS_LOGIC.md) | Ролевая модель, правила расписания, управление ресурсами | +| [Календарный учебный график](ACADEMIC_CALENDAR.md) | Модель графиков по специальностям, профилям и группам | | [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции | | [REST API](API.md) | Все эндпоинты с примерами запросов и ответов | | [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг | diff --git a/docs/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md new file mode 100644 index 0000000..c064c40 --- /dev/null +++ b/docs/SCHEDULE_PROPOSAL.md @@ -0,0 +1,50 @@ +# Концепция динамической генерации расписания + +Документ фиксирует актуальную концепцию после внедрения календарных учебных графиков. Подробная продуктовая модель описана в [`docs/ACADEMIC_CALENDAR.md`](docs/ACADEMIC_CALENDAR.md), REST API — в [`docs/API.md`](docs/API.md), схема БД — в [`docs/DATABASE.md`](docs/DATABASE.md). + +## Базовая идея + +Фактические занятия не хранятся отдельными строками на каждую дату. Система хранит: + +- календарный учебный график по специальности, профилю, форме обучения и учебному году; +- правила расписания дисциплин; +- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональные подгруппы для лабораторных; +- назначение конкретного календарного графика учебной группе на учебный год. + +При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются. + +## Слои модели + +### Календарный слой + +- `academic_years` и `semesters` задают границы учебного года и семестров. +- `specialties` и `specialty_profiles` определяют образовательную программу группы. +- `academic_calendars` хранит график профиля и формы обучения. +- `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`. +- `academic_calendar_days` хранит дневную сетку по курсу и дате. +- `student_group_calendar_assignments` выбирает график для группы на учебный год. + +### Слой правил расписания + +- `time_slots` задаёт сетку пар для тенанта. +- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик. +- `schedule_rule_groups` связывает правило с одной или несколькими группами. +- `schedule_rule_slots` хранит шаблонные занятия правила, а `schedule_rule_slot_subgroups` связывает лабораторный слот с одной или несколькими подгруппами. + +## Алгоритм генерации + +1. Для даты определяется семестр. +2. Для группы находится назначенный календарный учебный график. +3. По году начала обучения вычисляется текущий курс группы. +4. По курсу и дате находится код активности. +5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. +6. Для разрешённого дня выбираются активные правила и слоты. +7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе. + +Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к нескольким подгруппам разных групп, он выводится как совместное занятие этих групп, а часы списываются отдельно по каждой подгруппе. + +Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. + +## Административный сценарий + +Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу. diff --git a/docs/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md new file mode 100644 index 0000000..86221ef --- /dev/null +++ b/docs/SCHEDULE_TASKS.md @@ -0,0 +1,34 @@ +# Задачи: динамическое расписание и календарный учебный график + +Актуальная декомпозиция относится к модели, где календарный учебный график хранится дневной сеткой и назначается группе на учебный год. + +## Выполнено + +- [x] Переписана базовая схема `V1__init.sql` под полный сброс БД. +- [x] Добавлены специальности, профили обучения и связь группы с профилем. +- [x] Добавлены формы обучения календарного графика. +- [x] Добавлен справочник кодов активностей Excel-графика. +- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам. +- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. +- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. +- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. +- [x] Добавлены подгруппы для лабораторных слотов, включая выбор разных подгрупп разных групп в одном поточном лабораторном слоте; лекции и практики не делятся на подгруппы. +- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. +- [x] Обновлены админские экраны специальностей, групп и расписания. +- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. + +## Оставшиеся задачи + +- [ ] Добавить интеграционные тесты генерации расписания для кодов `Т`, `Э`, `К`, `*`, `=`. +- [ ] Добавить проверку конфликтов преподавателей, аудиторий и групп при сохранении правил. +- [ ] Добавить модель связи пользователя-студента с учебной группой. +- [ ] Добавить импорт календарного графика из Excel, если он понадобится после ручного редактора. + +## Проверочный сценарий + +1. Полностью сбросить локальную БД. +2. Запустить приложение и применить `V1__init.sql`. +3. Создать профиль специальности. +4. Создать календарный график и заполнить дневную сетку. +5. Назначить график группе. +6. Проверить `GET /api/schedule` на дне теории и на днях, где пары запрещены. diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index c43f42d..9722ab7 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -1,4 +1,4 @@ -/* ===== Auditorium Workload Specific Styles ===== */ +/* ===== Стили загруженности аудиторий ===== */ .workload-grid-container { width: 100%; @@ -9,6 +9,50 @@ position: relative; } +.workload-filter-row { + flex-wrap: wrap; +} + +.workload-display-control { + flex: 1 1 260px; + min-width: 240px; +} + +.workload-target-control { + flex: 0 1 220px; + min-width: 200px; +} + +.workload-display-select { + width: 100%; + min-height: 38px; + padding: 0.45rem 2.25rem 0.45rem 1rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font: inherit; + font-size: 0.85rem; + outline: none; + transition: background var(--transition), border-color var(--transition), box-shadow var(--transition); +} + +.workload-display-select:hover { + background: var(--bg-hover); +} + +.workload-display-select:focus { + background: var(--bg-input-focus); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.workload-overview-filter[hidden], +#workload-overview-block[hidden], +#room-workload-block[hidden] { + display: none !important; +} + .workload-table { width: 100%; border-collapse: collapse; @@ -22,7 +66,7 @@ vertical-align: top; position: relative; min-width: 150px; - height: 80px; + height: 86px; } .workload-table th { @@ -37,32 +81,89 @@ box-shadow: 0 1px 0 var(--bg-card-border); } -.workload-table .time-cell { - background: var(--bg-input); +.workload-table th strong, +.workload-table th span, +.workload-table th small { + display: block; +} + +.workload-table th strong { + color: var(--text-primary); + font-size: 0.82rem; + line-height: 1.1; +} + +.workload-table th span { + margin-top: 0.2rem; color: var(--text-secondary); - font-weight: 500; - text-align: center; + font-size: 0.76rem; + line-height: 1.1; +} + +.workload-table th small { + width: fit-content; + margin: 0.35rem auto 0; + padding: 0.1rem 0.35rem; + border-radius: 999px; + background: rgba(139, 92, 246, 0.16); + color: var(--accent-hover); + font-size: 0.68rem; + font-weight: 700; + line-height: 1.2; +} + +.workload-table .time-cell, +.workload-table .axis-cell { + background: var(--bg-input); + color: var(--text-primary); + font-weight: 600; + text-align: left; vertical-align: middle; - width: 120px; - min-width: 120px; + width: 140px; + min-width: 140px; position: sticky; left: 0; z-index: 5; box-shadow: 1px 0 0 var(--bg-card-border); } +.workload-table .axis-cell strong, +.workload-table .axis-cell span, +.workload-table .axis-cell small { + display: block; +} + +.workload-table .axis-cell strong { + color: var(--text-primary); + font-size: 0.9rem; + line-height: 1.15; +} + +.workload-table .axis-cell span, +.workload-table .axis-cell small { + margin-top: 0.2rem; + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.15; +} + +.workload-table .axis-cell.room-unavailable { + color: var(--error); + background: color-mix(in srgb, var(--error) 8%, var(--bg-input)); +} + .workload-table .top-left-cell { position: sticky; top: 0; left: 0; z-index: 20; background: var(--bg-input); - min-width: 120px; - width: 120px; + min-width: 140px; + width: 140px; box-shadow: 1px 1px 0 var(--bg-card-border); } -/* Diagonal line using SVG or linear-gradient */ +/* Диагональная линия через linear-gradient */ .workload-table .top-left-cell::before { content: ''; position: absolute; @@ -95,7 +196,37 @@ color: var(--text-secondary); } -/* Lesson Cards inside grid cells */ +.workload-summary { + margin: -0.75rem 0 1rem; + color: var(--text-secondary); + font-size: 0.86rem; +} + +.workload-filter-empty { + padding: 0.75rem; + color: var(--text-secondary); + font-size: 0.86rem; +} + +.workload-busy-cell { + background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary)); +} + +.workload-free-cell { + background: color-mix(in srgb, var(--success) 4%, var(--bg-primary)); +} + +.free-slot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 58px; + color: var(--text-secondary); + font-size: 0.78rem; +} + +/* Карточки занятий внутри ячеек сетки */ .lesson-card { background: var(--bg-card); border: 1px solid var(--bg-card-border); @@ -126,18 +257,610 @@ } .lesson-group { - font-weight: 700; /* Bolder specific for groups request mockup */ + font-weight: 700; /* Акцент на названии группы */ color: var(--text-primary); margin-bottom: 0.1rem; font-size: 0.75rem; } +.lesson-subgroup { + color: var(--accent-hover); + font-size: 0.72rem; + font-weight: 600; + margin-bottom: 0.1rem; + line-height: 1.2; +} + .lesson-teacher { color: var(--text-secondary); font-size: 0.75rem; } -/* Custom scrollbar adjustments for grid container */ +.lesson-meta { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.2; +} + +.room-workload-block { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.room-workload-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin: -0.75rem 0 0.25rem; + color: var(--text-secondary); + font-size: 0.86rem; +} + +.room-summary-main { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.45rem; + margin-right: 0.25rem; +} + +.room-summary-main strong { + color: var(--text-primary); + font-size: 0.98rem; +} + +.room-summary-chip, +.room-summary-warning { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0.25rem 0.55rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + line-height: 1.15; +} + +.room-summary-warning { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 10%, var(--bg-input)); +} + +.room-workload-tables { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.room-week-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.room-week-heading h3 { + margin: 0; + color: var(--text-primary); + font-size: 1rem; + line-height: 1.2; +} + +.room-week-heading span { + color: var(--text-secondary); + font-size: 0.82rem; +} + +.room-week-panel { + display: flex; + flex-direction: column; + min-height: calc(100vh - 19rem); +} + +.room-grid-container { + flex: 1; + height: calc(100vh - 24rem); + min-height: 420px; + max-height: none; +} + +.room-week-table { + min-width: 1040px; +} + +.room-week-table th, +.room-week-table td { + min-width: 126px; + height: 172px; +} + +.room-week-table .room-time-heading, +.room-week-table .room-time-cell { + position: sticky; + left: 0; + min-width: 128px; + width: 128px; + background: var(--bg-input); + box-shadow: 1px 0 0 var(--bg-card-border); +} + +.room-week-table .room-time-heading { + z-index: 20; +} + +.room-week-table .room-time-cell { + z-index: 5; + color: var(--text-primary); + font-weight: 600; + vertical-align: middle; +} + +.room-week-table .room-time-cell strong, +.room-week-table .room-time-cell span, +.room-week-table .room-day-cell strong, +.room-week-table .room-day-cell span, +.room-week-table .room-day-cell small { + display: block; +} + +.room-week-table .room-time-cell strong { + font-size: 0.86rem; + line-height: 1.15; +} + +.room-week-table .room-time-cell span { + margin-top: 0.2rem; + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.15; +} + +.room-week-table .room-day-cell strong { + color: var(--text-primary); + font-size: 0.84rem; + line-height: 1.1; +} + +.room-week-table .room-day-cell > span { + margin-top: 0.15rem; + color: var(--text-secondary); + font-size: 0.76rem; + line-height: 1.1; +} + +.room-week-table .room-day-cell small { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.68rem; + line-height: 1.1; +} + +.room-day-parity-dates { + display: grid; + gap: 0.2rem; + margin-top: 0.45rem; +} + +.room-day-parity-dates span { + padding: 0.16rem 0.35rem; + border-radius: 999px; + background: var(--bg-input); + color: var(--text-secondary); + font-size: 0.68rem; + font-weight: 700; + line-height: 1.1; + white-space: nowrap; +} + +.room-combined-cell { + height: 1px; + padding: 0 !important; + background: var(--bg-primary); +} + +.room-single-cell { + padding: 0 !important; +} + +.room-week-unified { + min-height: 172px; + height: 100%; + padding: 0.45rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.25rem; +} + +.room-week-unified-busy { + justify-content: flex-start; + background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary)); +} + +.room-week-unified-busy > .lesson-card { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + margin-bottom: 0; +} + +.room-week-unified-free { + background: color-mix(in srgb, var(--success) 4%, var(--bg-primary)); +} + +.room-week-unified-missing, +.room-week-unified-empty { + background: color-mix(in srgb, var(--warning) 5%, var(--bg-primary)); +} + +.room-week-half { + position: relative; + min-height: 86px; + padding: 1.4rem 0.45rem 0.45rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.25rem; +} + +.room-week-half + .room-week-half { + border-top: 1px solid var(--bg-card-border); +} + +.room-week-half-busy { + justify-content: flex-start; + background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary)); +} + +.room-week-half-free { + background: color-mix(in srgb, var(--success) 4%, var(--bg-primary)); +} + +.room-week-half-missing, +.room-week-half-empty { + background: color-mix(in srgb, var(--warning) 5%, var(--bg-primary)); +} + +.room-half-marker { + position: absolute; + top: 0.35rem; + left: 0.45rem; + display: inline-flex; + align-items: center; + max-width: calc(100% - 0.9rem); + padding: 0.1rem 0.35rem; + border-radius: 999px; + background: var(--bg-input); + color: var(--text-secondary); + font-size: 0.64rem; + font-weight: 700; + line-height: 1.1; + white-space: nowrap; +} + +.room-week-half-odd .room-half-marker { + color: var(--accent-hover); +} + +.room-week-half-even .room-half-marker { + color: var(--success); +} + +.slot-unavailable { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 58px; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.room-workload-state { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.schedule-view-shell { + min-height: calc(100vh - 8.5rem); + display: grid; + grid-template-rows: auto auto minmax(420px, 1fr); + gap: 1rem; +} + +.schedule-view-control-card, +.schedule-view-results-card, +.schedule-view-table-card { + animation-duration: 0.28s; +} + +.schedule-view-control-header, +.schedule-view-results-header, +.schedule-view-table-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.schedule-view-control-header h2, +.schedule-view-results-header h2, +.schedule-view-table-header h2 { + margin-bottom: 0.3rem; +} + +.schedule-view-control-grid, +.schedule-view-extra-grid { + display: grid; + gap: 1rem; + align-items: end; +} + +.schedule-view-control-grid { + grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr) minmax(210px, 0.9fr); + margin-top: 1rem; +} + +.schedule-view-extra-grid { + grid-template-columns: repeat(3, minmax(180px, 1fr)); + margin-top: 0.9rem; +} + +.schedule-view-period-control { + min-width: 0; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 500; +} + +.schedule-view-period-buttons, +.schedule-view-result-actions, +.schedule-view-day-tabs { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.schedule-view-shell .btn-secondary { + min-height: 42px; + padding: 0.65rem 0.9rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition); +} + +.schedule-view-shell .btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--accent-hover); + transform: translateY(-1px); +} + +.schedule-view-shell .btn-secondary:disabled { + cursor: default; + opacity: 0.45; +} + +.schedule-view-extra-filters { + margin-top: 0.9rem; + border-top: 1px solid var(--bg-card-border); + padding-top: 0.85rem; +} + +.schedule-view-extra-filters summary { + width: fit-content; + color: var(--text-secondary); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; +} + +.schedule-view-result-actions { + min-height: 42px; + justify-content: flex-end; + color: var(--text-secondary); + font-size: 0.88rem; + text-align: right; +} + +.schedule-view-result-tabs { + display: flex; + gap: 0.55rem; + margin-top: 1rem; + padding-bottom: 0.1rem; + overflow-x: auto; +} + +.schedule-view-result-tab { + flex: 0 0 auto; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 0.18rem; + min-width: 150px; + max-width: 240px; + padding: 0.65rem 0.85rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), transform var(--transition); +} + +.schedule-view-result-tab span, +.schedule-view-result-tab small { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-view-result-tab small { + color: var(--text-secondary); + font-size: 0.72rem; +} + +.schedule-view-result-tab:hover, +.schedule-view-result-tab.active { + background: color-mix(in srgb, var(--accent) 12%, var(--bg-input)); + border-color: var(--accent); + transform: translateY(-1px); +} + +.schedule-view-empty-result { + display: inline-flex; + align-items: center; + min-height: 38px; + color: var(--text-secondary); + font-size: 0.88rem; +} + +.schedule-view-table-card { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.schedule-view-table-header { + flex: 0 0 auto; + margin-bottom: 1rem; +} + +.schedule-view-table-stage { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.schedule-view-week-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.schedule-view-grid-container { + flex: 1 1 auto; + min-height: 0; + height: 100%; + max-height: none; +} + +.schedule-view-matrix-table { + min-width: 980px; +} + +.schedule-view-matrix-table th, +.schedule-view-matrix-table td { + min-width: 150px; + height: 172px; +} + +.schedule-view-lesson-card .lesson-meta + .lesson-meta { + margin-top: 0.1rem; +} + +.schedule-view-state { + min-height: 100%; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.schedule-view-day-tabs { + flex-wrap: wrap; + justify-content: flex-end; +} + +.schedule-view-day-tab { + min-width: 42px; + min-height: 34px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + font: inherit; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.schedule-view-day-tab.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 15%, var(--bg-input)); + color: var(--text-primary); +} + +.schedule-view-mobile-day { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.schedule-view-mobile-day-heading, +.schedule-view-mobile-slot { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.schedule-view-mobile-day-heading { + padding: 0.8rem 0.9rem; +} + +.schedule-view-mobile-day-heading strong, +.schedule-view-mobile-day-heading span, +.schedule-view-mobile-slot-time strong, +.schedule-view-mobile-slot-time span { + display: block; +} + +.schedule-view-mobile-day-heading span, +.schedule-view-mobile-slot-time span { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.schedule-view-mobile-slot { + overflow: hidden; +} + +.schedule-view-mobile-slot-time { + padding: 0.7rem 0.85rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.schedule-view-mobile-slot-content .room-week-unified { + min-height: 124px; +} + +/* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; height: 8px; @@ -152,3 +875,78 @@ [data-theme="light"] .workload-grid-container::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); } + +@media (max-width: 1180px) { + .schedule-view-control-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .workload-target-control, + .workload-display-control { + min-width: 100%; + } + + .room-week-heading { + align-items: flex-start; + } + + .room-week-panel { + min-height: auto; + } + + .room-grid-container { + height: 520px; + } + + .schedule-view-shell { + min-height: auto; + grid-template-rows: auto; + } + + .schedule-view-control-header, + .schedule-view-results-header, + .schedule-view-table-header { + display: grid; + grid-template-columns: 1fr; + } + + .schedule-view-control-grid, + .schedule-view-extra-grid { + grid-template-columns: 1fr; + } + + .schedule-view-period-buttons, + .schedule-view-result-actions, + .schedule-view-day-tabs { + width: 100%; + } + + .schedule-view-period-buttons .btn-secondary, + .schedule-view-result-actions .btn-secondary { + flex: 1; + } + + .schedule-view-result-actions { + justify-content: stretch; + text-align: center; + } + + .schedule-view-result-actions span { + flex: 2; + align-self: center; + } + + .schedule-view-result-tab { + min-width: 72%; + } + + .schedule-view-table-card { + min-height: 520px; + } + + .schedule-view-day-tabs { + justify-content: flex-start; + } +} diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 3dbb57e..623cfeb 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -104,6 +104,746 @@ opacity: 0.5; } +.form-group select, +.filter-row select, +.schedule-view-filter-grid select { + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: inherit; + font-size: 0.95rem; + outline: none; + transition: all var(--transition); +} + +.form-group select:focus, +.filter-row select:focus, +.schedule-view-filter-grid select:focus { + background: var(--bg-input-focus); + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-glow); +} + +.stack-form { + display: grid; + gap: 1rem; +} + +.stack-form textarea { + width: 100%; + min-height: 150px; + resize: vertical; + padding: 0.75rem 1rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: inherit; + font-size: 0.95rem; + outline: none; + transition: all var(--transition); +} + +.stack-form textarea:focus { + background: var(--bg-input-focus); + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-glow); +} + +.schedule-view-filter-grid { + display: grid; + grid-template-columns: repeat(4, minmax(170px, 1fr)); + gap: 1rem; + align-items: end; +} + +.schedule-view-metrics, +.department-workspace-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.metric-grid { + display: grid; + gap: 0.75rem; +} + +.metric-card, +.department-comment-item { + padding: 0.85rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.metric-card strong, +.department-comment-item strong { + display: block; + margin-bottom: 0.35rem; + color: var(--text-primary); +} + +.metric-card span, +.metric-card small, +.department-comment-item span { + display: block; + color: var(--text-secondary); +} + +.department-workload-list { + margin-top: 1rem; +} + +.department-comment-list { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.department-comment-item p { + margin-top: 0.5rem; + color: var(--text-primary); +} + +.schedule-section-label { + display: block; + font-size: 0.78rem; + font-weight: 500; + color: var(--text-secondary); + margin: 1rem 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.schedule-group-select { + max-width: 520px; +} + +.schedule-group-select .select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-group-checkboxes { + max-height: 220px; +} + +.schedule-hours-panel { + margin-top: 1rem; +} + +.schedule-hours-grid { + display: grid; + grid-template-columns: repeat(3, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.schedule-hours-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + align-items: end; + padding: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.schedule-hours-title { + grid-column: 1 / -1; + color: var(--text-primary); + font-weight: 700; +} + +.schedule-slots-panel { + margin-top: 1rem; +} + +.schedule-slots-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.schedule-slot-row { + display: grid; + grid-template-columns: repeat(7, minmax(125px, 1fr)) auto; + gap: 0.75rem; + align-items: end; + padding: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.schedule-slot-row.has-subgroup { + grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; +} + +.slot-subgroup-multi .select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slot-subgroup-multi .dropdown-menu { + min-width: 260px; +} + +.schedule-slot-summary, +.schedule-semester-line { + margin-bottom: 0.35rem; +} + +.schedule-semester-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.schedule-inline-table { + margin-top: 1rem; +} + +.calendar-fill-panel { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)) auto; + gap: 0.75rem; + align-items: end; + margin: 1rem 0; + padding: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.academic-calendar-grid { + display: grid; + gap: 0.65rem; + margin-top: 1rem; +} + +.calendar-course-block { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-input) 82%, var(--bg-card)); + overflow: hidden; +} + +.calendar-course-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 44px; + padding: 0.6rem 0.85rem; + cursor: pointer; + list-style: none; + user-select: none; + transition: background var(--transition); +} + +.calendar-course-header::-webkit-details-marker { + display: none; +} + +.calendar-course-header:hover { + background: var(--bg-hover); +} + +.calendar-course-block[open] .calendar-course-header { + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-course-title { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-size: 0.96rem; + font-weight: 800; +} + +.calendar-course-chevron { + width: 9px; + height: 9px; + border-right: 2px solid var(--text-secondary); + border-bottom: 2px solid var(--text-secondary); + transform: rotate(-45deg); + transition: transform var(--transition), border-color var(--transition); +} + +.calendar-course-block[open] .calendar-course-chevron { + transform: rotate(45deg); + border-color: var(--accent-hover); +} + +.calendar-course-meta { + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 700; +} + +.calendar-semesters-grid { + --calendar-semester-block-height: 194px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-items: stretch; + gap: 0.65rem; + padding: 0.65rem; +} + +.calendar-semester-block { + display: flex; + flex-direction: column; + height: var(--calendar-semester-block-height); + min-height: 100%; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-card); + overflow: hidden; +} + +.calendar-semester-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 32px; + padding: 0.38rem 0.55rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-semester-header h4 { + margin: 0; + font-size: 0.86rem; +} + +.calendar-semester-header span { + color: var(--text-secondary); + font-size: 0.72rem; + white-space: nowrap; +} + +.calendar-semester-table-wrap { + flex: 1; + display: flex; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; + background: var(--bg-primary); + scrollbar-width: none; +} + +.calendar-semester-table-wrap::-webkit-scrollbar { + width: 0; + height: 0; +} + +.calendar-semester-table { + width: max-content; + min-width: 100%; + height: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 0.62rem; + user-select: none; +} + +.calendar-semester-table th, +.calendar-semester-table td { + border: 1px solid var(--bg-card-border); +} + +.calendar-semester-table tbody tr { + height: 19px; +} + +.calendar-semester-table thead th { + position: sticky; + top: 0; + z-index: 1; + height: 18px; + min-width: 18px; + padding: 0 0.12rem; + background: var(--bg-input); + color: var(--text-secondary); + font-weight: 700; + text-align: center; +} + +.calendar-semester-table .calendar-day-head { + position: sticky; + left: 0; + z-index: 2; + width: 30px; + min-width: 30px; + background: var(--bg-input); + color: var(--text-secondary); + font-weight: 700; + text-align: center; +} + +.calendar-day { + position: relative; + width: 18px; + min-width: 18px; + height: 19px; + min-height: 19px; + padding: 0; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 12%, var(--bg-card)); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border)); + cursor: pointer; + outline: none; + box-shadow: none; + text-align: center; + transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition); +} + +.calendar-day:hover, +.calendar-day:focus-visible { + background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card)); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 50%, var(--bg-card-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 30%, transparent); + z-index: 3; +} + +.calendar-day.is-selected { + background: color-mix(in srgb, var(--activity-color, var(--accent)) 34%, var(--bg-card)); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 64%, var(--bg-card-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 44%, transparent); + filter: saturate(1.08); + z-index: 4; +} + +.calendar-day-code { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 2px; + color: var(--text-primary); + font-size: 0.6rem; + line-height: 1; + font-weight: 800; + text-shadow: 0 1px 0 color-mix(in srgb, var(--bg-primary) 70%, transparent); +} + +.calendar-day-time-marker { + position: absolute; + right: 0; + bottom: 0; + padding: 0 1px; + border-radius: 3px; + background: var(--accent); + color: #fff; + font-size: 0.4rem; + font-weight: 800; + line-height: 1.15; +} + +.calendar-day-activity { + display: none !important; +} + +.calendar-day-tooltip { + position: fixed; + z-index: 1200; + width: min(280px, calc(100vw - 24px)); + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 26%, var(--bg-card-border)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); + color: var(--text-primary); + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.36), 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + pointer-events: none; + opacity: 0; + visibility: hidden; + transform: translateY(4px) scale(0.98); + transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease; +} + +.calendar-day-tooltip.visible { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); +} + +.calendar-day-tooltip-head { + display: flex; + align-items: center; + gap: 0.65rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-day-tooltip-head strong, +.calendar-day-tooltip-body strong { + display: block; + color: var(--text-primary); + font-size: 0.85rem; + line-height: 1.25; +} + +.calendar-day-tooltip-head span, +.calendar-day-tooltip-body span { + display: block; + color: var(--text-secondary); + font-size: 0.68rem; + line-height: 1.2; +} + +.calendar-day-tooltip-code { + width: 34px; + min-width: 34px; + height: 34px; + display: inline-grid; + place-items: center; + border-radius: 8px; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent); + color: var(--text-primary); + font-size: 0.86rem; + font-weight: 800; + line-height: 1; + padding-top: 2px; + text-align: center; +} + +.calendar-day-tooltip-body { + display: grid; + gap: 0.45rem; + padding-top: 0.65rem; +} + +.calendar-empty-day { + width: 18px; + min-width: 18px; + height: 19px; + background: color-mix(in srgb, var(--bg-input) 65%, transparent); +} + +.calendar-empty-semester { + flex: 1; + display: flex; + align-items: center; + padding: 0.75rem; + color: var(--text-secondary); + font-size: 0.82rem; +} + +.calendar-totals { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.85rem; +} + +.calendar-total-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-height: 28px; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--chip-color) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--chip-color) 52%, transparent); + color: var(--text-primary); + font-size: 0.8rem; + font-weight: 600; +} + +.calendar-total-chip.is-selected { + background: color-mix(in srgb, var(--chip-color) 30%, var(--bg-card)); + border-color: color-mix(in srgb, var(--chip-color) 72%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--chip-color) 22%, transparent); +} + +.calendar-total-selected { + padding: 0.08rem 0.32rem; + border-radius: 999px; + background: color-mix(in srgb, var(--chip-color) 18%, var(--bg-primary)); + color: var(--text-secondary); + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; +} + +#calendar-day-dialog.modal-overlay { + background: rgba(0, 0, 0, 0.42); + backdrop-filter: blur(3px); +} + +#calendar-day-dialog .calendar-day-dialog-card { + width: min(470px, 100%); + max-height: none; + padding: 1rem 1rem 0.85rem; + overflow: visible; + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); + border-radius: 18px; +} + +.calendar-day-dialog-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; + margin: 0.45rem 0; +} + +.calendar-day-dialog-grid div { + padding: 0.58rem 0.65rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.calendar-day-dialog-grid span { + display: block; + color: var(--text-secondary); + font-size: 0.78rem; + margin-bottom: 0.25rem; +} + +.calendar-day-dialog-grid strong { + font-size: 0.9rem; +} + +.calendar-day-dialog-activity { + display: flex; + gap: 0.7rem; + align-items: center; + margin: 0.55rem 0 0.65rem; + padding: 0.6rem; + border-radius: var(--radius-sm); + background: var(--bg-input); + border: 1px solid var(--bg-card-border); +} + +.calendar-day-dialog-code { + width: 40px; + min-width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 20%, transparent); + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 55%, transparent); + font-weight: 800; +} + +.calendar-day-dialog-activity p { + margin: 0.2rem 0 0; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.calendar-day-dialog-time-grid { + margin-top: 0.6rem; + padding-top: 0.6rem; + border-top: 1px solid var(--bg-card-border); +} + +.calendar-day-dialog-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.6rem; + margin-bottom: 0; +} + +#calendar-day-dialog-time-alert { + margin: 0; + padding: 0; +} + +#calendar-day-dialog-time-alert.error, +#calendar-day-dialog-time-alert.success { + margin-top: 0.55rem; + padding: 0.5rem 0.75rem; +} + +.profiles-context { + color: var(--text-secondary); + font-weight: 600; + margin: -0.25rem 0 0.75rem; +} + +.muted { + color: var(--text-secondary); +} + +@media (max-width: 1180px) { + .schedule-view-filter-grid, + .schedule-view-metrics, + .department-workspace-grid { + grid-template-columns: 1fr; + } + + .schedule-hours-grid { + grid-template-columns: 1fr; + } + + .schedule-slot-row, + .schedule-slot-row.has-subgroup { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } + + .calendar-fill-panel { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } + + .calendar-semesters-grid { + grid-template-columns: 1fr; + grid-auto-rows: auto; + } + + .calendar-semester-block { + height: auto; + min-height: 0; + } + + .calendar-semester-header { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } +} + +@media (max-width: 640px) { + .schedule-hours-row { + grid-template-columns: 1fr; + } + + .schedule-slot-row, + .schedule-slot-row.has-subgroup { + grid-template-columns: 1fr; + } + + .calendar-fill-panel { + grid-template-columns: 1fr; + } + + .calendar-day-dialog-grid { + grid-template-columns: 1fr; + } + + #calendar-day-dialog .calendar-day-dialog-card { + max-height: calc(100vh - 2rem); + overflow-y: auto; + } +} + /* Hide Number Arrows */ input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { @@ -124,6 +864,11 @@ input[type="number"] { font-family: inherit; } +.select-layer-active { + position: relative; + z-index: 300; +} + .custom-select-trigger { display: flex; align-items: center; @@ -1032,4 +1777,4 @@ tbody tr:hover { position: fixed; top: 1.25rem; right: 1.25rem; -} \ No newline at end of file +} diff --git a/frontend/admin/css/department.css b/frontend/admin/css/department.css deleted file mode 100644 index 88b83c9..0000000 --- a/frontend/admin/css/department.css +++ /dev/null @@ -1,344 +0,0 @@ -/* ===== Оверлей для модалок создания записей (к/ф) ===== */ -.cs-overlay { - display: none; - position: fixed; - inset: 0; - z-index: 1000; - background: rgba(0, 0, 0, 0.55); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); -} - -.cs-overlay.open { - display: block; -} - -.cs-overlay-scroll { - width: 100%; - height: 100%; - overflow-y: auto; - padding: 2rem 1rem; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -/* Общие стили для обеих модалок */ -.cs-modal { - width: 100%; - max-width: 1100px; - position: relative; - animation: csModalAppear 0.25s ease-out; -} - -/* Модалка 1 (форма) всегда поверх модалки 2 (таблицы), - чтобы выпадающие списки не уходили под таблицу */ -.cs-modal-form { - z-index: 2; -} - -.cs-modal-table { - z-index: 1; -} - -@keyframes csModalAppear { - from { opacity: 0; transform: translateY(-12px); } - to { opacity: 1; transform: translateY(0); } -} - -.cs-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.cs-modal-header h2 { - margin: 0; -} - -/* Кнопка закрытия */ -.btn-close-panel { - background: none; - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - font-size: 1.3rem; - line-height: 1; - padding: 0.25rem 0.6rem; - color: var(--text-secondary); - cursor: pointer; - transition: color var(--transition), background var(--transition), border-color var(--transition); -} - -.btn-close-panel:hover { - color: var(--error); - background: rgba(239, 68, 68, 0.1); - border-color: var(--error); -} - -.wrap{ - max-width: 900px; - margin: 0 auto; - background: var(--bg-card); - border: 1px solid var(--bg-card-border); - border-radius: 12px; - overflow: hidden; - box-shadow: 0 6px 20px rgba(0,0,0,.06); -} - -.header{ - padding: 14px 16px; - border-bottom: 1px solid var(--bg-card-border); - font-weight: 700; - color: var(--text-primary); -} - -details.table-item{ - border-top: 1px solid var(--bg-card-border); -} -details.table-item:first-of-type{ border-top:none; } - -summary{ - list-style: none; - cursor: pointer; - user-select: none; - padding: 12px 16px; - display: flex; - align-items: center; - gap: 10px; -} -summary::-webkit-details-marker{ display:none; } - -.chev{ - width: 28px; - height: 28px; - border: 1px solid var(--bg-card-border); - border-radius: 10px; - display: grid; - place-items: center; - flex: 0 0 auto; - - color: var(--text-secondary); - background: var(--bg-input); - - transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease; -} - -.chev-icon{ - width: 16px; - height: 16px; - display: block; -} - -summary:hover .chev{ - background: var(--bg-hover); - border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border)); - color: var(--text-primary); -} - -details[open] .chev{ - transform: rotate(180deg); - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border)); - background: color-mix(in srgb, var(--accent) 10%, var(--bg-input)); -} - -.meta{ color: var(--text-secondary); font-size: 12px; } - -.content{ padding: 0 16px 16px 16px; } - -.wrap table{ - width: 100%; - border-collapse: collapse; - border: 1px solid var(--bg-card-border); - border-radius: 10px; - overflow: hidden; - background: var(--bg-card); -} - -.wrap thead th{ - text-align: left; - font-size: 13px; - color: var(--text-secondary); - background: var(--bg-input); - border-bottom: 1px solid var(--bg-card-border); - padding: 10px 12px; -} - -.wrap tbody td{ - padding: 10px 12px; - border-bottom: 1px solid var(--bg-card-border); - font-size: 14px; - color: var(--text-primary); -} - -.wrap tbody tr:hover{ background: var(--bg-hover); } - -.title-multiline{ - display: flex; - flex-direction: column; - gap: 2px; - line-height: 1.2; -} - -.title-multiline .title-main{ - font-weight: 700; - color: var(--text-primary); -} - -.title-multiline .title-sub{ - font-weight: 500; - font-size: 12px; - color: var(--text-secondary); -} - -.title-multiline b{ - font-weight: 700; - color: var(--text-primary); -} - -/* summary = 3 колонки: [chev] [title] [meta] */ -details.table-item > summary{ - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 12px; - align-items: start; /* важно: всё прижимаем к верху */ - padding: 12px 16px; -} - -/* чтобы текст нормально переносился и не растягивал мету */ -details.table-item > summary .title{ - min-width: 0; /* важно для grid, иначе может распирать */ -} - -/* "2 записи" всегда справа и сверху, аккуратно */ -details.table-item > summary .meta{ - justify-self: end; - align-self: start; - white-space: nowrap; - padding-top: 4px; /* чуть опустить относительно первой строки */ - font-size: 12px; - color: var(--text-secondary); -} - -/* стрелка тоже сверху */ -details.table-item > summary .chev{ - align-self: start; - margin-top: 2px; -} - -.records-search{ - width: min(360px, 60vw); - padding: 0.45rem 0.7rem; - background: var(--bg-input); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: 0.9rem; - outline: none; - transition: border-color .2s ease, box-shadow .2s ease, background .2s ease; -} - -.records-search::placeholder{ color: var(--text-placeholder); } - -.records-search:focus{ - background: var(--bg-input-focus); - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); -} -/* Таблица внутри раскрывающегося блока */ -details.table-item .content table{ - width: 100%; - border-collapse: separate; /* нужно для красивых линий */ - border-spacing: 0; - border: 1px solid var(--bg-card-border); - border-radius: 12px; - overflow: hidden; - background: var(--bg-card); -} - -/* Шапка */ -details.table-item .content thead th{ - position: sticky; /* опционально: шапка прилипает при скролле */ - top: 0; - z-index: 1; - - background: var(--bg-input); - color: var(--text-secondary); - border-bottom: 1px solid var(--bg-card-border); -} - -/* Ячейки: одинаковые отступы */ -details.table-item .content th, -details.table-item .content td{ - padding: 0.75rem 0.85rem; - vertical-align: top; -} - -/* Вертикальные разделители между колонками */ -details.table-item .content th:not(:last-child), -details.table-item .content td:not(:last-child){ - border-right: 1px solid var(--bg-card-border); -} - -/* Горизонтальные разделители между строками */ -details.table-item .content tbody td{ - border-bottom: 1px solid var(--bg-card-border); - color: var(--text-primary); -} - -/* У последней строки нет нижней линии */ -details.table-item .content tbody tr:last-child td{ - border-bottom: none; -} - -/* "Зебра" для читабельности */ -details.table-item .content tbody tr:nth-child(even){ - background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover)); -} - -/* Ховер по строке */ -details.table-item .content tbody tr:hover{ - background: var(--bg-hover); -} - -/* (Опционально) Чтобы длинный текст не ломал ширину */ -details.table-item .content td{ - word-break: break-word; -} - -/* (Опционально) если таблица широкая — пусть скроллится горизонтально */ -details.table-item .content{ - overflow-x: auto; -} - -/* ===== Контейнер занятий преподавателя в модалках ===== */ -.cs-modal-table .lessons-container { - max-height: 50vh; - overflow-y: auto; - padding-right: 0.5rem; - scrollbar-width: thin; - scrollbar-color: rgba(99, 102, 241, 0.55) rgba(255, 255, 255, 0.06); -} - -.cs-modal-table .lessons-container::-webkit-scrollbar { - width: 10px; -} - -.cs-modal-table .lessons-container::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.06); - border-radius: 10px; -} - -.cs-modal-table .lessons-container::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.55); - border-radius: 10px; - border: 2px solid rgba(0, 0, 0, 0); - background-clip: padding-box; -} - -.cs-modal-table .lessons-container::-webkit-scrollbar-thumb:hover { - background: rgba(99, 102, 241, 0.75); -} - \ No newline at end of file diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css index e08b63e..3265a1a 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -72,6 +72,11 @@ overflow: hidden; } +.nav-item[hidden], +.settings-menu-item[hidden] { + display: none !important; +} + .nav-item::before { content: ''; position: absolute; @@ -411,4 +416,4 @@ visibility: visible; pointer-events: auto; } -} \ No newline at end of file +} diff --git a/frontend/admin/css/modals.css b/frontend/admin/css/modals.css index e4be11b..2ba19c1 100644 --- a/frontend/admin/css/modals.css +++ b/frontend/admin/css/modals.css @@ -2,10 +2,7 @@ .modal-overlay { display: none; position: fixed; - top: 0; - left: 0; - right: 0; - /* bottom: 0; */ + inset: 0; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); @@ -13,6 +10,8 @@ align-items: center; justify-content: center; + padding: 1rem; + overflow-y: auto; opacity: 0; transition: opacity var(--transition); @@ -29,10 +28,11 @@ border-radius: var(--radius-md); padding: 2rem; - width: 100%; - top: 0; - max-width: 100%; + width: min(560px, 100%); + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 2rem); margin: 0 auto; + overflow-y: auto; position: relative; transform: scale(0.95); @@ -40,10 +40,24 @@ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); } +.modal-content.card:hover { + transform: scale(0.95); + border-color: var(--bg-card-border); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +.modal-content.card:hover::before { + opacity: 0; +} + .modal-overlay.open .modal-content { transform: scale(1); } +.modal-overlay.open .modal-content.card:hover { + transform: scale(1); +} + .modal-close { position: absolute; top: 1rem; @@ -59,360 +73,3 @@ .modal-close:hover { color: var(--error); } - -/* ===== Кнопки ===== */ -.btn-add-lesson { - padding: 0.35rem 0.7rem; - background: rgba(16, 185, 129, 0.1); - border: 1px solid rgba(16, 185, 129, 0.2); - border-radius: var(--radius-sm); - color: var(--success); - font-family: inherit; - font-size: 0.8rem; - cursor: pointer; - transition: background var(--transition), transform var(--transition); - position: relative; - overflow: hidden; -} - -.btn-add-lesson:hover { - background: rgba(16, 185, 129, 0.2); - transform: scale(1.05); -} - -.btn-view-lessons { - padding: 0.35rem 0.7rem; - background: rgba(99, 102, 241, 0.1); - border: 1px solid rgba(99, 102, 241, 0.2); - border-radius: var(--radius-sm); - color: var(--accent); - font-family: inherit; - font-size: 0.8rem; - cursor: pointer; - transition: all var(--transition); - white-space: nowrap; -} - -.btn-view-lessons:hover { - background: rgba(99, 102, 241, 0.2); - transform: translateY(-1px); -} - -/* ===== Кнопки-переключатели (неделя) ===== */ -.btn-checkbox { - display: inline-block; - cursor: pointer; -} - -.btn-checkbox input { - position: absolute; - opacity: 0; - width: 0; - height: 0; -} - -.checkbox-btn { - display: inline-block; - padding: 0.5rem 1rem; - background: var(--bg-secondary); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - color: var(--text-primary); - transition: all var(--transition); - user-select: none; -} - -.btn-checkbox input:checked + .checkbox-btn { - background: var(--success, #10b981); - border-color: var(--success, #10b981); - color: #fff; -} - -/* =========================================================== - ===== 2-е модальное окно (View Lessons) — ОСНОВНЫЕ ПРАВКИ ===== - Требования: - - слева - - ~30% ширины - - сверху начинается СРАЗУ под 1-й модалкой - - высота = весь остаток до низа экрана - - визуально "ниже" 1-й модалки (и по z-index тоже ниже) - =========================================================== */ - -#modal-view-lessons.modal-overlay { - background: transparent !important; - backdrop-filter: none !important; - pointer-events: none; - z-index: 999; /* ниже чем 1-е (1000) */ -} - -/* В открытом состоянии: прижать влево и опустить вниз на высоту "шапки" */ -#modal-view-lessons.modal-overlay.open { - justify-content: flex-start; - align-items: flex-start; - - padding-left: 1rem; - padding-right: 1rem; - - /* ключевое: высота 1-й модалки приходит из JS через --add-lesson-height */ - padding-top: var(--add-lesson-height, 0px); -} - -/* Панель 2-й модалки */ -#modal-view-lessons .view-lessons-modal { - width: 30vw !important; - max-width: 30vw !important; - min-width: 320px; - - pointer-events: auto; - - background: var(--bg-primary); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-md); - padding: 2rem; - - position: relative; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); - - margin: 0; - - /* отключаем "пружинку" от .modal-content */ - transform: none; - - /* ключевое: занимает остаток по высоте */ - height: calc(100vh - var(--add-lesson-height, 0px)); - max-height: calc(100vh - var(--add-lesson-height, 0px)); - - /* чтобы скролл был внутри, а не у всей модалки */ - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* Header во 2-й модалке */ -#modal-veiw-lessons .modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding-right: 2rem; - flex: 0 0 auto; -} - -#modal-view-lessons .modal-header h2 { - margin: 0; - font-size: 1.3rem; - color: var(--text-primary); -} - -/* Контейнер занятий: растягивается и скроллится */ -#modal-view-lessons .lessons-container { - flex: 1 1 auto; - overflow-y: auto; - - /* перебиваем старое ограничение */ - max-height: none; - - padding-right: 0.5rem; -} - -/* ===== Карточки занятий ===== */ -.lesson-card { - background: var(--bg-card); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - padding: 1.2rem; - margin-bottom: 1rem; - transition: all 0.2s ease; -} - -.lesson-card:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); - border-color: var(--accent); -} - -.lesson-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.8rem; - padding-bottom: 0.5rem; - border-bottom: 1px dashed var(--bg-card-border); -} - -.lesson-group { - font-weight: 700; - color: var(--accent); - font-size: 1rem; - background: rgba(99, 102, 241, 0.1); - padding: 0.3rem 0.8rem; - border-radius: 20px; -} - -.lesson-time { - color: var(--text-secondary); - font-size: 0.9rem; - display: flex; - align-items: center; - gap: 0.3rem; -} - -.lesson-time::before { - content: "🕒"; - font-size: 0.9rem; - opacity: 0.7; -} - -.lesson-card-body { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.lesson-subject { - font-weight: 600; - color: var(--text-primary); - font-size: 1.1rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.lesson-subject::before { - content: "📚"; - font-size: 1rem; - opacity: 0.7; -} - -.lesson-details { - display: flex; - flex-wrap: wrap; - gap: 0.8rem; - margin-top: 0.5rem; -} - -.lesson-detail-item { - background: var(--bg-input); - padding: 0.3rem 0.8rem; - border-radius: 15px; - font-size: 0.85rem; - color: var(--text-secondary); - border: 1px solid var(--bg-card-border); -} - -/* День недели как разделитель */ -.lesson-day-divider { - margin: 1.5rem 0 1rem 0; - font-weight: 700; - color: var(--accent); - font-size: 1.1rem; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 2px solid var(--accent-glow); - padding-bottom: 0.3rem; -} - -.lesson-day-divider:first-of-type { - margin-top: 0; -} - -/* Загрузка/пусто */ -.loading-lessons, -.no-lessons { - text-align: center; - color: var(--text-secondary); - padding: 3rem; - font-size: 1rem; - background: var(--bg-card); - border-radius: var(--radius-sm); -} - -/* Светлая тема */ -[data-theme="light"] .lesson-card { - background: #fff; - border-color: rgba(0, 0, 0, 0.1); -} - -[data-theme="light"] .lesson-group { - background: rgba(99, 102, 241, 0.05); -} - -/* ===== Адаптивность ===== */ -@media (max-width: 1200px) { - #modal-view-lessons .view-lessons-modal { - width: 40vw !important; - max-width: 40vw !important; - } -} - -@media (max-width: 768px) { - /* На мобилке делаем поведение более "обычным" */ - #modal-view-lessons.modal-overlay.open { - padding-top: 1rem; - justify-content: center; - align-items: flex-start; - } - - #modal-view-lessons .view-lessons-modal { - width: 90vw !important; - max-width: 90vw !important; - min-width: 0; - - /* чтобы занимало почти весь экран */ - height: calc(100vh - 2rem); - max-height: calc(100vh - 2rem); - } - - .lesson-card-header { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } -} - -/* ===== Скролл во 2-й модалке ===== */ -#modal-view-lessons .lessons-container { - scrollbar-width: thin; /* Firefox */ - scrollbar-color: rgba(99, 102, 241, 0.55) rgba(255, 255, 255, 0.06); /* thumb track */ -} - -/* WebKit (Chrome/Edge/Safari) */ -#modal-view-lessons .lessons-container::-webkit-scrollbar { - width: 10px; -} - -#modal-view-lessons .lessons-container::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.06); - border-radius: 10px; -} - -#modal-view-lessons .lessons-container::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.55); /* под accent */ - border-radius: 10px; - border: 2px solid rgba(0, 0, 0, 0); /* чтобы выглядел “тоньше” */ - background-clip: padding-box; -} - -#modal-view-lessons .lessons-container::-webkit-scrollbar-thumb:hover { - background: rgba(99, 102, 241, 0.75); -} - -/* Общий блюр/затемнение за модалками */ -#modal-backdrop{ - position: fixed; - inset: 0; - background: rgba(0,0,0,0.55); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - - opacity: 0; - pointer-events: none; - transition: opacity var(--transition); - z-index: 998; /* ниже модалок: 999 и 1000 */ -} - -#modal-backdrop.open{ - opacity: 1; - pointer-events: auto; -} - diff --git a/frontend/admin/index.html b/frontend/admin/index.html index d499948..99d71b2 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -14,7 +14,6 @@ - @@ -54,17 +53,6 @@ Пользователи - - - - - - - - - Кафедра - @@ -72,6 +60,14 @@ Создание кафедры/специальности + + + + + + + Профили обучения + @@ -113,6 +109,25 @@ Дисциплины + + + + + + + + + Кабинет кафедры + + + + + + + Просмотр расписаний + @@ -122,13 +137,23 @@ Расписание занятий + + + + + + + + + Календарный график + - Загруженность аудиторий + Загруженность @@ -204,4 +229,4 @@ - \ No newline at end of file + diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index 0c3cec1..5e96564 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -9,6 +9,16 @@ export function isAuthenticatedAsAdmin() { return token && role === 'ADMIN'; } +export function isAuthenticatedAsRole(expectedRole) { + const role = localStorage.getItem('role'); + return token && role === expectedRole; +} + +export function isAuthenticatedAsAny(roles) { + const role = localStorage.getItem('role'); + return token && roles.includes(role); +} + function getHeaders(contentType = 'application/json') { const headers = { 'Authorization': `Bearer ${token}` @@ -31,9 +41,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { const response = await fetch(endpoint, options); - // Si status is 401 or 403, we should probably redirect to login, - // but for now let's just throw an error or handle it in the view. - let data; try { data = await response.json(); @@ -48,7 +55,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { return data; } -// Shortcut methods export const api = { get: (url) => apiFetch(url, 'GET'), post: (url, body) => apiFetch(url, 'POST', body), diff --git a/frontend/admin/js/dropdown.js b/frontend/admin/js/dropdown.js index 4af5ab9..36ccf86 100644 --- a/frontend/admin/js/dropdown.js +++ b/frontend/admin/js/dropdown.js @@ -2,10 +2,17 @@ export class CustomSelect { constructor(originalSelect) { - if (originalSelect.classList.contains('custom-select-initialized')) return; + if (originalSelect.classList.contains('custom-select-initialized')) { + if (originalSelect.customSelectInstance) { + return originalSelect.customSelectInstance; + } + originalSelect.classList.remove('custom-select-initialized'); + } this.originalSelect = originalSelect; this.originalSelect.classList.add('custom-select-initialized'); + this.originalSelect.customSelectInstance = this; + this.layerParent = this.originalSelect.closest('.settings-card, .card'); // Hide original but keep it accessible for form submissions and JS this.originalSelect.style.display = 'none'; @@ -163,6 +170,7 @@ export class CustomSelect { this.closeAll(); // Close other open dropdowns if (!isOpen) { + this.layerParent?.classList.add('select-layer-active'); this.wrapper.classList.add('open'); // Scroll selected item into view const selectedItem = this.menu.querySelector('.selected'); @@ -178,10 +186,14 @@ export class CustomSelect { document.querySelectorAll('.custom-select-wrapper.open').forEach(wrapper => { wrapper.classList.remove('open'); }); + document.querySelectorAll('.select-layer-active').forEach(layer => { + layer.classList.remove('select-layer-active'); + }); } close() { this.wrapper.classList.remove('open'); + this.layerParent?.classList.remove('select-layer-active'); } handleItemClick(e, index) { @@ -198,12 +210,28 @@ export class CustomSelect { // Global initializer export function initAllCustomDropdowns(root = document) { - const selects = root.querySelectorAll('select:not(.custom-select-initialized)'); + const selects = root.querySelectorAll('select:not(.custom-select-initialized):not([data-native-select="true"])'); selects.forEach(select => { new CustomSelect(select); }); } +export function refreshCustomDropdowns(...selects) { + selects.filter(Boolean).forEach(select => { + if (select.dataset.nativeSelect === 'true') { + return; + } + if (select.customSelectInstance) { + select.customSelectInstance.rebuildMenu(); + return; + } + if (select.classList.contains('custom-select-initialized')) { + select.classList.remove('custom-select-initialized'); + } + new CustomSelect(select); + }); +} + // Observe DOM for automatically picking up new select elements export function startDropdownAutoObserver() { const observer = new MutationObserver((mutations) => { diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 7fa36c4..4b73c3d 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -3,12 +3,14 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); } -import { isAuthenticatedAsAdmin } from './api.js'; +import { isAuthenticatedAsAny } from './api.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; -// Auth check -if (!isAuthenticatedAsAdmin()) { +const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER']; +const currentRole = localStorage.getItem('role'); + +if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) { window.location.href = '/'; } @@ -26,23 +28,48 @@ import { initClassrooms } from './views/classrooms.js'; import { initSubjects } from './views/subjects.js'; import {initSchedule} from "./views/schedule.js"; import {initDatabase} from "./views/database.js"; -import {initDepartment} from "./views/department.js"; import {initDepartmentsData} from "./views/departments-data.js"; import {initAuditoriumWorkload} from "./views/auditorium-workload.js"; +import {initProfiles} from "./views/profiles.js"; +import {initAcademicCalendar} from "./views/academic-calendar.js"; +import {initScheduleView} from "./views/schedule-view.js"; +import {initDepartmentWorkspace} from "./views/department-workspace.js"; // Configuration const ROUTES = { - users: { title: 'Управление пользователями', file: 'views/users.html', init: initUsers }, - groups: { title: 'Управление группами', file: 'views/groups.html', init: initGroups }, - 'edu-forms': { title: 'Формы обучения', file: 'views/edu-forms.html', init: initEduForms }, - equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments }, - classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms }, - subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects }, - schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule }, - 'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload }, - database: { title: 'База данных', file: 'views/database.html', init: initDatabase }, - department: { title: 'Кафедры', file: 'views/department.html', init: initDepartment }, - 'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData }, + users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers }, + groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups }, + 'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms }, + equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments }, + classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms }, + subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects }, + 'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace }, + 'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView }, + schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule }, + 'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar }, + 'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload }, + database: { title: 'База данных', file: '/admin/views/database.html', init: initDatabase }, + 'departments-data': { title: 'Создание кафедры/специальности', file: '/admin/views/departments-data.html', init: initDepartmentsData }, + profiles: { title: 'Профили обучения', file: '/admin/views/profiles.html', init: initProfiles }, +}; + +const ROLE_NAVIGATION = { + ADMIN: { + defaultTab: 'users', + tabs: Object.keys(ROUTES) + }, + EDUCATION_OFFICE: { + defaultTab: 'schedule-view', + tabs: ['schedule-view', 'schedule', 'academic-calendar', 'auditorium-workload', 'classrooms', 'equipments'] + }, + DEPARTMENT: { + defaultTab: 'department-workspace', + tabs: ['department-workspace', 'schedule-view'] + }, + SCHEDULE_VIEWER: { + defaultTab: 'schedule-view', + tabs: ['schedule-view'] + } }; let currentTab = null; @@ -57,15 +84,13 @@ const menuToggle = document.getElementById('menu-toggle'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const btnLogout = document.getElementById('btn-logout'); const main = document.querySelector('.main'); - -// Initial auth check -if (!isAuthenticatedAsAdmin()) { - window.location.href = '/'; -} +const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER; +const allowedTabs = new Set(roleNavigation.tabs); // Setup Global Effects applyRippleEffect(); closeAllDropdownsOnOutsideClick(); +configureRoleInterface(); // Init sidebar state from localStorage on load if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { @@ -134,7 +159,7 @@ navItems.forEach(item => { }); async function switchTab(tab) { - if (currentTab === tab || !ROUTES[tab]) return; + if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return; // UI Update navItems.forEach(n => n.classList.remove('active')); @@ -145,7 +170,7 @@ async function switchTab(tab) { try { appContent.innerHTML = '
Загрузка...
'; const response = await fetch(ROUTES[tab].file); - if (!response.ok) throw new Error('Failed to load view'); + if (!response.ok) throw new Error('Не удалось загрузить вкладку'); const html = await response.text(); appContent.innerHTML = html; @@ -166,5 +191,24 @@ async function switchTab(tab) { sidebarOverlay.classList.remove('open'); } -// Load default tab -switchTab('users'); +function configureRoleInterface() { + navItems.forEach(item => { + item.hidden = !allowedTabs.has(item.dataset.tab); + }); + + const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]'); + if (settingsLink) { + settingsLink.hidden = !['ADMIN', 'EDUCATION_OFFICE'].includes(currentRole); + } + + const firstAllowedTitle = { + ADMIN: 'Magistr', + EDUCATION_OFFICE: 'Учебный отдел', + DEPARTMENT: 'Кафедра', + SCHEDULE_VIEWER: 'Расписания' + }[currentRole] || 'Magistr'; + document.querySelector('.logo span').textContent = firstAllowedTitle; +} + +const hashTab = window.location.hash.replace('#', ''); +switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab); diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js new file mode 100644 index 0000000..c0b9ebd --- /dev/null +++ b/frontend/admin/js/views/academic-calendar.js @@ -0,0 +1,1225 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +const DAY_OPTIONS = [ + { value: 1, label: 'Понедельник', shortLabel: 'Пн' }, + { value: 2, label: 'Вторник', shortLabel: 'Вт' }, + { value: 3, label: 'Среда', shortLabel: 'Ср' }, + { value: 4, label: 'Четверг', shortLabel: 'Чт' }, + { value: 5, label: 'Пятница', shortLabel: 'Пт' }, + { value: 6, label: 'Суббота', shortLabel: 'Сб' }, + { value: 7, label: 'Воскресенье', shortLabel: 'Вс' } +]; + +const SEMESTER_LABELS = { + autumn: 'осенний', + spring: 'весенний' +}; + +export async function initAcademicCalendar() { + const academicYearForm = document.getElementById('academic-year-form'); + const academicYearFormTitle = document.getElementById('academic-year-form-title'); + const academicYearIdInput = document.getElementById('academic-year-id'); + const academicYearTitleInput = document.getElementById('academic-year-title'); + const academicYearStartInput = document.getElementById('academic-year-start'); + const academicYearEndInput = document.getElementById('academic-year-end'); + const academicYearResetButton = document.getElementById('academic-year-reset'); + const academicYearsTbody = document.getElementById('academic-years-tbody'); + const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); + + const semesterForm = document.getElementById('semester-form'); + const semesterFormTitle = document.getElementById('semester-form-title'); + const semesterIdInput = document.getElementById('semester-id'); + const semesterYearSelect = document.getElementById('semester-year'); + const semesterTypeSelect = document.getElementById('semester-type'); + const semesterStartInput = document.getElementById('semester-start'); + const semesterEndInput = document.getElementById('semester-end'); + const semesterResetButton = document.getElementById('semester-reset'); + + const calendarForm = document.getElementById('academic-calendar-form'); + const calendarFormTitle = document.getElementById('academic-calendar-form-title'); + const calendarIdInput = document.getElementById('academic-calendar-id'); + const calendarTitleInput = document.getElementById('academic-calendar-title'); + const calendarYearSelect = document.getElementById('academic-calendar-year'); + const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); + const calendarProfileSelect = document.getElementById('academic-calendar-profile'); + const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); + const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); + const calendarResetButton = document.getElementById('academic-calendar-reset'); + const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); + const calendarsTbody = document.getElementById('academic-calendars-tbody'); + + const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); + const editorLoadButton = document.getElementById('calendar-editor-load'); + const editorSaveButton = document.getElementById('calendar-editor-save'); + const fillCourseSelect = document.getElementById('calendar-fill-course'); + const fillStartInput = document.getElementById('calendar-fill-start'); + const fillEndInput = document.getElementById('calendar-fill-end'); + const fillActivitySelect = document.getElementById('calendar-fill-activity'); + const fillApplyButton = document.getElementById('calendar-fill-apply'); + const calendarGrid = document.getElementById('calendar-grid'); + const calendarTotals = document.getElementById('calendar-totals'); + const calendarDayDialog = document.getElementById('calendar-day-dialog'); + const calendarDayDialogClose = document.getElementById('calendar-day-dialog-close'); + const calendarDayDialogDate = document.getElementById('calendar-day-dialog-date'); + const calendarDayDialogCourse = document.getElementById('calendar-day-dialog-course'); + const calendarDayDialogWeek = document.getElementById('calendar-day-dialog-week'); + const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day'); + const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary'); + const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity'); + const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope'); + const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save'); + const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear'); + + let academicYears = []; + let semesters = []; + let specialties = []; + let profilesBySpecialty = new Map(); + let studyForms = []; + let activityTypes = []; + let calendars = []; + let timeSlotScopes = []; + let timeSlotDateAssignments = []; + let activeCalendarDay = null; + let selectedCalendarDayKey = null; + let selectedCalendarDays = []; + let isCalendarDragSelecting = false; + let didCalendarDragSelect = false; + let suppressCalendarDayClick = false; + let isSyncingCalendarDayDialog = false; + let calendarDayTooltip = null; + + bindEvents(); + + try { + await Promise.all([ + loadBaseLists(), + loadYears(), + loadStudyForms(), + loadActivityTypes(), + loadTimeSlotScopes(), + loadTimeSlotDateAssignments() + ]); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error'); + } + + function bindEvents() { + academicYearsRefreshButton?.addEventListener('click', loadYears); + calendarsRefreshButton?.addEventListener('click', loadCalendars); + academicYearResetButton?.addEventListener('click', resetAcademicYearForm); + semesterResetButton?.addEventListener('click', resetSemesterForm); + calendarResetButton?.addEventListener('click', resetCalendarForm); + calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); + editorLoadButton?.addEventListener('click', loadCalendarGrid); + editorSaveButton?.addEventListener('click', saveCalendarGrid); + fillApplyButton?.addEventListener('click', applyCalendarFill); + + calendarGrid?.addEventListener('change', (event) => { + if (event.target.matches('.calendar-day-activity')) { + updateCalendarDayVisual(event.target.closest('.calendar-day')); + renderCalendarTotals(); + } + }); + calendarGrid?.addEventListener('mousedown', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) startCalendarDayDragSelection(event, day); + }); + calendarGrid?.addEventListener('mouseover', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) addCalendarDayToDragSelection(day); + }); + calendarGrid?.addEventListener('mousemove', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) showCalendarDayTooltip(day, event); + }); + calendarGrid?.addEventListener('mouseleave', hideCalendarDayTooltip); + calendarGrid?.addEventListener('focusin', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) showCalendarDayTooltip(day); + }); + calendarGrid?.addEventListener('focusout', hideCalendarDayTooltip); + calendarGrid?.addEventListener('click', (event) => { + if (suppressCalendarDayClick) { + suppressCalendarDayClick = false; + event.preventDefault(); + return; + } + const day = event.target.closest('.calendar-day'); + if (day) openCalendarDayDialog(day); + }); + document.addEventListener('mouseup', finishCalendarDayDragSelection); + calendarGrid?.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const day = event.target.closest('.calendar-day'); + if (!day) return; + event.preventDefault(); + openCalendarDayDialog(day); + }); + calendarDayDialogClose?.addEventListener('click', closeCalendarDayDialog); + calendarDayDialog?.addEventListener('click', (event) => { + if (event.target === calendarDayDialog) closeCalendarDayDialog(); + }); + calendarDayDialogActivitySelect?.addEventListener('change', () => { + if (isSyncingCalendarDayDialog || !calendarDayDialogActivitySelect.value) return; + const days = selectedCalendarDays.length ? selectedCalendarDays : (activeCalendarDay ? [activeCalendarDay] : []); + days.forEach(day => { + const select = day.querySelector('.calendar-day-activity'); + select.value = calendarDayDialogActivitySelect.value; + updateCalendarDayVisual(day); + }); + updateCalendarDayDialogSummaryForSelection(days); + renderCalendarTotals(); + }); + calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); + calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); + + academicYearForm?.addEventListener('submit', saveAcademicYear); + semesterForm?.addEventListener('submit', saveSemester); + calendarForm?.addEventListener('submit', saveCalendar); + + academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick); + calendarsTbody?.addEventListener('click', handleCalendarTableClick); + } + + async function loadBaseLists() { + specialties = await api.get('/api/specialties'); + await loadProfiles(); + populateSpecialtySelects(); + } + + async function loadProfiles() { + const pairs = await Promise.all(specialties.map(async (speciality) => { + const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); + return [String(speciality.id), profiles]; + })); + profilesBySpecialty = new Map(pairs); + } + + async function loadYears() { + academicYearsTbody.innerHTML = 'Загрузка...'; + try { + academicYears = await api.get('/api/admin/calendar/years'); + semesters = academicYears.flatMap(year => year.semesters || []); + renderAcademicYears(); + populateYearSelects(); + } catch (error) { + academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + throw error; + } + } + + function renderAcademicYears() { + if (!academicYears.length) { + academicYearsTbody.innerHTML = 'Учебные годы не созданы'; + return; + } + academicYearsTbody.innerHTML = academicYears.map(year => ` + + ${escapeHtml(year.title)} + ${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)} + ${renderSemesterList(year.semesters || [])} + + + + + + `).join(''); + } + + function renderSemesterList(items) { + if (!items.length) return 'Нет семестров'; + return items.map(semester => ` +
+ ${escapeHtml(semesterLabel(semester))} + +
+ `).join(''); + } + + async function saveAcademicYear(event) { + event.preventDefault(); + hideAlert('academic-year-alert'); + const payload = { + title: academicYearTitleInput.value.trim(), + startDate: academicYearStartInput.value, + endDate: academicYearEndInput.value + }; + try { + if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); + const id = academicYearIdInput.value; + await (id + ? api.put('/api/admin/calendar/years/' + id, payload) + : api.post('/api/admin/calendar/years', payload)); + showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); + resetAcademicYearForm(); + await loadYears(); + await loadCalendars(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); + } + } + + async function handleAcademicYearTableClick(event) { + const editYearButton = event.target.closest('.btn-edit-year'); + const deleteYearButton = event.target.closest('.btn-delete-year'); + const editSemesterButton = event.target.closest('.btn-edit-semester'); + + if (editYearButton) { + const year = academicYears.find(item => item.id == editYearButton.dataset.id); + if (!year) return; + academicYearFormTitle.textContent = 'Редактирование учебного года'; + academicYearIdInput.value = year.id; + academicYearTitleInput.value = year.title || ''; + academicYearStartInput.value = year.startDate || ''; + academicYearEndInput.value = year.endDate || ''; + hideAlert('academic-year-alert'); + academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (editSemesterButton) { + const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); + if (!semester) return; + semesterFormTitle.textContent = 'Редактирование семестра'; + semesterIdInput.value = semester.id; + semesterYearSelect.value = semester.academicYearId || ''; + semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); + semesterStartInput.value = semester.startDate || ''; + semesterEndInput.value = semester.endDate || ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteYearButton) { + if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; + try { + await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); + showAlert('academic-year-alert', 'Учебный год удалён', 'success'); + await loadYears(); + await loadCalendars(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); + } + } + } + + function resetAcademicYearForm() { + academicYearForm.reset(); + academicYearFormTitle.textContent = 'Учебный год'; + academicYearIdInput.value = ''; + hideAlert('academic-year-alert'); + } + + async function saveSemester(event) { + event.preventDefault(); + hideAlert('semester-alert'); + const yearId = semesterYearSelect.value; + const payload = { + semesterType: semesterTypeSelect.value, + startDate: semesterStartInput.value, + endDate: semesterEndInput.value + }; + try { + if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { + throw new Error('Заполните учебный год, тип и даты семестра'); + } + const id = semesterIdInput.value; + await (id + ? api.put('/api/admin/calendar/semesters/' + id, payload) + : api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload)); + showAlert('semester-alert', 'Семестр сохранён', 'success'); + resetSemesterForm(); + await loadYears(); + } catch (error) { + showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error'); + } + } + + function resetSemesterForm() { + semesterForm.reset(); + semesterFormTitle.textContent = 'Семестр'; + semesterIdInput.value = ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + } + + async function loadStudyForms() { + studyForms = await api.get('/api/education-forms'); + populateStudyFormSelect(); + } + + async function loadActivityTypes() { + activityTypes = await api.get('/api/admin/calendar/activity-types'); + populateActivitySelects(); + } + + async function loadTimeSlotScopes() { + timeSlotScopes = await api.get('/api/admin/time-slots/scopes'); + } + + async function loadTimeSlotDateAssignments() { + timeSlotDateAssignments = await api.get('/api/admin/time-slots/date-assignments'); + } + + async function loadCalendars() { + calendarsTbody.innerHTML = 'Загрузка...'; + try { + calendars = await api.get('/api/admin/academic-calendars'); + renderCalendars(); + populateEditorCalendarSelect(); + } catch (error) { + calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + throw error; + } + } + + function renderCalendars() { + if (!calendars.length) { + calendarsTbody.innerHTML = 'Календарные графики не созданы'; + return; + } + calendarsTbody.innerHTML = calendars.map(calendar => ` + + ${escapeHtml(calendar.title)} + ${escapeHtml(calendar.academicYearTitle || '-')} + ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} + ${escapeHtml(calendar.specialtyProfileName || '-')} + ${escapeHtml(calendar.studyFormName || '-')} + ${escapeHtml(String(calendar.courseCount || '-'))} + + + + + + + `).join(''); + } + + async function saveCalendar(event) { + event.preventDefault(); + hideAlert('academic-calendar-alert'); + const payload = { + id: calendarIdInput.value ? Number(calendarIdInput.value) : null, + title: calendarTitleInput.value.trim(), + academicYearId: Number(calendarYearSelect.value), + specialtyId: Number(calendarSpecialtySelect.value), + specialtyProfileId: Number(calendarProfileSelect.value), + studyFormId: Number(calendarStudyFormSelect.value), + courseCount: Number(calendarCourseCountInput.value) + }; + try { + if (!payload.title || !payload.academicYearId || !payload.specialtyId + || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { + throw new Error('Заполните все поля календарного графика'); + } + const id = calendarIdInput.value; + const saved = id + ? await api.put('/api/admin/academic-calendars/' + id, payload) + : await api.post('/api/admin/academic-calendars', payload); + showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); + resetCalendarForm(); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error'); + } + } + + async function handleCalendarTableClick(event) { + const editButton = event.target.closest('.btn-edit-calendar'); + const gridButton = event.target.closest('.btn-open-calendar-grid'); + const deleteButton = event.target.closest('.btn-delete-calendar'); + + if (editButton) { + const calendar = calendars.find(item => item.id == editButton.dataset.id); + if (!calendar) return; + calendarFormTitle.textContent = 'Редактирование календарного графика'; + calendarIdInput.value = calendar.id; + calendarTitleInput.value = calendar.title || ''; + calendarYearSelect.value = calendar.academicYearId || ''; + calendarSpecialtySelect.value = calendar.specialtyId || ''; + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); + calendarStudyFormSelect.value = calendar.studyFormId || ''; + calendarCourseCountInput.value = calendar.courseCount || 4; + syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); + hideAlert('academic-calendar-alert'); + calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (gridButton) { + editorCalendarSelect.value = gridButton.dataset.id; + syncSelects(editorCalendarSelect); + await loadCalendarGrid(); + calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteButton) { + if (!confirm('Удалить календарный график?')) return; + try { + await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); + showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error'); + } + } + } + + function resetCalendarForm() { + calendarForm.reset(); + calendarFormTitle.textContent = 'Календарный учебный график'; + calendarIdInput.value = ''; + calendarCourseCountInput.value = 4; + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); + syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); + hideAlert('academic-calendar-alert'); + } + + async function loadCalendarGrid() { + hideAlert('calendar-editor-alert'); + const calendar = selectedEditorCalendar(); + if (!calendar) { + showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); + return; + } + try { + await loadTimeSlotDateAssignments(); + const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); + const gridRows = rows.length ? rows : buildDefaultGrid(calendar); + renderCalendarGrid(gridRows); + showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); + } catch (error) { + showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); + } + } + + async function saveCalendarGrid() { + hideAlert('calendar-editor-alert'); + const calendar = selectedEditorCalendar(); + if (!calendar) { + showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); + return; + } + const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ + calendarId: calendar.id, + courseNumber: Number(day.dataset.course), + date: day.dataset.date, + weekNumber: Number(day.dataset.week), + dayOfWeek: Number(day.dataset.day), + activityTypeId: Number(day.querySelector('.calendar-day-activity').value) + })); + if (!rows.length) { + showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error'); + return; + } + try { + await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); + showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); + await loadCalendarGrid(); + } catch (error) { + showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error'); + } + } + + function applyCalendarFill() { + hideAlert('calendar-editor-alert'); + const activityId = fillActivitySelect.value; + const start = fillStartInput.value; + const end = fillEndInput.value; + if (!activityId || !start || !end) { + showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); + return; + } + if (end < start) { + showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); + return; + } + const course = fillCourseSelect.value; + let changed = 0; + calendarGrid.querySelectorAll('.calendar-day').forEach(day => { + const sameCourse = !course || day.dataset.course === course; + const inRange = day.dataset.date >= start && day.dataset.date <= end; + if (sameCourse && inRange) { + day.querySelector('.calendar-day-activity').value = activityId; + updateCalendarDayVisual(day); + changed += 1; + } + }); + renderCalendarTotals(); + showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); + } + + function renderCalendarGrid(rows) { + activeCalendarDay = null; + selectedCalendarDayKey = null; + selectedCalendarDays = []; + const grouped = groupBy(rows, row => row.courseNumber); + calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => ` +
+ + + + ${course} курс + + ${courseRows.length} дней + +
+ ${renderCourseSemesters(Number(course), courseRows)} +
+
+ `).join(''); + populateFillCourseSelect(); + renderCalendarTotals(); + } + + function renderCourseSemesters(course, courseRows) { + const calendar = selectedEditorCalendar(); + const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); + return semestersForCourse.map(block => ` +
+
+

${block.semesterNumber} семестр

+ ${escapeHtml(block.caption)} +
+ ${renderSemesterTable(block.rows)} +
+ `).join(''); + } + + function renderSemesterTable(rows) { + if (!rows.length) { + return '
Нет дат в этом семестре
'; + } + + const rowsByWeek = groupBy(rows, row => row.weekNumber); + const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); + const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); + return ` +
+ + + + + ${weeks.map(week => ``).join('')} + + + + ${DAY_OPTIONS.map(day => ` + + + ${weeks.map(week => { + const row = cellByWeekAndDay.get(`${week}:${day.value}`); + return row ? renderCalendarDay(row) : ''; + }).join('')} + + `).join('')} + +
День${week}
${escapeHtml(day.shortLabel)}
+
+ `; + } + + function renderCalendarDay(row) { + const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); + const dayName = shortDayLabel(row.dayOfWeek); + const activity = activityById(selected) || activityTypes[0] || {}; + const code = activity.code || row.activityCode || 'Т'; + const color = activity.colorCode || '#64748b'; + return ` + + ${escapeHtml(code)} + ${timeAssignmentForDate(row.date) ? 'вр.' : ''} + + + `; + } + + function openCalendarDayDialog(day) { + openCalendarDayDialogForSelection([day]); + } + + function openCalendarDayDialogForSelection(days) { + const selectedDays = uniqueCalendarDays(days); + if (!selectedDays.length) return; + + activeCalendarDay = selectedDays[0]; + selectCalendarDays(selectedDays); + + const dates = selectedDays.map(day => day.dataset.date).sort(); + const courses = new Set(selectedDays.map(day => day.dataset.course)); + const weeks = new Set(selectedDays.map(day => day.dataset.week)); + const weekDays = new Set(selectedDays.map(day => day.dataset.day)); + const isSingle = selectedDays.length === 1; + + calendarDayDialogDate.textContent = isSingle + ? `${formatLongDate(activeCalendarDay.dataset.date)} (${shortDayLabel(activeCalendarDay.dataset.day)})` + : `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; + calendarDayDialogCourse.textContent = courses.size === 1 + ? `${activeCalendarDay.dataset.course} курс` + : `${courses.size} курса`; + calendarDayDialogWeek.textContent = weeks.size === 1 + ? `${activeCalendarDay.dataset.week} неделя` + : `${weeks.size} недель`; + calendarDayDialogDay.textContent = weekDays.size === 1 + ? dayLabel(activeCalendarDay.dataset.day) + : `${selectedDays.length} ячеек`; + calendarDayDialogActivitySelect.innerHTML = options( + activityTypes.map(toActivityOption), + commonCalendarDayActivityValue(selectedDays) + ); + if (isSingle) { + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + } else { + populateMultiSelectionTimeScopeDialogSelect(); + } + isSyncingCalendarDayDialog = true; + syncSelects(calendarDayDialogActivitySelect); + syncSelects(calendarDayDialogTimeScopeSelect); + isSyncingCalendarDayDialog = false; + updateCalendarDayDialogSummaryForSelection(selectedDays); + renderCalendarTotals(); + calendarDayDialog.classList.add('open'); + } + + function closeCalendarDayDialog() { + activeCalendarDay = null; + calendarDayDialog.classList.remove('open'); + } + + function updateCalendarDayDialogSummary(day) { + const activity = activityById(day.querySelector('.calendar-day-activity').value); + if (!activity) { + calendarDayDialogActivitySummary.innerHTML = ''; + return; + } + calendarDayDialogActivitySummary.innerHTML = ` + ${escapeHtml(activity.code)} +
+ ${escapeHtml(activity.name)} +

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

+
+ `; + } + + function updateCalendarDayDialogSummaryForSelection(days) { + if (days.length <= 1) { + updateCalendarDayDialogSummary(days[0]); + return; + } + + const activity = activityById(commonCalendarDayActivityValue(days)); + if (!activity) { + calendarDayDialogActivitySummary.innerHTML = ` + ... +
+ Выбрано ячеек: ${days.length} +

Выберите код активности, чтобы применить его ко всему выделению

+
+ `; + return; + } + + calendarDayDialogActivitySummary.innerHTML = ` + ${escapeHtml(activity.code)} +
+ ${escapeHtml(activity.name)} · ${days.length} ячеек +

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

+
+ `; + } + + async function saveTimeSlotDateAssignment() { + if (!activeCalendarDay) return; + hideAlert('calendar-day-dialog-time-alert'); + const scopeId = calendarDayDialogTimeScopeSelect.value; + if (!scopeId) { + await clearTimeSlotDateAssignment(); + return; + } + try { + await api.post('/api/admin/time-slots/date-assignments', { + date: activeCalendarDay.dataset.date, + scopeId: Number(scopeId) + }); + await loadTimeSlotDateAssignments(); + updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Ручная сетка применена к дате', 'success'); + } catch (error) { + showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка применения временной сетки', 'error'); + } + } + + async function clearTimeSlotDateAssignment() { + if (!activeCalendarDay) return; + hideAlert('calendar-day-dialog-time-alert'); + const assignment = timeAssignmentForDate(activeCalendarDay.dataset.date); + if (!assignment) { + calendarDayDialogTimeScopeSelect.value = ''; + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Для даты нет ручной сетки', 'success'); + return; + } + try { + await api.delete('/api/admin/time-slots/date-assignments/' + assignment.id); + await loadTimeSlotDateAssignments(); + updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Ручная сетка убрана', 'success'); + } catch (error) { + showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка удаления ручной сетки', 'error'); + } + } + + function populateTimeScopeDialogSelect(date) { + const assignment = timeAssignmentForDate(date); + const manualScopes = timeSlotScopes.filter(scope => scope.applyMode === 'MANUAL'); + const automaticLabel = automaticTimeScopeLabel(date); + calendarDayDialogTimeScopeSelect.innerHTML = `` + + manualScopes.map(scope => ``).join(''); + calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; + calendarDayDialogTimeScopeSelect.disabled = false; + calendarDayDialogTimeSaveButton.disabled = false; + calendarDayDialogTimeClearButton.disabled = !assignment; + } + + function populateMultiSelectionTimeScopeDialogSelect() { + calendarDayDialogTimeScopeSelect.innerHTML = ''; + calendarDayDialogTimeScopeSelect.value = ''; + calendarDayDialogTimeScopeSelect.disabled = true; + calendarDayDialogTimeSaveButton.disabled = false; + calendarDayDialogTimeClearButton.disabled = true; + } + + function updateTimeAssignmentVisuals(date) { + const assignment = timeAssignmentForDate(date); + calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { + day.dataset.timeAssignmentId = assignment?.id || ''; + day.dataset.timeScopeId = assignment?.scopeId || ''; + const marker = day.querySelector('.calendar-day-time-marker'); + if (assignment && !marker) { + day.insertAdjacentHTML('beforeend', 'вр.'); + } + if (!assignment && marker) { + marker.remove(); + } + }); + } + + function updateCalendarDayVisual(day) { + if (!day) return; + const select = day.querySelector('.calendar-day-activity'); + const activity = activityById(select.value); + const code = activity?.code || '-'; + const color = activity?.colorCode || '#64748b'; + day.style.setProperty('--activity-color', color); + day.querySelector('.calendar-day-code').textContent = code; + day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); + } + + function showCalendarDayTooltip(day, event = null) { + if (isCalendarDragSelecting) { + hideCalendarDayTooltip(); + return; + } + const tooltip = ensureCalendarDayTooltip(); + const activity = activityById(day.querySelector('.calendar-day-activity')?.value); + tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b'); + tooltip.innerHTML = renderCalendarDayTooltip(day); + tooltip.classList.add('visible'); + positionCalendarDayTooltip(tooltip, day, event); + } + + function hideCalendarDayTooltip() { + calendarDayTooltip?.classList.remove('visible'); + } + + function ensureCalendarDayTooltip() { + if (calendarDayTooltip) return calendarDayTooltip; + calendarDayTooltip = document.createElement('div'); + calendarDayTooltip.className = 'calendar-day-tooltip'; + calendarDayTooltip.setAttribute('role', 'tooltip'); + document.body.appendChild(calendarDayTooltip); + return calendarDayTooltip; + } + + function renderCalendarDayTooltip(day) { + const activity = activityById(day.querySelector('.calendar-day-activity')?.value); + const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-'; + const color = activity?.colorCode || '#64748b'; + const timeAssignment = timeAssignmentForDate(day.dataset.date); + const timeLabel = timeAssignment + ? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка') + : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); + return ` +
+ ${escapeHtml(code)} +
+ ${escapeHtml(formatLongDate(day.dataset.date))} + ${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя +
+
+
+
+ Курс + ${escapeHtml(day.dataset.course)} +
+
+ Активность + ${escapeHtml(activity?.name || 'Не задана')} +
+
+ Время + ${escapeHtml(timeLabel)} +
+
+ `; + } + + function positionCalendarDayTooltip(tooltip, day, event) { + const offset = 14; + const rect = day.getBoundingClientRect(); + const x = event?.clientX ?? rect.left + rect.width / 2; + const y = event?.clientY ?? rect.top; + const tooltipRect = tooltip.getBoundingClientRect(); + let left = x + offset; + let top = y + offset; + if (left + tooltipRect.width > window.innerWidth - 12) { + left = x - tooltipRect.width - offset; + } + if (top + tooltipRect.height > window.innerHeight - 12) { + top = y - tooltipRect.height - offset; + } + tooltip.style.left = `${Math.max(12, left)}px`; + tooltip.style.top = `${Math.max(12, top)}px`; + } + + function startCalendarDayDragSelection(event, day) { + if (event.button !== 0) return; + hideCalendarDayTooltip(); + isCalendarDragSelecting = true; + didCalendarDragSelect = false; + suppressCalendarDayClick = false; + selectCalendarDays([day]); + event.preventDefault(); + } + + function addCalendarDayToDragSelection(day) { + if (!isCalendarDragSelecting || selectedCalendarDays.includes(day)) return; + didCalendarDragSelect = true; + selectedCalendarDays.push(day); + day.classList.add('is-selected'); + renderCalendarTotals(); + } + + function finishCalendarDayDragSelection() { + if (!isCalendarDragSelecting) return; + isCalendarDragSelecting = false; + if (didCalendarDragSelect && selectedCalendarDays.length > 1) { + suppressCalendarDayClick = true; + openCalendarDayDialogForSelection(selectedCalendarDays); + } + } + + function selectCalendarDay(day) { + selectCalendarDays(day ? [day] : []); + } + + function selectCalendarDays(days) { + const uniqueDays = uniqueCalendarDays(days); + selectedCalendarDays = uniqueDays; + selectedCalendarDayKey = uniqueDays[0] ? calendarDayKey(uniqueDays[0]) : null; + calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); + uniqueDays.forEach(item => item.classList.add('is-selected')); + renderCalendarTotals(); + } + + function uniqueCalendarDays(days) { + return Array.from(new Set(days.filter(Boolean))); + } + + function commonCalendarDayActivityValue(days) { + const values = uniqueCalendarDays(days).map(day => day.querySelector('.calendar-day-activity')?.value || ''); + const uniqueValues = Array.from(new Set(values)); + return uniqueValues.length === 1 ? uniqueValues[0] : ''; + } + + function calendarDayKey(day) { + return `${day.dataset.course}:${day.dataset.date}`; + } + + function buildDefaultGrid(calendar) { + const rows = []; + const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); + for (let course = 1; course <= calendar.courseCount; course += 1) { + dates.forEach((date, index) => { + rows.push({ + id: null, + calendarId: calendar.id, + courseNumber: course, + date: date.iso, + weekNumber: Math.floor(index / 7) + 1, + dayOfWeek: date.dayOfWeek, + activityTypeId: theoryActivityId(), + activityCode: 'Т' + }); + }); + } + return rows; + } + + function semesterBlocksForCourse(calendar, course, courseRows) { + const sortedRows = [...courseRows].sort((a, b) => String(a.date).localeCompare(String(b.date))); + const year = academicYears.find(item => String(item.id) === String(calendar?.academicYearId)); + const yearSemesters = [...(year?.semesters || [])] + .sort((a, b) => String(a.startDate).localeCompare(String(b.startDate))); + + if (!yearSemesters.length) { + return [{ + semesterNumber: `${course * 2 - 1}-${course * 2}`, + caption: dateRangeCaption(sortedRows), + rows: sortedRows + }]; + } + + return yearSemesters.map((semester, index) => { + const nextSemester = yearSemesters[index + 1]; + const startDate = index === 0 + ? minIso(calendar.academicYearStartDate, semester.startDate) + : semester.startDate; + const endDate = nextSemester + ? previousIsoDate(nextSemester.startDate) + : maxIso(calendar.academicYearEndDate, semester.endDate); + const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate); + return { + semesterNumber: course * 2 - 1 + index, + caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`, + rows + }; + }); + } + + function renderCalendarTotals() { + const counts = new Map(); + calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { + const activity = activityById(select.value); + const key = activity ? activity.code : '-'; + counts.set(key, (counts.get(key) || 0) + 1); + }); + const selectedDays = selectedCalendarDays.length + ? selectedCalendarDays + : (selectedCalendarDayKey + ? [Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey)].filter(Boolean) + : []); + const selectedActivity = selectedDays.length ? activityById(commonCalendarDayActivityValue(selectedDays)) : null; + const selectedCode = selectedActivity?.code || null; + calendarTotals.innerHTML = Array.from(counts.entries()) + .sort(([a], [b]) => a.localeCompare(b, 'ru')) + .map(([code, count]) => { + const activity = activityTypes.find(item => item.code === code); + const selectedClass = selectedCode === code ? ' is-selected' : ''; + const suffix = selectedCode === code ? 'выбрано' : ''; + return `${escapeHtml(code)}: ${count}${suffix}`; + }).join(''); + } + + function populateYearSelects() { + const yearOptions = '' + + academicYears.map(year => ``).join(''); + semesterYearSelect.innerHTML = yearOptions; + calendarYearSelect.innerHTML = yearOptions; + syncSelects(semesterYearSelect, calendarYearSelect); + } + + function populateSpecialtySelects() { + calendarSpecialtySelect.innerHTML = '' + + specialties.map(speciality => ``).join(''); + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); + syncSelects(calendarSpecialtySelect); + } + + function populateProfileSelect(select, specialtyId, selectedProfileId = null) { + const profiles = profilesBySpecialty.get(String(specialtyId)) || []; + select.innerHTML = profiles.length + ? '' + profiles.map(profile => + `` + ).join('') + : ''; + if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { + select.value = selectedProfileId; + } + syncSelects(select); + } + + function populateStudyFormSelect() { + calendarStudyFormSelect.innerHTML = '' + + studyForms.map(form => ``).join(''); + syncSelects(calendarStudyFormSelect); + } + + function populateActivitySelects() { + const activityOptions = '' + + activityTypes.map(type => ``).join(''); + fillActivitySelect.innerHTML = activityOptions; + syncSelects(fillActivitySelect); + } + + function populateEditorCalendarSelect() { + const current = editorCalendarSelect.value; + editorCalendarSelect.innerHTML = '' + + calendars.map(calendar => ``).join(''); + if (current && calendars.some(calendar => String(calendar.id) === String(current))) { + editorCalendarSelect.value = current; + } + syncSelects(editorCalendarSelect); + } + + function populateFillCourseSelect() { + const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); + fillCourseSelect.innerHTML = '' + + Array.from(courses).sort((a, b) => Number(a) - Number(b)) + .map(course => ``).join(''); + syncSelects(fillCourseSelect); + } + + function options(items, selectedValue) { + const selected = selectedValue == null ? '' : String(selectedValue); + return '' + items.map(item => { + const value = String(item.value); + return ``; + }).join(''); + } + + function toActivityOption(type) { + return { value: type.id, label: `${type.code} - ${type.name}` }; + } + + function selectedEditorCalendar() { + return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); + } + + function theoryActivityId() { + return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; + } + + function activityIdByCode(code) { + return activityTypes.find(type => type.code === code)?.id || null; + } + + function activityById(id) { + return activityTypes.find(type => String(type.id) === String(id)); + } + + function timeAssignmentForDate(date) { + return timeSlotDateAssignments.find(item => item.date === date); + } + + function automaticTimeScopeLabel(date) { + const jsDate = new Date(`${date}T00:00:00`); + const jsDay = jsDate.getDay(); + const isoDay = jsDay === 0 ? 7 : jsDay; + const weekdayScope = timeSlotScopes.find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay); + const defaultScope = timeSlotScopes.find(scope => scope.applyMode === 'DEFAULT'); + const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка'; + return `По правилу даты (${scopeName})`; + } + + function semesterLabel(semester) { + const type = normalizeSemesterType(semester.semesterType); + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; + } + + function normalizeSemesterType(type) { + return String(type || '').toLowerCase(); + } + + function specialityLabel(speciality) { + const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; + const name = speciality.specialityName || speciality.name || ''; + return name ? `${code} - ${name}` : String(code); + } + + function dayLabel(value) { + return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; + } + + function shortDayLabel(value) { + return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-'; + } + + function formatShortDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}`; + } + + function formatLongDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; + } + + function dateRangeCaption(rows) { + if (!rows.length) return ''; + const dates = rows.map(row => row.date).sort(); + return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; + } + + function minIso(a, b) { + return String(a) <= String(b) ? a : b; + } + + function maxIso(a, b) { + return String(a) >= String(b) ? a : b; + } + + function previousIsoDate(value) { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() - 1); + return date.toISOString().slice(0, 10); + } + + function enumerateDates(startIso, endIso) { + const result = []; + const current = new Date(`${startIso}T00:00:00Z`); + const end = new Date(`${endIso}T00:00:00Z`); + while (current <= end) { + const iso = current.toISOString().slice(0, 10); + const jsDay = current.getUTCDay(); + result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); + current.setUTCDate(current.getUTCDate() + 1); + } + return result; + } + + function groupBy(items, keyFn) { + const grouped = new Map(); + items.forEach(item => { + const key = keyFn(item); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(item); + }); + return grouped; + } + + function syncSelects(...selects) { + selects.filter(Boolean).forEach(select => { + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } +} diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js index f7c7629..4f4a28f 100644 --- a/frontend/admin/js/views/auditorium-workload.js +++ b/frontend/admin/js/views/auditorium-workload.js @@ -1,153 +1,1172 @@ -import { initMultiSelect } from '../utils.js'; +import { api } from '../api.js'; +import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js'; -export function initAuditoriumWorkload() { - // Initialize date input with current date +const NO_BUILDING = '__no_building__'; +const DISPLAY_ALL = 'all'; +const TARGET_CLASSROOMS = 'classrooms'; +const TARGET_TEACHERS = 'teachers'; +const TARGET_DEPARTMENTS = 'departments'; +const ROOM_PREFIX = 'room:'; +const TEACHER_PREFIX = 'teacher:'; +const DEPARTMENT_PREFIX = 'department:'; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const WORKLOAD_TARGETS = { + [TARGET_CLASSROOMS]: { + allLabel: 'Все аудитории', + emptyLabel: 'Нет аудиторий по выбранным фильтрам', + rowLabel: 'Аудитория', + summaryLabel: 'Аудиторий', + periodTitle: 'Нагрузка аудитории по неделям', + selectError: 'Выберите аудиторию', + prefix: ROOM_PREFIX + }, + [TARGET_TEACHERS]: { + allLabel: 'Все преподаватели', + emptyLabel: 'Нет преподавателей', + rowLabel: 'Преподаватель', + summaryLabel: 'Преподавателей', + periodTitle: 'Нагрузка преподавателя по неделям', + selectError: 'Выберите преподавателя', + prefix: TEACHER_PREFIX + }, + [TARGET_DEPARTMENTS]: { + allLabel: 'Все кафедры', + emptyLabel: 'Нет кафедр', + rowLabel: 'Кафедра', + summaryLabel: 'Кафедр', + periodTitle: 'Нагрузка кафедры по неделям', + selectError: 'Выберите кафедру', + prefix: DEPARTMENT_PREFIX + } +}; + +const DAY_SHORT_LABELS = { + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + 7: 'Вс' +}; + +const DAY_FULL_LABELS = { + 1: 'Понедельник', + 2: 'Вторник', + 3: 'Среда', + 4: 'Четверг', + 5: 'Пятница', + 6: 'Суббота', + 7: 'Воскресенье' +}; + +const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]; + +const CELL_PARITY_LAYOUT = [ + { parity: 'ODD', label: 'Нечётная' }, + { parity: 'EVEN', label: 'Чётная' } +]; + +const CAPACITY_BUCKETS = [ + { id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 }, + { id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 }, + { id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 }, + { id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 } +]; + +export async function initAuditoriumWorkload() { const dateInput = document.getElementById('workload-date'); - if (dateInput) { + const refreshButton = document.getElementById('workload-refresh'); + const targetSelect = document.getElementById('workload-target-select'); + const displaySelect = document.getElementById('workload-display-select'); + const headerRow = document.getElementById('workload-header-row'); + const tbody = document.getElementById('workload-tbody'); + const summary = document.getElementById('workload-summary'); + const overviewBlock = document.getElementById('workload-overview-block'); + const roomBlock = document.getElementById('room-workload-block'); + const roomSummary = document.getElementById('room-workload-summary'); + const roomTables = document.getElementById('room-workload-tables'); + const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter')); + const dateLabel = dateInput?.closest('.form-group')?.querySelector('label'); + + let classrooms = []; + let teachers = []; + let departments = []; + let timeSlots = []; + let equipments = []; + let groups = []; + let semesters = []; + let lessonsByEntitySlot = new Map(); + let failedGroupLoads = 0; + let currentTarget = TARGET_CLASSROOMS; + let currentView = DISPLAY_ALL; + let selectedRoomId = null; + let selectedEntityId = null; + + setDefaultDate(); + initFilters(); + bindEvents(); + await loadInitialData(); + + function setDefaultDate() { + if (!dateInput || dateInput.value) return; const today = new Date(); - const yyyy = today.getFullYear(); - const mm = String(today.getMonth() + 1).padStart(2, '0'); - const dd = String(today.getDate()).padStart(2, '0'); - dateInput.value = `${yyyy}-${mm}-${dd}`; + dateInput.value = formatIsoDate(today); } - // Initialize Multi-Selects - initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); - initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes'); - initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); + function initFilters() { + initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); + initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes'); + initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); + } - // Populate Filters with Mock/Initial Data - populateFilters(); - - // Render Mock Data for the Grid based on the UI requested layout - renderMockGrid(); -} - -function populateFilters() { - // Buildings - const buildingsContainer = document.getElementById('building-checkboxes'); - const buildings = [ - { id: 1, name: "Корпус 1 (Главный)" }, - { id: 2, name: "Корпус 2 (Физ-мат)" }, - { id: 3, name: "Корпус 3 (Гуманитарный)" } - ]; - buildingsContainer.innerHTML = buildings.map(item => ` - - `).join(''); - - // Capacities - const capacityContainer = document.getElementById('capacity-checkboxes'); - const capacities = [ - { id: 'small', name: "До 30 мест" }, - { id: 'medium', name: "30 - 60 мест" }, - { id: 'large', name: "60 - 100 мест" }, - { id: 'xlarge', name: "Более 100 мест" } - ]; - capacityContainer.innerHTML = capacities.map(item => ` - - `).join(''); - - // Equipment - const equipmentContainer = document.getElementById('equipment-checkboxes'); - const equipmentList = [ - { id: 1, name: "Проектор" }, - { id: 2, name: "Компьютерные места" }, - { id: 3, name: "Интерактивная доска" }, - { id: 4, name: "Микрофон" } - ]; - equipmentContainer.innerHTML = equipmentList.map(item => ` - - `).join(''); -} - -function renderMockGrid() { - // In future this will be loaded from API - const timeslots = [ - "8:00-9:30", - "9:40-11:10", - "11:40-13:10", - "13:20-14:50", - "15:00-16:30", - "16:50-18:20", - "18:30-19:50", - "20:00-21:20" - ]; - - const auditoriums = [ - "201", "202", "204", "205", "206", "207", "208" - ]; - - // Mock schedule data mapped by room and time - // Key: "roomId_timeSlotId", Value: Lesson object - const mockSchedule = { - "201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." }, - "201_9:40-11:10": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." }, - "201_11:40-13:10": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." }, - "201_13:20-14:50": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." }, - - "202_9:40-11:10": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." }, - "202_13:20-14:50": { subject: "Математика", group: "ИВТ-21-1", teacher: "Рутина Л.П." }, - "202_15:00-16:30": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." }, - "202_16:50-18:20": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." }, - - "205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." }, - }; - - // Render Headers - const headerRow = document.getElementById('workload-header-row'); - // Start after the first fixed cell (which is already in HTML) - - auditoriums.forEach(room => { - const th = document.createElement('th'); - th.textContent = room; - headerRow.appendChild(th); - }); - - // Render Body Rows - const tbody = document.getElementById('workload-tbody'); - - timeslots.forEach((time) => { - const tr = document.createElement('tr'); - - // Add Time Cell - const tdTime = document.createElement('td'); - tdTime.className = 'time-cell'; - tdTime.textContent = time; - tr.appendChild(tdTime); - - // Add Room Cells for this Time - auditoriums.forEach(room => { - const td = document.createElement('td'); - - const scheduleKey = `${room}_${time}`; - const lesson = mockSchedule[scheduleKey]; - - if (lesson) { - // Render lesson card - td.innerHTML = ` -
-
${lesson.subject}
-
${lesson.group}
-
${lesson.teacher}
-
- `; - } - - tr.appendChild(td); + function bindEvents() { + refreshButton?.addEventListener('click', loadCurrentView); + dateInput?.addEventListener('change', loadCurrentView); + targetSelect?.addEventListener('change', () => { + currentTarget = targetSelect.value || TARGET_CLASSROOMS; + currentView = DISPLAY_ALL; + selectedRoomId = null; + selectedEntityId = null; + populateDisplayOptions(); + applyDisplaySelection(); + syncDisplayMode(); + loadCurrentView(); + }); + displaySelect?.addEventListener('change', () => { + applyDisplaySelection(); + syncDisplayMode(); + loadCurrentView(); }); - tbody.appendChild(tr); - }); + [ + ['building-checkboxes', 'building-text', 'Все корпуса'], + ['capacity-checkboxes', 'capacity-text', 'Любая вместимость'], + ['equipment-checkboxes', 'equipment-text', 'Любое оборудование'] + ].forEach(([containerId, textId, emptyText]) => { + const container = document.getElementById(containerId); + container?.addEventListener('change', () => { + updateFilterText(containerId, textId, emptyText); + if (currentView === DISPLAY_ALL) { + renderGrid(); + } + }); + }); + } + + async function loadInitialData() { + setTableLoading('Загрузка справочников и расписания...'); + try { + const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears, loadedTeachers, loadedDepartments] = await Promise.all([ + api.get('/api/classrooms'), + api.get('/api/equipments'), + api.get('/api/groups'), + api.get('/api/admin/calendar/years').catch(() => []), + api.get('/api/users/teachers'), + api.get('/api/departments') + ]); + classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name)); + teachers = [...loadedTeachers].sort((a, b) => naturalCompare(userName(a), userName(b))); + departments = [...loadedDepartments].sort((a, b) => naturalCompare(departmentName(a), departmentName(b))); + equipments = loadedEquipments; + groups = loadedGroups; + semesters = (academicYears || []).flatMap(year => year.semesters || []); + currentTarget = targetSelect?.value || TARGET_CLASSROOMS; + populateFilters(); + populateDisplayOptions(); + applyDisplaySelection(); + syncDisplayMode(); + await loadCurrentView(); + } catch (error) { + const message = error.message || 'Ошибка загрузки данных загруженности'; + setTableError(message); + setRoomError(message); + } + } + + async function loadCurrentView() { + applyDisplaySelection(); + syncDisplayMode(); + try { + if (currentView === DISPLAY_ALL) { + await loadScheduleForSelectedDate(); + } else { + await loadSelectedEntityWorkload(); + } + } catch (error) { + const message = error.message || 'Ошибка загрузки загруженности'; + if (currentView === DISPLAY_ALL) { + setTableError(message); + } else { + setRoomError(message); + } + } + } + + async function loadScheduleForSelectedDate() { + const date = dateInput?.value; + if (!date) { + showAlert('workload-alert', 'Выберите дату', 'error'); + return; + } + + hideAlert('workload-alert'); + failedGroupLoads = 0; + setTableLoading('Загрузка расписания на выбранную дату...'); + timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`); + + if (!groups.length) { + lessonsByEntitySlot = new Map(); + renderGrid(); + showAlert('workload-alert', 'Группы не созданы, загруженность пуста', 'error'); + return; + } + + const responses = await Promise.allSettled( + groups.map(group => api.get(`/api/schedule?${scheduleParams(group, date, date)}`)) + ); + failedGroupLoads = responses.filter(response => response.status === 'rejected').length; + const lessons = responses + .filter(response => response.status === 'fulfilled') + .flatMap(response => response.value || []); + + lessonsByEntitySlot = groupLessonsByEntitySlot(lessons); + renderGrid(); + + if (failedGroupLoads > 0) { + showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error'); + } + } + + async function loadSelectedEntityWorkload() { + const date = dateInput?.value; + const entity = selectedEntity(); + const target = targetConfig(); + if (!date) { + showAlert('workload-alert', 'Выберите дату', 'error'); + setRoomError('Выберите дату для расчёта двухнедельного периода'); + return; + } + if (!entity) { + showAlert('workload-alert', target.selectError, 'error'); + setRoomError(`${target.selectError} в поле «Отображение»`); + return; + } + + hideAlert('workload-alert'); + setRoomLoading('Загрузка расписания на две недели...'); + const range = twoWeekRange(date); + const dates = datesBetween(range.startDate, range.endDate); + const [slotLoad, lessonLoad] = await Promise.all([ + loadSlotsForDates(dates), + loadEntityLessons(range, entity.id) + ]); + + renderEntityWorkload(entity, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, { + failedSlotLoads: slotLoad.failedLoads, + failedGroupLoads: lessonLoad.failedLoads, + parityLessons: lessonLoad.parityLessons + }); + + const warnings = []; + if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`); + if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`); + if (warnings.length) { + showAlert('workload-alert', `Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`, 'error'); + } + } + + async function loadSlotsForDates(dates) { + const responses = await Promise.allSettled( + dates.map(date => api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`)) + ); + const slotsByDate = new Map(); + responses.forEach((response, index) => { + const slots = response.status === 'fulfilled' ? response.value || [] : []; + slotsByDate.set(dates[index], [...slots].sort(compareSlots)); + }); + return { + slotsByDate, + failedLoads: responses.filter(response => response.status === 'rejected').length + }; + } + + async function loadEntityLessons(range, entityId) { + if (!groups.length) { + return { + lessons: [], + lessonsByDateSlot: new Map(), + parityLessons: [], + failedLoads: 0 + }; + } + + const responses = await Promise.allSettled( + groups.map(group => api.get(`/api/schedule?${scheduleParams(group, range.startDate, range.endDate)}`)) + ); + const parityLessons = responses + .filter(response => response.status === 'fulfilled') + .flatMap(response => response.value || []); + const lessons = uniqueLessons(parityLessons) + .filter(lesson => entityIdsForLesson(lesson).some(id => sameId(id, entityId))); + const lessonsByDateSlot = new Map(); + lessons.forEach(lesson => { + const slotKey = lessonSlotKey(lesson); + if (!lesson.date || slotKey == null) return; + const key = dateSlotKey(lesson.date, slotKey); + if (!lessonsByDateSlot.has(key)) lessonsByDateSlot.set(key, []); + lessonsByDateSlot.get(key).push(lesson); + }); + lessonsByDateSlot.forEach(items => { + items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); + }); + + return { + lessons, + lessonsByDateSlot, + parityLessons, + failedLoads: responses.filter(response => response.status === 'rejected').length + }; + } + + function scheduleParams(group, startDate, endDate) { + return new URLSearchParams({ + groupId: String(group.id), + startDate, + endDate + }).toString(); + } + + function populateFilters() { + renderCheckboxes( + 'building-checkboxes', + buildingOptions(), + item => item.value, + item => item.label + ); + renderCheckboxes( + 'capacity-checkboxes', + CAPACITY_BUCKETS, + item => item.id, + item => item.name + ); + renderCheckboxes( + 'equipment-checkboxes', + equipmentOptions(), + item => item.id, + item => item.name + ); + + updateFilterText('building-checkboxes', 'building-text', 'Все корпуса'); + updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость'); + updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование'); + } + + function populateDisplayOptions() { + if (!displaySelect) return; + const selectedValue = displaySelect.value; + const target = targetConfig(); + displaySelect.innerHTML = ''; + displaySelect.append(new Option(target.allLabel, DISPLAY_ALL)); + allEntities().forEach(entity => { + displaySelect.append(new Option(entityOptionLabel(entity), `${target.prefix}${entity.id}`)); + }); + if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) { + displaySelect.value = selectedValue; + } + } + + function applyDisplaySelection() { + const value = displaySelect?.value || DISPLAY_ALL; + selectedRoomId = null; + selectedEntityId = null; + const target = targetConfig(); + if (value.startsWith(target.prefix)) { + currentView = 'detail'; + selectedEntityId = value.slice(target.prefix.length); + if (currentTarget === TARGET_CLASSROOMS) { + selectedRoomId = selectedEntityId; + } + return; + } + currentView = DISPLAY_ALL; + } + + function syncDisplayMode() { + const roomMode = currentView !== DISPLAY_ALL; + overviewFilters.forEach(filter => { + filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS; + }); + if (overviewBlock) overviewBlock.hidden = roomMode; + if (roomBlock) roomBlock.hidden = !roomMode; + if (dateLabel) { + dateLabel.textContent = roomMode ? 'Дата в периоде' : 'Дата'; + } + } + + function selectedEntity() { + const id = selectedEntityId || selectedRoomId; + if (!id) return null; + return allEntities().find(entity => sameId(entity.id, id)) || null; + } + + function targetConfig() { + return WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS]; + } + + function allEntities() { + if (currentTarget === TARGET_TEACHERS) return teachers; + if (currentTarget === TARGET_DEPARTMENTS) return departments; + return classrooms; + } + + function visibleEntities() { + const source = currentTarget === TARGET_CLASSROOMS ? filteredClassrooms() : allEntities(); + if (!selectedEntityId) return source; + return source.filter(entity => sameId(entity.id, selectedEntityId)); + } + + function entityOptionLabel(entity) { + if (currentTarget === TARGET_TEACHERS) { + const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', '); + return details ? `${userName(entity)} · ${details}` : userName(entity); + } + if (currentTarget === TARGET_DEPARTMENTS) { + const code = entity.departmentCode ? `код ${entity.departmentCode}` : ''; + return code ? `${departmentName(entity)} · ${code}` : departmentName(entity); + } + const details = [entity.building, entity.floor ? `${entity.floor} этаж` : null].filter(Boolean).join(', '); + return details ? `${entity.name} · ${details}` : entity.name; + } + + function buildingOptions() { + const values = new Map(); + classrooms.forEach(room => { + const key = buildingKey(room); + values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building); + }); + return Array.from(values.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => naturalCompare(a.label, b.label)); + } + + function equipmentOptions() { + const source = equipments.length + ? equipments + : classrooms.flatMap(room => room.equipments || []); + const values = new Map(); + source.forEach(item => { + if (item?.id && item?.name) values.set(String(item.id), item.name); + }); + return Array.from(values.entries()) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => naturalCompare(a.name, b.name)); + } + + function renderCheckboxes(containerId, items, valueFn, labelFn) { + const container = document.getElementById(containerId); + if (!container) return; + if (!items.length) { + container.innerHTML = '
Нет данных
'; + return; + } + container.innerHTML = items.map(item => ` + + `).join(''); + } + + function uniqueLessons(lessons) { + const unique = new Map(); + lessons.forEach(lesson => { + if (!lesson.timeSlotId) return; + const key = [ + lesson.date, + lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', + lesson.classroomId, + lesson.timeSlotId, + lesson.subjectId || '', + lesson.teacherId || '' + ].join(':'); + const existing = unique.get(key); + if (existing) { + existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames); + existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds); + existing.subgroupNames = mergeNames(existing.subgroupNames, lesson.subgroupNames); + existing.subgroupIds = mergeIds(existing.subgroupIds, lesson.subgroupIds); + return; + } + unique.set(key, { + ...lesson, + groupNames: [...(lesson.groupNames || [])], + groupIds: [...(lesson.groupIds || [])], + subgroupNames: [...(lesson.subgroupNames || [])], + subgroupIds: [...(lesson.subgroupIds || [])] + }); + }); + return Array.from(unique.values()); + } + + function groupLessonsByEntitySlot(lessons) { + const grouped = new Map(); + uniqueLessons(lessons).forEach(lesson => { + entityIdsForLesson(lesson).forEach(entityId => { + const key = entitySlotKey(entityId, lesson.timeSlotId); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(lesson); + }); + }); + grouped.forEach(items => { + items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); + }); + return grouped; + } + + function entityIdsForLesson(lesson) { + if (currentTarget === TARGET_TEACHERS) { + return lesson.teacherId ? [lesson.teacherId] : []; + } + if (currentTarget === TARGET_DEPARTMENTS) { + return departmentIdsForLesson(lesson); + } + return lesson.classroomId ? [lesson.classroomId] : []; + } + + function departmentIdsForLesson(lesson) { + const departmentId = teacherDepartmentId(lesson.teacherId); + return departmentId == null ? [] : [departmentId]; + } + + function renderGrid() { + const rows = visibleEntities(); + const target = targetConfig(); + renderHeader(); + tbody.innerHTML = ''; + + if (!rows.length) { + tbody.innerHTML = `${escapeHtml(target.emptyLabel)}`; + renderSummary(0, 0, 0); + return; + } + + rows.forEach(entity => { + const tr = document.createElement('tr'); + const entityCell = document.createElement('td'); + entityCell.className = `axis-cell${currentTarget === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`; + entityCell.innerHTML = entityAxisHtml(entity); + tr.appendChild(entityCell); + + timeSlots.forEach(slot => { + const td = document.createElement('td'); + const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []; + td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell'; + td.innerHTML = lessons.length + ? lessons.map(renderLessonCard).join('') + : 'Свободно'; + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + + const occupiedCells = countOccupiedCells(rows); + renderSummary(rows.length, occupiedCells, rows.length * timeSlots.length); + } + + function renderHeader() { + const target = targetConfig(); + headerRow.innerHTML = ` + + Время + ${escapeHtml(target.rowLabel)} + + `; + + timeSlots.forEach(slot => { + const th = document.createElement('th'); + th.innerHTML = ` + ${escapeHtml(slot.orderNumber || '')} пара + ${escapeHtml(timeSlotLabel(slot))} + ${slot.scopeApplyMode === 'DEFAULT' ? '' : `${escapeHtml(scopeShort(slot))}`} + `; + headerRow.appendChild(th); + }); + } + + function entityAxisHtml(entity) { + if (currentTarget === TARGET_TEACHERS) { + return ` + ${escapeHtml(userName(entity))} + ${escapeHtml(entity.jobTitle || 'Должность не указана')} + ${entity.departmentName ? `${escapeHtml(entity.departmentName)}` : ''} + `; + } + if (currentTarget === TARGET_DEPARTMENTS) { + return ` + ${escapeHtml(departmentName(entity))} + ${entity.departmentCode ? `Код: ${escapeHtml(entity.departmentCode)}` : 'Код не указан'} + `; + } + return ` + ${escapeHtml(entity.name)} + ${entity.capacity ? `${escapeHtml(entity.capacity)} мест` : 'Вместимость не указана'} + ${entity.building ? `${escapeHtml(entity.building)}${entity.floor ? `, ${escapeHtml(entity.floor)} этаж` : ''}` : ''} + `; + } + + function entityName(entity) { + if (currentTarget === TARGET_TEACHERS) return userName(entity); + if (currentTarget === TARGET_DEPARTMENTS) return departmentName(entity); + return entity?.name || 'Аудитория без названия'; + } + + function entitySummaryDetails(entity) { + if (currentTarget === TARGET_TEACHERS) { + return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', '); + } + if (currentTarget === TARGET_DEPARTMENTS) { + return entity.departmentCode ? `код ${entity.departmentCode}` : ''; + } + return [ + entity.capacity ? `${entity.capacity} мест` : null, + entity.building || null, + entity.floor ? `${entity.floor} этаж` : null + ].filter(Boolean).join(', '); + } + + function renderEntityWorkload(entity, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) { + const slotRows = buildSlotRows(dates, slotsByDate, lessons); + const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons); + renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates); + + if (!slotRows.length) { + roomTables.innerHTML = '
За выбранный период нет временных слотов
'; + return; + } + + roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot); + } + + function renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) { + if (!roomSummary) return; + const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot); + const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot); + const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; + const details = entitySummaryDetails(entity); + const warnings = []; + if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`); + if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`); + if (unknownParityDates) warnings.push('часть дат вне семестра'); + + roomSummary.innerHTML = ` +
+ ${escapeHtml(entityName(entity))} + ${details ? `${escapeHtml(details)}` : ''} +
+ ${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))} + Занято: ${occupiedCells} из ${totalCells} (${percent}%) + ${warnings.length ? `${escapeHtml(warnings.join(', '))}` : ''} + `; + } + + function renderRoomCombinedWeekTable(datesByDay, slotRows, slotsByDate, lessonsByDateSlot) { + const dayHeaders = DAY_ORDER.map(day => ` + + ${escapeHtml(dayShort(day))} + ${escapeHtml(DAY_FULL_LABELS[day] || '')} +
+ ${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(datesByDay[day]?.[parity], label)).join('')} +
+ + `).join(''); + const rows = slotRows.map(row => ` + + + ${escapeHtml(row.orderLabel)} + ${escapeHtml(row.timeLabel)} + + ${DAY_ORDER.map(day => renderRoomCombinedSlotCell(datesByDay[day] || {}, row, slotsByDate, lessonsByDateSlot)).join('')} + + `).join(''); + + return ` +
+
+

${escapeHtml(targetConfig().periodTitle)}

+ ${escapeHtml(roomWeekCaption(datesByDay))} +
+
+ + + + + ${dayHeaders} + + + + ${rows} + +
Время
+
+
+ `; + } + + function renderDayParityDate(date, label) { + return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; + } + + function renderRoomCombinedSlotCell(datesByParity, row, slotsByDate, lessonsByDateSlot) { + const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ + parity, + label, + state: roomSlotState(datesByParity[parity], row, slotsByDate, lessonsByDateSlot) + })); + + if (roomSlotStatesEqual(states[0].state, states[1].state)) { + return `${renderRoomUnifiedSlot(states[0].state)}`; + } + + const halves = states.map(({ parity, label, state }) => + renderRoomSlotHalf(state, parity, label) + ).join(''); + return `${halves}`; + } + + function roomSlotState(date, row, slotsByDate, lessonsByDateSlot) { + if (!date) { + return { kind: 'empty', lessons: [] }; + } + + const key = dateSlotKey(date, row.key); + const lessons = lessonsByDateSlot.get(key) || []; + const slotExists = hasSlotForDate(date, row.key, slotsByDate) || lessons.length > 0; + if (lessons.length) { + return { kind: 'busy', lessons }; + } + if (!slotExists) { + return { kind: 'missing', lessons: [] }; + } + return { kind: 'free', lessons: [] }; + } + + function renderRoomUnifiedSlot(state) { + if (state.kind === 'busy') { + return `
${state.lessons.map(renderLessonCard).join('')}
`; + } + if (state.kind === 'missing') { + return '
Нет слота
'; + } + if (state.kind === 'empty') { + return '
Нет даты
'; + } + return '
Свободно
'; + } + + function renderRoomSlotHalf(state, parity, label) { + if (state.kind === 'busy') { + return ` +
+ ${escapeHtml(label)} + ${state.lessons.map(renderLessonCard).join('')} +
+ `; + } + if (state.kind === 'missing') { + return ` +
+ ${escapeHtml(label)} + Нет слота +
+ `; + } + if (state.kind === 'empty') { + return ` +
+ ${escapeHtml(label)} + Нет даты +
+ `; + } + return ` +
+ ${escapeHtml(label)} + Свободно +
+ `; + } + + function roomSlotStatesEqual(first, second) { + if (first.kind !== second.kind) return false; + if (first.kind !== 'busy') return true; + return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons); + } + + function lessonSetSignature(lessons) { + return lessons + .map(lessonContentSignature) + .sort((a, b) => naturalCompare(a, b)) + .join('|'); + } + + function lessonContentSignature(lesson) { + return [ + lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', + lesson.subjectId || lesson.subjectName || '', + lesson.teacherId || lesson.teacherName || '', + lesson.lessonTypeId || lesson.lessonTypeName || '', + lesson.lessonFormat || '', + valueListSignature(lesson.groupIds, lesson.groupNames), + valueListSignature(lesson.subgroupIds, lesson.subgroupNames) + ].join(':'); + } + + function valueListSignature(primary = [], fallback = []) { + const primaryList = Array.isArray(primary) ? primary : []; + const fallbackList = Array.isArray(fallback) ? fallback : []; + const source = primaryList.length ? primaryList : fallbackList; + return [...source] + .map(value => String(value)) + .sort((a, b) => naturalCompare(a, b)) + .join(','); + } + + function buildSlotRows(dates, slotsByDate, lessons) { + const rows = new Map(); + dates.forEach(date => { + (slotsByDate.get(date) || []).forEach(slot => { + const key = slotOrderKey(slot); + if (key == null) return; + putSlotRow(rows, key, slot.orderNumber, slot.startTime, slot.endTime); + }); + }); + lessons.forEach(lesson => { + const key = lessonSlotKey(lesson); + if (key == null) return; + putSlotRow(rows, key, lesson.timeSlotOrder, lesson.startTime, lesson.endTime); + }); + return Array.from(rows.values()).sort(compareSlotRows); + } + + function putSlotRow(rows, key, orderNumber, startTime, endTime) { + const existing = rows.get(key); + const next = { + key, + orderNumber, + startTime, + endTime, + orderLabel: orderNumber ? `${orderNumber} пара` : 'Пара', + timeLabel: startTime || endTime ? `${trimTime(startTime)}-${trimTime(endTime)}` : 'Время не указано' + }; + if (!existing || existing.timeLabel === 'Время не указано') { + rows.set(key, next); + } + } + + function buildDateGridByDay(dates, lessons) { + const datesByDay = {}; + let unknownParityDates = 0; + dates.forEach((date, index) => { + let parity = parityForDate(date, lessons); + if (!parity) { + unknownParityDates += 1; + parity = index < 7 ? 'ODD' : 'EVEN'; + } + const day = isoDay(date); + if (!datesByDay[day]) datesByDay[day] = {}; + datesByDay[day][parity] = date; + }); + return { datesByDay, unknownParityDates }; + } + + function parityForDate(date, lessons) { + const lessonParity = lessons.find(lesson => lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity))?.parity; + if (lessonParity) return lessonParity; + const weekNumber = weekNumberForDate(date); + if (!weekNumber) return null; + return weekNumber % 2 === 0 ? 'EVEN' : 'ODD'; + } + + function weekNumberForDate(date) { + const semester = semesters.find(item => item.startDate <= date && item.endDate >= date); + if (!semester) return null; + const days = Math.floor((parseIsoDate(date) - parseIsoDate(semester.startDate)) / MS_PER_DAY); + return Math.floor(days / 7) + 1; + } + + function uniqueWeekNumbers(dates) { + return Array.from(new Set(dates.map(weekNumberForDate).filter(Boolean))) + .sort((a, b) => a - b); + } + + function roomWeekCaption(datesByDay) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean); + const weeks = uniqueWeekNumbers(dates); + const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра'; + const positionText = parity === 'ODD' ? 'сверху' : 'снизу'; + return `${label} ${positionText}, ${weekText}`; + }) + .join('; '); + } + + function hasSlotForDate(date, key, slotsByDate) { + return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key); + } + + function renderLessonCard(lesson) { + const subgroupText = (lesson.subgroupNames || []).join(', '); + return ` +
+
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
+
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
+ ${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''} +
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
+
${escapeHtml([lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}
+
+ `; + } + + function filteredClassrooms() { + const selectedBuildings = selectedValues('building-checkboxes'); + const selectedCapacities = selectedValues('capacity-checkboxes'); + const selectedEquipment = selectedValues('equipment-checkboxes'); + + return classrooms.filter(room => { + const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room)); + const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS + .filter(bucket => selectedCapacities.includes(bucket.id)) + .some(bucket => bucket.match(room.capacity)); + const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id => + (room.equipments || []).some(item => String(item.id) === String(id)) + ); + return buildingMatches && capacityMatches && equipmentMatches; + }); + } + + function selectedValues(containerId) { + const container = document.getElementById(containerId); + if (!container) return []; + return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) + .map(input => input.value); + } + + function updateFilterText(containerId, textId, emptyText) { + const container = document.getElementById(containerId); + const text = document.getElementById(textId); + if (!container || !text) return; + const checked = Array.from(container.querySelectorAll('input[type="checkbox"]:checked')); + if (!checked.length) { + text.textContent = emptyText; + } else if (checked.length === 1) { + text.textContent = checked[0].closest('.checkbox-item')?.textContent.trim() || emptyText; + } else { + text.textContent = `Выбрано: ${checked.length}`; + } + } + + function setTableLoading(message) { + renderHeader(); + tbody.innerHTML = `${escapeHtml(message)}`; + if (summary) summary.textContent = ''; + } + + function setTableError(message) { + renderHeader(); + tbody.innerHTML = `${escapeHtml(message)}`; + showAlert('workload-alert', message, 'error'); + } + + function setRoomLoading(message) { + if (roomSummary) roomSummary.textContent = ''; + if (roomTables) roomTables.innerHTML = `
${escapeHtml(message)}
`; + } + + function setRoomError(message) { + if (roomSummary) roomSummary.textContent = ''; + if (roomTables) roomTables.innerHTML = `
${escapeHtml(message)}
`; + showAlert('workload-alert', message, 'error'); + } + + function renderSummary(entityCount, occupiedCells, totalCells) { + if (!summary) return; + const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; + const date = dateInput?.value ? formatDate(dateInput.value) : '-'; + const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-'; + const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : ''; + const selected = selectedEntityId ? ', выбран один объект' : ''; + summary.textContent = `Дата: ${date}, ${day}. ${targetConfig().summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`; + } + + function countOccupiedCells(entities) { + return entities.reduce((count, entity) => count + timeSlots.filter(slot => + (lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0 + ).length, 0); + } + + function countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot) { + return dates.reduce((count, date) => { + const keys = new Set((slotsByDate.get(date) || []).map(slotOrderKey).filter(key => key != null)); + lessonsByDateSlot.forEach((items, key) => { + if (items.length && key.startsWith(`${date}:`)) { + keys.add(key.slice(date.length + 1)); + } + }); + return count + keys.size; + }, 0); + } + + function countOccupiedRoomCells(lessonsByDateSlot) { + return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length; + } + + function entitySlotKey(entityId, timeSlotId) { + return `${entityId}:${timeSlotId}`; + } + + function dateSlotKey(date, slotKey) { + return `${date}:${slotKey}`; + } + + function slotOrderKey(slot) { + const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id; + return value == null ? null : String(value); + } + + function lessonSlotKey(lesson) { + const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId; + return value == null ? null : String(value); + } + + function buildingKey(room) { + return room.building ? String(room.building) : NO_BUILDING; + } + + function teacherDepartmentId(teacherId) { + const teacher = teachers.find(item => sameId(item.id, teacherId)); + if (!teacher) return null; + if (teacher.departmentId != null) return teacher.departmentId; + if (!teacher.departmentName) return null; + return departments.find(department => departmentName(department) === teacher.departmentName)?.id ?? null; + } + + function userName(user) { + return user?.fullName || user?.username || 'Преподаватель без имени'; + } + + function departmentName(department) { + return department?.departmentName || department?.name || 'Кафедра без названия'; + } + + function mergeNames(first = [], second = []) { + return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b)); + } + + function mergeIds(first = [], second = []) { + return Array.from(new Set([...first, ...second].filter(id => id != null))); + } + + function timeSlotLabel(slot) { + return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`; + } + + function selectedIsoDay() { + if (!dateInput?.value) return null; + return isoDay(dateInput.value); + } + + function scopeShort(slot) { + if (slot.scopeApplyMode === 'WEEKDAY') { + return dayShort(slot.scopeDayOfWeek); + } + return 'Ручн.'; + } + + function dayShort(dayOfWeek) { + return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; + } + + function isoDay(value) { + const day = parseIsoDate(value).getDay(); + return day === 0 ? 7 : day; + } + + function twoWeekRange(value) { + const selected = parseIsoDate(value); + const start = addDays(selected, 1 - isoDay(value)); + const end = addDays(start, 13); + return { + startDate: formatIsoDate(start), + endDate: formatIsoDate(end) + }; + } + + function datesBetween(startDate, endDate) { + const dates = []; + for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) { + dates.push(formatIsoDate(date)); + } + return dates; + } + + function parseIsoDate(value) { + const [year, month, day] = String(value).split('-').map(Number); + return new Date(year, month - 1, day); + } + + function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; + } + + function trimTime(value) { + return value ? String(value).slice(0, 5) : ''; + } + + function formatIsoDate(date) { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); + } + + function formatDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; + } + + function formatDateShort(value) { + const [, month, day] = String(value).split('-'); + return `${day}.${month}`; + } + + function compareSlots(a, b) { + return Number(a.orderNumber || 0) - Number(b.orderNumber || 0) + || naturalCompare(a.startTime, b.startTime); + } + + function compareSlotRows(a, b) { + const first = Number(a.orderNumber || a.key); + const second = Number(b.orderNumber || b.key); + if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) { + return first - second; + } + return naturalCompare(a.startTime, b.startTime) + || naturalCompare(a.key, b.key); + } + + function sameId(first, second) { + return String(first) === String(second); + } + + function naturalCompare(a, b) { + return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); + } } diff --git a/frontend/admin/js/views/classrooms.js b/frontend/admin/js/views/classrooms.js index 419aa2e..3353bff 100755 --- a/frontend/admin/js/views/classrooms.js +++ b/frontend/admin/js/views/classrooms.js @@ -31,7 +31,7 @@ export async function initClassrooms() { async function loadClassrooms() { try { - const classrooms = await api.get('/api/classrooms'); + const classrooms = await api.get('/api/classrooms?includeArchived=true'); renderClassrooms(classrooms); } catch (e) { classroomsTbody.innerHTML = 'Ошибка загрузки'; @@ -57,7 +57,7 @@ export async function initClassrooms() {
- ${c.isAvailable ? 'Доступна' : 'Не доступна'} + ${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')} - + ${c.status === 'ARCHIVED' + ? `` + : ``} `; }).join(''); @@ -100,11 +102,19 @@ export async function initClassrooms() { const btnEdit = e.target.closest('.btn-edit-classroom'); if (btnDelete) { - if (!confirm('Удалить аудиторию?')) return; + if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return; try { await api.delete('/api/classrooms/' + btnDelete.dataset.id); loadClassrooms(); - } catch (err) { alert('Ошибка удаления'); } + } catch (err) { alert('Ошибка архивирования'); } + } + + const btnRestore = e.target.closest('.btn-restore-classroom'); + if (btnRestore) { + try { + await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {}); + loadClassrooms(); + } catch (err) { alert('Ошибка восстановления аудитории'); } } if (btnToggleStatus) { @@ -124,7 +134,7 @@ export async function initClassrooms() { async function openEditClassroomModal(id) { try { // Can optimize by using already loaded classrooms, but fetch is safer to get fresh data - const classrooms = await api.get('/api/classrooms'); + const classrooms = await api.get('/api/classrooms?includeArchived=true'); editingClassroomData = classrooms.find(c => c.id == id); if (!editingClassroomData) return; diff --git a/frontend/admin/js/views/department-workspace.js b/frontend/admin/js/views/department-workspace.js new file mode 100644 index 0000000..36ebb97 --- /dev/null +++ b/frontend/admin/js/views/department-workspace.js @@ -0,0 +1,263 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +const ROLE_DEPARTMENT = 'DEPARTMENT'; + +export async function initDepartmentWorkspace() { + const role = localStorage.getItem('role'); + const userDepartmentId = localStorage.getItem('departmentId'); + const departmentField = document.getElementById('department-workspace-department-field'); + const departmentSelect = document.getElementById('department-workspace-department'); + const startInput = document.getElementById('department-workspace-start'); + const endInput = document.getElementById('department-workspace-end'); + const refreshButton = document.getElementById('department-workspace-refresh'); + const importForm = document.getElementById('department-subject-import-form'); + const subjectsTbody = document.getElementById('department-subjects-tbody'); + const subjectCount = document.getElementById('department-subject-count'); + const commentsCard = document.getElementById('department-comments-card'); + const commentsTitle = document.getElementById('department-comments-title'); + const commentsList = document.getElementById('department-comment-list'); + const commentsForm = document.getElementById('department-comment-form'); + const commentsSubjectInput = document.getElementById('department-comment-subject-id'); + const commentsTextInput = document.getElementById('department-comment-text'); + const commentsCloseButton = document.getElementById('department-comments-close'); + + setDefaultDates(startInput, endInput); + refreshButton?.addEventListener('click', loadWorkspace); + departmentSelect?.addEventListener('change', loadWorkspace); + startInput?.addEventListener('change', loadWorkload); + endInput?.addEventListener('change', loadWorkload); + importForm?.addEventListener('submit', importSubjects); + commentsForm?.addEventListener('submit', saveComment); + commentsCloseButton?.addEventListener('click', () => { + commentsCard.hidden = true; + commentsSubjectInput.value = ''; + }); + subjectsTbody?.addEventListener('click', event => { + const button = event.target.closest('.department-comments-open'); + if (button) { + openComments(button.dataset.subjectId, button.dataset.subjectName); + } + }); + + try { + await loadDepartments(role, userDepartmentId); + if (role === ROLE_DEPARTMENT) { + departmentField.hidden = true; + departmentSelect.value = userDepartmentId || ''; + } + await loadWorkspace(); + } catch (error) { + showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error'); + } + + async function loadWorkspace() { + hideAlert('department-workspace-alert'); + await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]); + } + + async function loadSubjects() { + const departmentId = selectedDepartmentId(); + subjectsTbody.innerHTML = 'Загрузка...'; + subjectCount.textContent = 'Загрузка...'; + try { + const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`); + subjectCount.textContent = subjectCountLabel(subjects.length); + if (!subjects.length) { + subjectsTbody.innerHTML = 'Дисциплины не найдены'; + return; + } + subjectsTbody.innerHTML = subjects.map(subject => ` + + ${escapeHtml(subject.code || '-')} + ${escapeHtml(subject.name || '-')} + ${escapeHtml(subject.description || '-')} + + + + + `).join(''); + } catch (error) { + subjectsTbody.innerHTML = `${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}`; + subjectCount.textContent = '0 дисциплин'; + } + } + + async function importSubjects(event) { + event.preventDefault(); + const alertId = 'department-subject-import-alert'; + hideAlert(alertId); + const departmentId = selectedDepartmentId(); + const rows = document.getElementById('department-subject-import-text').value + .split('\n') + .map(row => row.trim()) + .filter(Boolean) + .map(row => { + const [code, ...nameParts] = row.split(';'); + return { + code: (code || '').trim(), + name: nameParts.join(';').trim(), + departmentId: Number(departmentId) + }; + }) + .filter(item => item.name); + + if (!departmentId) { + showAlert(alertId, 'Выберите кафедру', 'error'); + return; + } + if (!rows.length) { + showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error'); + return; + } + + try { + const result = await api.post('/api/department/subjects/import', rows); + showAlert(alertId, result.message || 'Дисциплины загружены', 'success'); + document.getElementById('department-subject-import-text').value = ''; + await loadSubjects(); + } catch (error) { + showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error'); + } + } + + async function openComments(subjectId, subjectName) { + commentsCard.hidden = false; + commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`; + commentsSubjectInput.value = subjectId; + commentsTextInput.value = ''; + hideAlert('department-comment-alert'); + commentsList.textContent = 'Загрузка...'; + + try { + const comments = await api.get(`/api/department/subjects/${subjectId}/comments`); + commentsList.innerHTML = comments.length + ? comments.map(comment => ` +
+ ${escapeHtml(comment.authorName || 'Автор не указан')} + ${escapeHtml(formatDateTime(comment.createdAt))} +

${escapeHtml(comment.comment)}

+
+ `).join('') + : '
Комментариев пока нет
'; + } catch (error) { + commentsList.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки комментариев')}
`; + } + } + + async function saveComment(event) { + event.preventDefault(); + const subjectId = commentsSubjectInput.value; + const comment = commentsTextInput.value.trim(); + if (!subjectId) { + showAlert('department-comment-alert', 'Выберите дисциплину', 'error'); + return; + } + if (!comment) { + showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error'); + return; + } + + try { + await api.post(`/api/department/subjects/${subjectId}/comments`, { comment }); + showAlert('department-comment-alert', 'Комментарий сохранён', 'success'); + await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', '')); + } catch (error) { + showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error'); + } + } + + async function loadTeachers() { + const container = document.getElementById('department-teachers-list'); + container.textContent = 'Загрузка...'; + try { + const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`); + container.innerHTML = teachers.length + ? teachers.map(teacher => ` +
+ ${escapeHtml(teacher.fullName || teacher.username || '-')} + ${escapeHtml(teacher.jobTitle || 'Должность не указана')} +
+ `).join('') + : '
Преподаватели не найдены
'; + } catch (error) { + container.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}
`; + } + } + + async function loadWorkload() { + const container = document.getElementById('department-workload-list'); + const params = new URLSearchParams({ + startDate: startInput.value, + endDate: endInput.value + }); + if (selectedDepartmentId()) { + params.set('departmentId', selectedDepartmentId()); + } + container.textContent = 'Загрузка...'; + try { + const workload = await api.get(`/api/workload/teachers?${params}`); + container.innerHTML = workload.length + ? workload.map(item => ` +
+ ${escapeHtml(item.name || '-')} + ${escapeHtml(String(item.academicHours ?? 0))} ак. ч. + ${escapeHtml(String(item.lessonCount ?? 0))} занятий +
+ `).join('') + : '
Нагрузка не найдена
'; + } catch (error) { + container.innerHTML = `
${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}
`; + } + } + + function selectedDepartmentId() { + return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value; + } +} + +async function loadDepartments(role, userDepartmentId) { + const departments = await api.get('/api/departments'); + fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру'); + if (role === ROLE_DEPARTMENT && userDepartmentId) { + document.getElementById('department-workspace-department').value = userDepartmentId; + } else if (!document.getElementById('department-workspace-department').value && departments.length) { + document.getElementById('department-workspace-department').value = departments[0].id; + } +} + +function fillSelect(id, items, valueFn, labelFn, emptyLabel) { + const select = document.getElementById(id); + if (!select) return; + select.innerHTML = `` + + (items || []).map(item => ``).join(''); +} + +function setDefaultDates(startInput, endInput) { + const today = new Date(); + const nextWeek = new Date(); + nextWeek.setDate(today.getDate() + 7); + startInput.value = toIsoDate(today); + endInput.value = toIsoDate(nextWeek); +} + +function toIsoDate(date) { + return date.toISOString().slice(0, 10); +} + +function formatDateTime(value) { + if (!value) return '-'; + return String(value).replace('T', ' ').slice(0, 16); +} + +function subjectCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return `${count} дисциплин`; + if (count % 10 === 1) return `${count} дисциплина`; + if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`; + return `${count} дисциплин`; +} diff --git a/frontend/admin/js/views/department.js b/frontend/admin/js/views/department.js deleted file mode 100644 index f1011c7..0000000 --- a/frontend/admin/js/views/department.js +++ /dev/null @@ -1,456 +0,0 @@ -import { api } from '../api.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; - -// Ключ для хранения данных в sessionStorage -const STORAGE_KEY = 'department_schedule_blocks'; - -export async function initDepartment() { - const form = document.getElementById('department-schedule-form'); - const departmentSelect = document.getElementById('filter-department'); - const container = document.getElementById('schedule-blocks-container'); - - let departments = []; - - // Загрузка кафедр - try { - departments = await api.get('/api/departments'); - departmentSelect.innerHTML = '' + - departments.map(d => ``).join(''); - } catch (e) { - departmentSelect.innerHTML = ''; - } - - // ===== Восстанавливаем ранее загруженные таблицы из sessionStorage ===== - restoreScheduleBlocks(); - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('schedule-form-alert'); - - const departmentId = departmentSelect.value; - const period = document.getElementById('filter-period').value; - const semesterType = document.querySelector('input[name="semesterType"]:checked')?.value; - - if (!departmentId || !period || !semesterType) { - showAlert('schedule-form-alert', 'Заполните все поля', 'error'); - return; - } - - const deptName = departmentSelect.options[departmentSelect.selectedIndex].text; - - try { - const params = new URLSearchParams({ departmentId, semesterType, period }); - const data = await api.get(`/api/department/schedule?${params.toString()}`); - - const semesterName = semesterType === 'spring' ? 'весенний' : (semesterType === 'autumn' ? 'осенний' : semesterType); - const periodName = period.replace('-', '/'); - - renderScheduleBlock(deptName, semesterName, periodName, data, departmentId, semesterType, period); - - // НЕ сбрасываем форму — фильтры остаются заполненными (fix #3) - - } catch (err) { - showAlert('schedule-form-alert', err.message || 'Ошибка загрузки данных', 'error'); - } - }); - - // ===== Уникальный ключ для таблицы по параметрам ===== - function blockKey(departmentId, semesterType, period) { - return `${departmentId}_${semesterType}_${period}`; - } - - // ===== Рендер блока таблицы (с дедупликацией — fix #6) ===== - function renderScheduleBlock(deptName, semester, period, schedule, departmentId, semesterType, rawPeriod) { - const key = blockKey(departmentId, semesterType, rawPeriod); - - // Удаляем ранее загруженный блок с тем же ключом - const existing = container.querySelector(`[data-block-key="${key}"]`); - if (existing) existing.remove(); - - const details = document.createElement('details'); - details.className = 'table-item'; - details.open = true; - details.setAttribute('data-block-key', key); - details.innerHTML = ` - - -
- Данные к составлению расписания - Кафедра: ${escapeHtml(deptName)} - Семестр: ${escapeHtml(semester)} - Уч. год: ${escapeHtml(period)} -
-
${Array.isArray(schedule) ? schedule.length : 0} записей
-
-
- - - - - - - - - - - - - - - ${renderRows(schedule)} - -
СпециальностьКурс/семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыПреподаватель
-
- `; - - container.prepend(details); - - // Сохраняем в sessionStorage - saveScheduleBlock(key, { deptName, semester, period, schedule, departmentId, semesterType, rawPeriod }); - } - - function renderRows(schedule) { - if (!Array.isArray(schedule) || schedule.length === 0) { - return 'Нет данных'; - } - return schedule.map(r => ` - - ${escapeHtml(r.specialityCode || '-')} - ${(() => { - const course = r.groupCourse || '-'; - const semester = r.semester || '-'; - if (course === '-' && semester === '-') return '-'; - return `${course} | ${semester}`; - })()} - ${escapeHtml(r.groupName || '-')} - ${escapeHtml(r.subjectName || '-')} - ${escapeHtml(r.lessonType || '-')} - ${escapeHtml(r.numberOfHours || '-')} - ${r.division === true ? '✓' : ''} - ${(() => { - const jobTitle = r.teacherJobTitle || '-'; - const teacherName = r.teacherName || '-'; - if (jobTitle === '-' && teacherName === '-') return '-'; - return `${jobTitle}, ${teacherName}`; - })()} - - `).join(''); - } - - // ===== Persistence: sessionStorage (fix #4) ===== - function saveScheduleBlock(key, blockData) { - try { - const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}'); - stored[key] = blockData; - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); - } catch (e) { - console.warn('Ошибка сохранения в sessionStorage:', e); - } - } - - function restoreScheduleBlocks() { - try { - const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}'); - const keys = Object.keys(stored); - if (keys.length === 0) return; - - keys.forEach(key => { - const b = stored[key]; - renderScheduleBlock(b.deptName, b.semester, b.period, b.schedule, b.departmentId, b.semesterType, b.rawPeriod); - }); - } catch (e) { - console.warn('Ошибка восстановления из sessionStorage:', e); - } - } - - // ========================================================= - // ЛОГИКА ДЛЯ ФУНКЦИОНАЛА "СОЗДАТЬ ЗАПИСЬ (К/Ф)" - // Два модальных окна поверх всего контента в одном оверлее - // ========================================================= - const btnCreateSchedule = document.getElementById('btn-create-schedule'); - const csOverlay = document.getElementById('cs-overlay'); - - const modalCreateSchedule = document.getElementById('modal-create-schedule'); - const modalCreateScheduleClose = document.getElementById('modal-create-schedule-close'); - const formCreateSchedule = document.getElementById('create-schedule-form'); - - const modalViewSchedules = document.getElementById('modal-view-schedules'); - const btnSaveSchedules = document.getElementById('btn-save-schedules'); - const preparedSchedulesTbody = document.getElementById('prepared-schedules-tbody'); - - const csGroupSelect = document.getElementById('cs-group'); - const csSubjectSelect = document.getElementById('cs-subject'); - const csTeacherSelect = document.getElementById('cs-teacher'); - const csDepartmentIdInput = document.getElementById('cs-department-id'); - - let preparedSchedules = []; - let csGroups = []; - let csSubjects = []; - let csTeachers = []; - - const SEMESTER_LABELS = { autumn: 'Осенний', spring: 'Весенний' }; - const LESSON_TYPE_LABELS = { 1: 'Лекция', 2: 'Практическая работа', 3: 'Лабораторная работа' }; - - const localDepartmentId = localStorage.getItem('departmentId'); - - // ===== Загрузка справочников ===== - async function loadDictionariesForSchedule() { - try { - csGroups = await api.get('/api/groups'); - csGroupSelect.innerHTML = '' + - csGroups.map(g => ``).join(''); - - csSubjects = await api.get('/api/subjects'); - csSubjectSelect.innerHTML = '' + - csSubjects.map(s => ``).join(''); - - // Загрузка преподавателей: сначала по кафедре, при ошибке — все преподаватели - csTeachers = []; - if (localDepartmentId) { - try { - csTeachers = await api.get(`/api/users/teachers/${localDepartmentId}`); - } catch (e) { - console.warn('Не удалось загрузить преподавателей для кафедры, загружаем всех:', e); - } - } - // Фолбэк: загружаем всех преподавателей - if (!Array.isArray(csTeachers) || csTeachers.length === 0) { - try { - csTeachers = await api.get('/api/users/teachers'); - } catch (e2) { - console.error('Ошибка загрузки всех преподавателей:', e2); - } - } - if (Array.isArray(csTeachers) && csTeachers.length > 0) { - csTeacherSelect.innerHTML = '' + - csTeachers.map(t => ``).join(''); - } else { - csTeacherSelect.innerHTML = ''; - } - } catch (e) { - console.error('Ошибка загрузки справочников:', e); - } - } - - loadDictionariesForSchedule(); - - // ===== Открытие / Закрытие оверлея ===== - function openOverlay() { - csOverlay.classList.add('open'); - document.body.style.overflow = 'hidden'; - } - - function closeOverlay() { - csOverlay.classList.remove('open'); - document.body.style.overflow = ''; - hideAlert('create-schedule-alert'); - hideAlert('save-schedules-alert'); - } - - function updateTableVisibility() { - modalViewSchedules.style.display = preparedSchedules.length > 0 ? '' : 'none'; - } - - // ===== Кнопка «Создать запись» ===== - btnCreateSchedule.addEventListener('click', () => { - if (localDepartmentId) { - csDepartmentIdInput.value = localDepartmentId; - } else { - showAlert('schedule-form-alert', 'Требуется перезайти (отсутствует ID кафедры)', 'error'); - return; - } - openOverlay(); - }); - - // ===== Закрытие ===== - modalCreateScheduleClose.addEventListener('click', closeOverlay); - - csOverlay.addEventListener('click', (e) => { - if (e.target === csOverlay || e.target.classList.contains('cs-overlay-scroll')) { - closeOverlay(); - } - }); - - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && csOverlay.classList.contains('open')) { - closeOverlay(); - } - }); - - // ===== Рендер таблицы подготовленных записей ===== - function renderPreparedSchedules() { - if (preparedSchedules.length === 0) { - preparedSchedulesTbody.innerHTML = 'Нет записей'; - return; - } - preparedSchedulesTbody.innerHTML = preparedSchedules.map((s, index) => { - const groupName = csGroups.find(g => g.id == s.groupId)?.name || s.groupId; - const subjectName = csSubjects.find(sub => sub.id == s.subjectsId)?.name || s.subjectsId; - const teacherName = csTeachers.find(t => t.id == s.teacherId)?.fullName - || csTeachers.find(t => t.id == s.teacherId)?.username || s.teacherId; - const lessonTypeName = LESSON_TYPE_LABELS[s.lessonTypeId] || 'Неизвестно'; - const semLabel = SEMESTER_LABELS[s.semesterType] || s.semesterType; - const periodDisplay = s.period.replace('-', '/'); - const divText = s.isDivision ? '✓' : ''; - const hasError = !!s._errorMsg; - const rowStyle = hasError ? ' style="background: rgba(239, 68, 68, 0.08);"' : ''; - let row = ` - - ${escapeHtml(periodDisplay)} - ${escapeHtml(semLabel)} - ${escapeHtml(String(groupName))} - ${escapeHtml(String(subjectName))} - ${escapeHtml(lessonTypeName)} - ${s.numberOfHours} - ${divText} - ${escapeHtml(String(teacherName))} - - `; - if (hasError) { - row += ` - - ⚠ ${escapeHtml(s._errorMsg)} - - `; - } - return row; - }).join(''); - } - - // ===== Удаление строки из таблицы ===== - preparedSchedulesTbody.addEventListener('click', (e) => { - if (e.target.classList.contains('btn-delete')) { - const idx = parseInt(e.target.getAttribute('data-index'), 10); - preparedSchedules.splice(idx, 1); - renderPreparedSchedules(); - updateTableVisibility(); - } - }); - - // ===== Очистка полей формы (частичная) ===== - function clearFormFields() { - document.getElementById('cs-hours').value = ''; - document.getElementById('cs-division').checked = false; - } - - // ===== Добавление записи в список ===== - formCreateSchedule.addEventListener('submit', (e) => { - e.preventDefault(); - hideAlert('create-schedule-alert'); - - const depId = csDepartmentIdInput.value; - const period = document.getElementById('cs-period').value; - const semesterType = document.querySelector('input[name="csSemesterType"]:checked')?.value; - const groupId = csGroupSelect.value; - const subjectId = csSubjectSelect.value; - const lessonTypeId = document.getElementById('cs-lesson-type').value; - const hours = document.getElementById('cs-hours').value; - const isDivision = document.getElementById('cs-division').checked; - const teacherId = csTeacherSelect.value; - - if (!period || !semesterType || !groupId || !subjectId || !lessonTypeId || !hours || !teacherId) { - showAlert('create-schedule-alert', 'Заполните все обязательные поля', 'error'); - return; - } - - const newRecord = { - departmentId: Number(depId), - groupId: Number(groupId), - subjectsId: Number(subjectId), - lessonTypeId: Number(lessonTypeId), - numberOfHours: Number(hours), - isDivision: isDivision, - teacherId: Number(teacherId), - semesterType: semesterType, - period: period - }; - - // Проверка на дубликат - const isDuplicate = preparedSchedules.some(s => - s.period === newRecord.period && - s.semesterType === newRecord.semesterType && - s.groupId === newRecord.groupId && - s.subjectsId === newRecord.subjectsId && - s.lessonTypeId === newRecord.lessonTypeId && - s.numberOfHours === newRecord.numberOfHours && - s.isDivision === newRecord.isDivision && - s.teacherId === newRecord.teacherId - ); - - if (isDuplicate) { - showAlert('create-schedule-alert', 'Такая запись уже есть в списке', 'error'); - return; - } - - preparedSchedules.push(newRecord); - - clearFormFields(); - - showAlert('create-schedule-alert', 'Запись добавлена ✓', 'success'); - setTimeout(() => hideAlert('create-schedule-alert'), 4000); // fix #1: 4 секунды - - renderPreparedSchedules(); - updateTableVisibility(); - }); - - // ===== Сохранение в БД ===== - btnSaveSchedules.addEventListener('click', async () => { - if (preparedSchedules.length === 0) { - showAlert('save-schedules-alert', 'Нет записей для сохранения', 'error'); - return; - } - - btnSaveSchedules.disabled = true; - btnSaveSchedules.textContent = 'Сохранение...'; - hideAlert('save-schedules-alert'); - - let errors = 0; - let saved = 0; - const failedRecords = []; - - for (const record of preparedSchedules) { - try { - await api.post('/api/department/schedule/create', record); - saved++; - } catch (err) { - console.error('Ошибка сохранения записи:', err); - errors++; - const isDuplicate = err.status === 409 || - (err.message && err.message.toLowerCase().includes('уже существует')); - failedRecords.push({ - ...record, - _errorMsg: isDuplicate - ? 'Такая запись уже есть в базе данных' - : (err.message || 'Ошибка сохранения') - }); - } - } - - btnSaveSchedules.disabled = false; - btnSaveSchedules.textContent = 'Сохранить в БД'; - - if (errors === 0) { - showAlert('save-schedules-alert', `Все записи (${saved}) успешно сохранены!`, 'success'); - preparedSchedules = []; - renderPreparedSchedules(); - updateTableVisibility(); - setTimeout(closeOverlay, 2000); - } else { - preparedSchedules = failedRecords; - renderPreparedSchedules(); - if (saved > 0) { - showAlert('save-schedules-alert', - `Сохранено: ${saved}. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error'); - } else { - showAlert('save-schedules-alert', - `Не удалось сохранить. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error'); - } - } - }); - -} \ No newline at end of file diff --git a/frontend/admin/js/views/departments-data.js b/frontend/admin/js/views/departments-data.js index 489a188..2e2e64f 100644 --- a/frontend/admin/js/views/departments-data.js +++ b/frontend/admin/js/views/departments-data.js @@ -7,6 +7,12 @@ export async function initDepartmentsData() { const createDeptForm = document.getElementById('create-department-form'); const createSpecForm = document.getElementById('create-specialty-form'); + const editDeptForm = document.getElementById('edit-department-form'); + const editSpecForm = document.getElementById('edit-specialty-form'); + const editDeptModal = document.getElementById('modal-edit-department'); + const editSpecModal = document.getElementById('modal-edit-specialty'); + const editDeptClose = document.getElementById('modal-edit-department-close'); + const editSpecClose = document.getElementById('modal-edit-specialty-close'); let departments = []; let specialties = []; @@ -17,7 +23,7 @@ export async function initDepartmentsData() { departments = await api.get('/api/departments'); renderDepartments(); } catch (e) { - deptTbody.innerHTML = '-'; + deptTbody.innerHTML = '-'; } // Load Specialties @@ -25,13 +31,13 @@ export async function initDepartmentsData() { specialties = await api.get('/api/specialties'); renderSpecialties(); } catch (e) { - specTbody.innerHTML = '-'; + specTbody.innerHTML = '-'; } } function renderDepartments() { if (!departments || !departments.length) { - deptTbody.innerHTML = '-'; + deptTbody.innerHTML = '-'; return; } deptTbody.innerHTML = departments.map(d => ` @@ -39,13 +45,17 @@ export async function initDepartmentsData() { ${d.id} ${escapeHtml(d.departmentName || d.name)} ${escapeHtml(String(d.departmentCode || d.code))} + + + + `).join(''); } function renderSpecialties() { if (!specialties || !specialties.length) { - specTbody.innerHTML = '-'; + specTbody.innerHTML = '-'; return; } specTbody.innerHTML = specialties.map(s => ` @@ -53,6 +63,10 @@ export async function initDepartmentsData() { ${s.id} ${escapeHtml(s.specialityName || s.name)} ${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)} + + + + `).join(''); } @@ -78,6 +92,34 @@ export async function initDepartmentsData() { } }); + deptTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-department'); + const deleteBtn = e.target.closest('.btn-delete-department'); + + if (editBtn) { + const department = departments.find(item => item.id == editBtn.dataset.id); + if (!department) return; + + document.getElementById('edit-dept-id').value = department.id; + document.getElementById('edit-dept-name').value = department.departmentName || department.name || ''; + document.getElementById('edit-dept-code').value = department.departmentCode || department.code || ''; + hideAlert('edit-dept-alert'); + editDeptModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить кафедру?')) return; + try { + await api.delete('/api/departments/' + deleteBtn.dataset.id); + showAlert('create-dept-alert', 'Кафедра удалена', 'success'); + await loadData(); + } catch (error) { + showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error'); + } + } + }); + createSpecForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-spec-alert'); @@ -99,5 +141,86 @@ export async function initDepartmentsData() { } }); - loadData(); + specTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-specialty'); + const deleteBtn = e.target.closest('.btn-delete-specialty'); + + if (editBtn) { + const speciality = specialties.find(item => item.id == editBtn.dataset.id); + if (!speciality) return; + + document.getElementById('edit-spec-id').value = speciality.id; + document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || ''; + document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || ''; + hideAlert('edit-spec-alert'); + editSpecModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить специальность?')) return; + try { + await api.delete('/api/specialties/' + deleteBtn.dataset.id); + showAlert('create-spec-alert', 'Специальность удалена', 'success'); + await loadData(); + } catch (error) { + showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error'); + } + } + }); + + editDeptForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-dept-alert'); + const id = document.getElementById('edit-dept-id').value; + const name = document.getElementById('edit-dept-name').value.trim(); + const code = document.getElementById('edit-dept-code').value.trim(); + + if (!name || !code) { + showAlert('edit-dept-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) }); + editDeptModal.classList.remove('open'); + showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success'); + await loadData(); + } catch (error) { + showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error'); + } + }); + + editSpecForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-spec-alert'); + const id = document.getElementById('edit-spec-id').value; + const name = document.getElementById('edit-spec-name').value.trim(); + const code = document.getElementById('edit-spec-code').value.trim(); + + if (!name || !code) { + showAlert('edit-spec-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code }); + editSpecModal.classList.remove('open'); + showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success'); + await loadData(); + } catch (error) { + showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error'); + } + }); + + editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open')); + editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open')); + editDeptModal.addEventListener('click', (e) => { + if (e.target === editDeptModal) editDeptModal.classList.remove('open'); + }); + editSpecModal.addEventListener('click', (e) => { + if (e.target === editSpecModal) editSpecModal.classList.remove('open'); + }); + + await loadData(); } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js old mode 100755 new mode 100644 index ec57a24..b1fa96b --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -6,122 +6,569 @@ export async function initGroups() { const groupsTbody = document.getElementById('groups-tbody'); const createGroupForm = document.getElementById('create-group-form'); const newGroupEfSelect = document.getElementById('new-group-ef'); + const newGroupDepartmentSelect = document.getElementById('new-group-department'); + const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); + const newGroupProfileSelect = document.getElementById('new-group-profile'); const filterEfSelect = document.getElementById('filter-ef'); + const modalEditGroup = document.getElementById('modal-edit-group'); + const modalEditGroupClose = document.getElementById('modal-edit-group-close'); + const editGroupForm = document.getElementById('edit-group-form'); + const editGroupEfSelect = document.getElementById('edit-group-ef'); + const editGroupDepartmentSelect = document.getElementById('edit-group-department'); + const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code'); + const editGroupProfileSelect = document.getElementById('edit-group-profile'); + const calendarForm = document.getElementById('group-calendar-form'); + const calendarGroupSelect = document.getElementById('calendar-group'); + const calendarYearSelect = document.getElementById('calendar-year'); + const calendarSelect = document.getElementById('calendar-id'); + const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); + const subgroupForm = document.getElementById('subgroup-form'); + const subgroupIdInput = document.getElementById('subgroup-id'); + const subgroupGroupSelect = document.getElementById('subgroup-group'); + const subgroupNameInput = document.getElementById('subgroup-name'); + const subgroupCapacityInput = document.getElementById('subgroup-capacity'); + const subgroupSubmitButton = document.getElementById('subgroup-submit'); + const subgroupResetButton = document.getElementById('subgroup-reset'); + const subgroupsTbody = document.getElementById('subgroups-tbody'); let allGroups = []; + let subgroups = []; let educationForms = []; + let departments = []; + let specialties = []; + let profilesBySpecialty = new Map(); + let academicYears = []; + let calendars = []; + let currentAssignments = []; + + bindEvents(); + await loadInitialData(); + + function bindEvents() { + filterEfSelect.addEventListener('change', applyGroupFilter); + newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); + editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); + calendarGroupSelect.addEventListener('change', () => { + populateCalendarSelect(); + loadAssignments(); + }); + calendarYearSelect.addEventListener('change', populateCalendarSelect); + createGroupForm.addEventListener('submit', createGroup); + editGroupForm.addEventListener('submit', updateGroup); + calendarForm.addEventListener('submit', saveAssignment); + subgroupForm.addEventListener('submit', saveSubgroup); + subgroupGroupSelect.addEventListener('change', renderSubgroups); + subgroupResetButton.addEventListener('click', resetSubgroupForm); + groupsTbody.addEventListener('click', handleGroupTableClick); + subgroupsTbody.addEventListener('click', handleSubgroupTableClick); + calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); + modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); + modalEditGroup.addEventListener('click', (event) => { + if (event.target === modalEditGroup) { + modalEditGroup.classList.remove('open'); + } + }); + } async function loadInitialData() { try { - educationForms = await fetchEducationForms(); + [educationForms, departments, specialties, academicYears, calendars] = await Promise.all([ + fetchEducationForms(), + api.get('/api/departments'), + api.get('/api/specialties'), + api.get('/api/admin/calendar/years'), + api.get('/api/admin/academic-calendars') + ]); + await loadProfiles(); populateEfSelects(educationForms); + populateDepartmentSelects(departments); + populateSpecialitySelects(specialties); + populateYearSelect(); + populateCalendarSelect(); await loadGroups(); - } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки данных'; + await loadSubgroups(); + } catch (error) { + groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; + subgroupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } } + async function loadProfiles() { + const pairs = await Promise.all(specialties.map(async (speciality) => { + const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); + return [String(speciality.id), profiles]; + })); + profilesBySpecialty = new Map(pairs); + } + async function loadGroups() { try { allGroups = await api.get('/api/groups'); applyGroupFilter(); - } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки'; + populateGroupSelect(); + populateSubgroupGroupSelect(); + await loadAssignments(); + } catch (error) { + groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + async function loadSubgroups() { + try { + subgroups = await api.get('/api/subgroups'); + renderSubgroups(); + } catch (error) { + subgroupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function applyGroupFilter() { const filterId = filterEfSelect.value; const filtered = filterId - ? allGroups.filter(g => g.educationFormId == filterId) + ? allGroups.filter(group => group.educationFormId == filterId) : allGroups; renderGroups(filtered); } - filterEfSelect.addEventListener('change', applyGroupFilter); - - function populateEfSelects(forms) { - // Group creation select - const currentVal = newGroupEfSelect.value; - newGroupEfSelect.innerHTML = forms.map(ef => - `` - ).join(''); - if (currentVal && forms.find(f => f.id == currentVal)) { - newGroupEfSelect.value = currentVal; - } - - // Filter select - const currentFilter = filterEfSelect.value; - filterEfSelect.innerHTML = '' + - forms.map(ef => - `` - ).join(''); - if (currentFilter) filterEfSelect.value = currentFilter; - } - function renderGroups(groups) { if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; + groupsTbody.innerHTML = 'Нет групп'; return; } - groupsTbody.innerHTML = groups.map(g => ` - - ${g.id} - ${escapeHtml(g.name)} - ${escapeHtml(g.groupSize)} - ${escapeHtml(g.educationFormName)} - ${g.departmentId || '-'} - ${g.course || '-'} - ${escapeHtml(g.specialityCode || '-')} - - `).join(''); + groupsTbody.innerHTML = groups.map(group => ` + + ${group.id} + ${escapeHtml(group.name)} + ${escapeHtml(String(group.groupSize))} + ${escapeHtml(group.educationFormName)} + ${escapeHtml(departmentLabel(group.departmentId))} + ${group.course || '-'} + ${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))} + ${escapeHtml(group.specialtyProfileName || '-')} + + + + + + `).join(''); } - createGroupForm.addEventListener('submit', async (e) => { - e.preventDefault(); + function populateEfSelects(forms) { + populateEfSelect(newGroupEfSelect, forms); + populateEfSelect(editGroupEfSelect, forms); + const currentFilter = filterEfSelect.value; + filterEfSelect.innerHTML = '' + + forms.map(form => ``).join(''); + if (currentFilter) filterEfSelect.value = currentFilter; + syncSelects(filterEfSelect); + } + + function populateEfSelect(select, forms) { + const currentVal = select.value; + select.innerHTML = forms.map(form => ``).join(''); + if (currentVal && forms.find(form => form.id == currentVal)) { + select.value = currentVal; + } + syncSelects(select); + } + + function populateDepartmentSelects(items) { + populateDepartmentSelect(newGroupDepartmentSelect, items); + populateDepartmentSelect(editGroupDepartmentSelect, items); + } + + function populateDepartmentSelect(select, items) { + const currentVal = select.value; + select.innerHTML = '' + + items.map(department => { + const name = department.departmentName || department.name || 'Без названия'; + const code = department.departmentCode || department.code || department.id; + return ``; + }).join(''); + if (currentVal && items.find(department => department.id == currentVal)) { + select.value = currentVal; + } + syncSelects(select); + } + + function populateSpecialitySelects(items) { + populateSpecialitySelect(newGroupSpecialitySelect, items); + populateSpecialitySelect(editGroupSpecialitySelect, items); + populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value); + populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value); + } + + function populateSpecialitySelect(select, items) { + const currentVal = select.value; + select.innerHTML = '' + + items.map(speciality => ``).join(''); + if (currentVal && items.find(speciality => speciality.id == currentVal)) { + select.value = currentVal; + } + syncSelects(select); + } + + function populateProfileSelect(select, specialtyId, selectedProfileId = null) { + const profiles = profilesBySpecialty.get(String(specialtyId)) || []; + select.innerHTML = profiles.length + ? '' + profiles.map(profile => + `` + ).join('') + : ''; + if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { + select.value = selectedProfileId; + } + syncSelects(select); + } + + function populateYearSelect() { + calendarYearSelect.innerHTML = '' + + academicYears.map(year => ``).join(''); + if (!calendarYearSelect.value && academicYears.length) { + calendarYearSelect.value = String(academicYears[0].id); + } + syncSelects(calendarYearSelect); + } + + function populateGroupSelect() { + const current = calendarGroupSelect.value; + calendarGroupSelect.innerHTML = '' + + allGroups.map(group => ``).join(''); + if (current && allGroups.some(group => String(group.id) === String(current))) { + calendarGroupSelect.value = current; + } else if (allGroups.length) { + calendarGroupSelect.value = String(allGroups[0].id); + } + syncSelects(calendarGroupSelect); + } + + function populateSubgroupGroupSelect() { + const current = subgroupGroupSelect.value; + subgroupGroupSelect.innerHTML = '' + + allGroups.map(group => ``).join(''); + if (current && allGroups.some(group => String(group.id) === String(current))) { + subgroupGroupSelect.value = current; + } else if (allGroups.length) { + subgroupGroupSelect.value = String(allGroups[0].id); + } + syncSelects(subgroupGroupSelect); + } + + function populateCalendarSelect() { + const group = selectedCalendarGroup(); + const yearId = calendarYearSelect.value; + const matching = group && yearId + ? calendars.filter(calendar => + String(calendar.academicYearId) === String(yearId) + && String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode) + && String(calendar.specialtyProfileId) === String(group.specialtyProfileId) + && String(calendar.studyFormId) === String(group.educationFormId) + ) + : []; + calendarSelect.innerHTML = matching.length + ? '' + matching.map(calendar => + `` + ).join('') + : ''; + syncSelects(calendarSelect); + } + + async function createGroup(event) { + event.preventDefault(); hideAlert('create-group-alert'); - const name = document.getElementById('new-group-name').value.trim(); - const groupSize = document.getElementById('new-group-size').value; - const educationFormId = newGroupEfSelect.value; - const departmentId = document.getElementById('new-group-department').value; - const course = document.getElementById('new-group-course').value; - const specialityCode = document.getElementById('new-group-speciality-code').value.trim(); - - if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; } - if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; } - if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; } - if (!departmentId) { showAlert('create-group-alert', 'Введите ID кафедры', 'error'); return; } - if (!course) { showAlert('create-group-alert', 'Введите курс', 'error'); return; } - if (!specialityCode) { showAlert('create-group-alert', 'Введите код специальности', 'error'); return; } + const payload = readGroupPayload('new'); + if (!payload) return; try { - const data = await api.post('/api/groups', { - name, - groupSize: Number(groupSize), - educationFormId: Number(educationFormId), - departmentId: Number(departmentId), - course: Number(course), - specialityCode: specialityCode - }); - showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); + const data = await api.post('/api/groups', payload); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" создана`, 'success'); createGroupForm.reset(); - loadGroups(); - } catch (e) { - showAlert('create-group-alert', e.message || 'Ошибка создания', 'error'); + syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); + await loadGroups(); + await loadSubgroups(); + } catch (error) { + showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); } - }); + } + + async function updateGroup(event) { + event.preventDefault(); + hideAlert('edit-group-alert'); + const id = document.getElementById('edit-group-id').value; + const payload = readGroupPayload('edit'); + if (!payload) return; - groupsTbody.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-delete'); - if (!btn) return; - if (!confirm('Удалить группу?')) return; try { - await api.delete('/api/groups/' + btn.dataset.id); - loadGroups(); - } catch (e) { - alert(e.message || 'Ошибка удаления'); + const data = await api.put('/api/groups/' + id, payload); + modalEditGroup.classList.remove('open'); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); + await loadGroups(); + await loadSubgroups(); + } catch (error) { + showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); } - }); + } - loadInitialData(); + function readGroupPayload(prefix) { + const isEdit = prefix === 'edit'; + const alertId = isEdit ? 'edit-group-alert' : 'create-group-alert'; + const name = document.getElementById(`${prefix}-group-name`).value.trim(); + const groupSize = document.getElementById(`${prefix}-group-size`).value; + const educationFormId = (isEdit ? editGroupEfSelect : newGroupEfSelect).value; + const departmentId = (isEdit ? editGroupDepartmentSelect : newGroupDepartmentSelect).value; + const yearStartStudy = document.getElementById(`${prefix}-group-yearStartStudy`).value; + const specialtyId = (isEdit ? editGroupSpecialitySelect : newGroupSpecialitySelect).value; + const specialtyProfileId = (isEdit ? editGroupProfileSelect : newGroupProfileSelect).value; + + if (!name) { showAlert(alertId, 'Введите название группы', 'error'); return null; } + if (!groupSize) { showAlert(alertId, 'Введите размер группы', 'error'); return null; } + if (!educationFormId) { showAlert(alertId, 'Выберите форму обучения', 'error'); return null; } + if (!departmentId) { showAlert(alertId, 'Выберите кафедру', 'error'); return null; } + if (!yearStartStudy) { showAlert(alertId, 'Введите год начала обучения', 'error'); return null; } + if (!specialtyId) { showAlert(alertId, 'Выберите специальность', 'error'); return null; } + if (!specialtyProfileId) { showAlert(alertId, 'Выберите профиль обучения', 'error'); return null; } + + return { + name, + groupSize: Number(groupSize), + educationFormId: Number(educationFormId), + departmentId: Number(departmentId), + yearStartStudy: Number(yearStartStudy), + specialtyId: Number(specialtyId), + specialtyProfileId: Number(specialtyProfileId) + }; + } + + async function handleGroupTableClick(event) { + const btnDelete = event.target.closest('.btn-delete'); + const btnEdit = event.target.closest('.btn-edit-group'); + + if (btnDelete) { + if (!confirm('Удалить группу?')) return; + try { + await api.delete('/api/groups/' + btnDelete.dataset.id); + await loadGroups(); + await loadSubgroups(); + } catch (error) { + alert(error.message || 'Ошибка удаления'); + } + } + + if (btnEdit) { + openEditGroupModal(btnEdit.dataset.id); + } + } + + function openEditGroupModal(id) { + const group = allGroups.find(item => item.id == id); + if (!group) { + alert('Группа не найдена'); + return; + } + + document.getElementById('edit-group-id').value = group.id; + document.getElementById('edit-group-name').value = group.name || ''; + document.getElementById('edit-group-size').value = group.groupSize || ''; + editGroupEfSelect.value = group.educationFormId || ''; + editGroupDepartmentSelect.value = group.departmentId || ''; + document.getElementById('edit-group-yearStartStudy').value = group.yearStartStudy || ''; + editGroupSpecialitySelect.value = group.specialtyId || group.specialityCode || ''; + populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value, group.specialtyProfileId); + syncSelects(editGroupEfSelect, editGroupDepartmentSelect, editGroupSpecialitySelect, editGroupProfileSelect); + hideAlert('edit-group-alert'); + modalEditGroup.classList.add('open'); + } + + async function saveSubgroup(event) { + event.preventDefault(); + hideAlert('subgroup-alert'); + const groupId = subgroupGroupSelect.value; + const name = subgroupNameInput.value.trim(); + const capacity = subgroupCapacityInput.value; + const subgroupId = subgroupIdInput.value; + + if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; } + if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; } + if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; } + + const payload = { + name, + studentCapacity: capacity ? Number(capacity) : null + }; + + try { + const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`; + const saved = subgroupId + ? await api.put(endpoint, payload) + : await api.post(endpoint, payload); + showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success'); + resetSubgroupForm(); + await loadSubgroups(); + } catch (error) { + showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error'); + } + } + + function renderSubgroups() { + const groupId = subgroupGroupSelect.value; + const visibleSubgroups = groupId + ? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId)) + : subgroups; + if (!visibleSubgroups.length) { + subgroupsTbody.innerHTML = 'Подгруппы не созданы'; + return; + } + subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => ` + + ${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))} + ${escapeHtml(subgroup.name)} + ${escapeHtml(String(subgroup.studentCapacity ?? '-'))} + + + + + + `).join(''); + } + + async function handleSubgroupTableClick(event) { + const editButton = event.target.closest('.btn-edit-subgroup'); + const deleteButton = event.target.closest('.btn-delete-subgroup'); + + if (editButton) { + const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id)); + if (!subgroup) return; + subgroupIdInput.value = subgroup.id; + subgroupGroupSelect.value = subgroup.groupId; + subgroupGroupSelect.disabled = true; + subgroupNameInput.value = subgroup.name || ''; + subgroupCapacityInput.value = subgroup.studentCapacity ?? ''; + subgroupSubmitButton.textContent = 'Сохранить подгруппу'; + syncSelects(subgroupGroupSelect); + hideAlert('subgroup-alert'); + return; + } + + if (deleteButton) { + const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id)); + if (!subgroup || !confirm('Удалить подгруппу?')) return; + try { + await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`); + showAlert('subgroup-alert', 'Подгруппа удалена', 'success'); + resetSubgroupForm(); + await loadSubgroups(); + } catch (error) { + showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error'); + } + } + } + + function resetSubgroupForm() { + subgroupIdInput.value = ''; + subgroupNameInput.value = ''; + subgroupCapacityInput.value = ''; + subgroupGroupSelect.disabled = false; + subgroupSubmitButton.textContent = 'Создать подгруппу'; + syncSelects(subgroupGroupSelect); + hideAlert('subgroup-alert'); + } + + async function saveAssignment(event) { + event.preventDefault(); + hideAlert('group-calendar-alert'); + const groupId = calendarGroupSelect.value; + const academicYearId = calendarYearSelect.value; + const calendarId = calendarSelect.value; + + if (!groupId || !academicYearId || !calendarId) { + showAlert('group-calendar-alert', 'Выберите группу, учебный год и график', 'error'); + return; + } + + try { + await api.put(`/api/groups/${groupId}/calendar-assignments`, { + academicYearId: Number(academicYearId), + calendarId: Number(calendarId) + }); + showAlert('group-calendar-alert', 'Календарный график назначен', 'success'); + await loadAssignments(); + } catch (error) { + showAlert('group-calendar-alert', error.message || 'Ошибка назначения графика', 'error'); + } + } + + async function loadAssignments() { + const groupId = calendarGroupSelect.value; + if (!groupId) { + currentAssignments = []; + calendarAssignmentsTbody.innerHTML = 'Выберите группу'; + return; + } + + try { + currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`); + renderAssignments(); + } catch (error) { + calendarAssignmentsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderAssignments() { + if (!currentAssignments.length) { + calendarAssignmentsTbody.innerHTML = 'Графики не назначены'; + return; + } + calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => ` + + ${escapeHtml(assignment.academicYearTitle)} + ${escapeHtml(assignment.calendarTitle)} + + + + + `).join(''); + } + + async function handleAssignmentTableClick(event) { + const deleteButton = event.target.closest('.btn-delete-assignment'); + if (!deleteButton) return; + if (!confirm('Удалить назначение графика?')) return; + + try { + await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`); + showAlert('group-calendar-alert', 'Назначение удалено', 'success'); + await loadAssignments(); + } catch (error) { + showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error'); + } + } + + function selectedCalendarGroup() { + return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value)); + } + + function departmentLabel(departmentId) { + const department = departments.find(item => item.id == departmentId); + if (!department) return departmentId || '-'; + return department.departmentName || department.name || departmentId; + } + + function groupName(groupId) { + const group = allGroups.find(item => String(item.id) === String(groupId)); + return group ? group.name : groupId || '-'; + } + + function specialityLabel(specialityId) { + const speciality = specialties.find(item => item.id == specialityId); + if (!speciality) return specialityId || '-'; + const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; + const name = speciality.specialityName || speciality.name || ''; + return name ? `${code} — ${name}` : code; + } + + function syncSelects(...selects) { + selects.filter(Boolean).forEach(select => { + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } } diff --git a/frontend/admin/js/views/profiles.js b/frontend/admin/js/views/profiles.js new file mode 100644 index 0000000..299a768 --- /dev/null +++ b/frontend/admin/js/views/profiles.js @@ -0,0 +1,192 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +export async function initProfiles() { + const createProfileForm = document.getElementById('create-profile-form'); + const profileSpecialtySelect = document.getElementById('profile-specialty'); + const profileNameInput = document.getElementById('profile-name'); + const profileDescriptionInput = document.getElementById('profile-description'); + const profilesTbody = document.getElementById('profiles-tbody'); + const profilesContext = document.getElementById('profiles-context'); + const refreshButton = document.getElementById('profiles-refresh'); + const editProfileForm = document.getElementById('edit-profile-form'); + const editProfileModal = document.getElementById('modal-edit-profile'); + const editProfileClose = document.getElementById('modal-edit-profile-close'); + + let specialties = []; + let profiles = []; + + await loadSpecialties(); + + profileSpecialtySelect.addEventListener('change', loadProfiles); + refreshButton.addEventListener('click', loadProfiles); + + createProfileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + hideAlert('create-profile-alert'); + + const specialtyId = profileSpecialtySelect.value; + const name = profileNameInput.value.trim(); + const description = profileDescriptionInput.value.trim(); + + if (!specialtyId || !name) { + showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error'); + return; + } + + try { + await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description }); + showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success'); + createProfileForm.reset(); + profileSpecialtySelect.value = specialtyId; + syncSelect(profileSpecialtySelect); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error'); + } + }); + + profilesTbody.addEventListener('click', async (event) => { + const editButton = event.target.closest('.btn-edit-profile'); + const deleteButton = event.target.closest('.btn-delete-profile'); + + if (editButton) { + const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id)); + if (!profile) return; + + document.getElementById('edit-profile-id').value = profile.id; + document.getElementById('edit-profile-name').value = profile.name || ''; + document.getElementById('edit-profile-description').value = profile.description || ''; + hideAlert('edit-profile-alert'); + editProfileModal.classList.add('open'); + return; + } + + if (deleteButton) { + if (!confirm('Удалить профиль обучения?')) return; + try { + await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`); + showAlert('create-profile-alert', 'Профиль удалён', 'success'); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); + } + } + }); + + editProfileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + hideAlert('edit-profile-alert'); + + const specialtyId = profileSpecialtySelect.value; + const id = document.getElementById('edit-profile-id').value; + const name = document.getElementById('edit-profile-name').value.trim(); + const description = document.getElementById('edit-profile-description').value.trim(); + + if (!specialtyId || !id || !name) { + showAlert('edit-profile-alert', 'Заполните название профиля', 'error'); + return; + } + + try { + await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description }); + editProfileModal.classList.remove('open'); + showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success'); + await loadProfiles(); + } catch (error) { + showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error'); + } + }); + + editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open')); + editProfileModal.addEventListener('click', (event) => { + if (event.target === editProfileModal) { + editProfileModal.classList.remove('open'); + } + }); + + async function loadSpecialties() { + profilesTbody.innerHTML = 'Загрузка...'; + try { + specialties = await api.get('/api/specialties'); + renderSpecialtySelect(); + await loadProfiles(); + } catch (error) { + profileSpecialtySelect.innerHTML = ''; + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + syncSelect(profileSpecialtySelect); + } + } + + function renderSpecialtySelect() { + if (!specialties.length) { + profileSpecialtySelect.innerHTML = ''; + syncSelect(profileSpecialtySelect); + return; + } + + const current = profileSpecialtySelect.value; + profileSpecialtySelect.innerHTML = '' + + specialties.map(specialty => { + const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id; + const name = specialty.specialityName || specialty.name || 'Без названия'; + return ``; + }).join(''); + + if (current && specialties.some(specialty => String(specialty.id) === String(current))) { + profileSpecialtySelect.value = current; + } else { + profileSpecialtySelect.value = String(specialties[0].id); + } + syncSelect(profileSpecialtySelect); + } + + async function loadProfiles() { + const specialtyId = profileSpecialtySelect.value; + const specialty = specialties.find(item => String(item.id) === String(specialtyId)); + profilesContext.textContent = specialty ? specialtyLabel(specialty) : ''; + + if (!specialtyId) { + profiles = []; + profilesTbody.innerHTML = 'Выберите специальность'; + return; + } + + profilesTbody.innerHTML = 'Загрузка...'; + try { + profiles = await api.get(`/api/specialties/${specialtyId}/profiles`); + renderProfiles(); + } catch (error) { + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderProfiles() { + if (!profiles.length) { + profilesTbody.innerHTML = 'Профили не созданы'; + return; + } + + profilesTbody.innerHTML = profiles.map(profile => ` + + ${profile.id} + ${escapeHtml(profile.name)} + ${escapeHtml(profile.description || '-')} + + + + + + `).join(''); + } + + function specialtyLabel(specialty) { + const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id; + const name = specialty.specialityName || specialty.name || 'Без названия'; + return `${code} · ${name}`; + } + + function syncSelect(select) { + select.dispatchEvent(new Event('change', { bubbles: true })); + } +} diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js new file mode 100644 index 0000000..6f64179 --- /dev/null +++ b/frontend/admin/js/views/schedule-view.js @@ -0,0 +1,1025 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +const ROLE_DEPARTMENT = 'DEPARTMENT'; +const TARGET_GROUPS = 'groups'; +const TARGET_TEACHERS = 'teachers'; +const TARGET_CLASSROOMS = 'classrooms'; +const TARGET_DEPARTMENTS = 'departments'; +const PARITY_LABELS = { + BOTH: 'каждая', + ODD: 'нечётная', + EVEN: 'чётная' +}; +const DAY_SHORT_LABELS = { + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + 7: 'Вс' +}; +const DAY_FULL_LABELS = { + 1: 'Понедельник', + 2: 'Вторник', + 3: 'Среда', + 4: 'Четверг', + 5: 'Пятница', + 6: 'Суббота', + 7: 'Воскресенье' +}; +const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]; +const CELL_PARITY_LAYOUT = [ + { parity: 'ODD', label: 'Нечётная' }, + { parity: 'EVEN', label: 'Чётная' } +]; +const MOBILE_QUERY = '(max-width: 760px)'; + +export async function initScheduleView() { + const role = localStorage.getItem('role'); + const userDepartmentId = normalizeId(localStorage.getItem('departmentId')); + const dateInput = document.getElementById('schedule-view-date'); + const targetSelect = document.getElementById('schedule-view-target'); + const loadButton = document.getElementById('schedule-view-load'); + const previousPeriodButton = document.getElementById('schedule-view-prev-period'); + const todayButton = document.getElementById('schedule-view-today'); + const nextPeriodButton = document.getElementById('schedule-view-next-period'); + const resultPreviousButton = document.getElementById('schedule-view-result-prev'); + const resultNextButton = document.getElementById('schedule-view-result-next'); + const resultTabs = document.getElementById('schedule-view-result-tabs'); + const activeLabel = document.getElementById('schedule-view-active-label'); + const countLabel = document.getElementById('schedule-view-count'); + const periodLabel = document.getElementById('schedule-view-period-label'); + const tableTitle = document.getElementById('schedule-view-table-title'); + const tableCaption = document.getElementById('schedule-view-table-caption'); + const dayTabs = document.getElementById('schedule-view-day-tabs'); + const tables = document.getElementById('schedule-view-tables'); + const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]')); + const departmentSelect = document.getElementById('schedule-view-department'); + + const state = { + dictionaries: { + groups: [], + teachers: [], + classrooms: [], + departments: [] + }, + sections: [], + activeSectionIndex: 0, + mobileDay: 1, + range: null, + totalLessons: 0 + }; + const mobileMedia = window.matchMedia(MOBILE_QUERY); + + setDefaultDate(dateInput); + configureDepartmentTarget(); + updatePeriodLabel(); + updateEntityFieldVisibility(); + renderResultNavigation(); + + targetSelect?.addEventListener('change', () => { + clearInactiveEntityValues(); + updateEntityFieldVisibility(); + resetResults('Нажмите «Показать», чтобы обновить расписание'); + }); + loadButton?.addEventListener('click', loadData); + previousPeriodButton?.addEventListener('click', () => movePeriod(-1)); + todayButton?.addEventListener('click', () => { + dateInput.value = toIsoDate(new Date()); + loadData(); + }); + nextPeriodButton?.addEventListener('click', () => movePeriod(1)); + dateInput?.addEventListener('change', () => { + updatePeriodLabel(); + resetResults('Нажмите «Показать», чтобы обновить расписание'); + }); + resultPreviousButton?.addEventListener('click', () => moveResult(-1)); + resultNextButton?.addEventListener('click', () => moveResult(1)); + if (typeof mobileMedia.addEventListener === 'function') { + mobileMedia.addEventListener('change', renderActiveSection); + } else if (typeof mobileMedia.addListener === 'function') { + mobileMedia.addListener(renderActiveSection); + } + + try { + state.dictionaries = await loadDictionaries(role, userDepartmentId); + configureDepartmentAccess(); + updateEntityFieldVisibility(); + await loadData(); + } catch (error) { + const message = error.message || 'Ошибка загрузки справочников'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + } + + function configureDepartmentTarget() { + if (role !== ROLE_DEPARTMENT) return; + targetSelect.value = TARGET_DEPARTMENTS; + targetSelect.disabled = true; + } + + function configureDepartmentAccess() { + if (role !== ROLE_DEPARTMENT) return; + targetSelect.value = TARGET_DEPARTMENTS; + targetSelect.disabled = true; + if (departmentSelect) { + departmentSelect.value = userDepartmentId || ''; + departmentSelect.disabled = true; + if (userDepartmentId && departmentSelect.value !== userDepartmentId) { + departmentSelect.insertAdjacentHTML('afterbegin', + ``); + departmentSelect.value = userDepartmentId; + } + } + } + + function updateEntityFieldVisibility() { + const target = activeTarget(); + entityFields.forEach(field => { + field.hidden = field.dataset.scheduleViewTargetField !== target; + }); + } + + function clearInactiveEntityValues() { + const target = activeTarget(); + [ + [TARGET_GROUPS, 'schedule-view-group'], + [TARGET_TEACHERS, 'schedule-view-teacher'], + [TARGET_CLASSROOMS, 'schedule-view-classroom'], + [TARGET_DEPARTMENTS, 'schedule-view-department'] + ].forEach(([fieldTarget, id]) => { + if (fieldTarget === target) return; + if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return; + const select = document.getElementById(id); + if (select) select.value = ''; + }); + } + + function activeTarget() { + if (role === ROLE_DEPARTMENT) return TARGET_DEPARTMENTS; + return targetSelect?.value || TARGET_GROUPS; + } + + async function movePeriod(direction) { + if (!dateInput.value) { + dateInput.value = toIsoDate(new Date()); + } + const range = twoWeekRange(dateInput.value); + const nextDate = addDays(parseIsoDate(range.startDate), direction * 14); + dateInput.value = toIsoDate(nextDate); + await loadData(); + } + + async function loadData() { + hideAlert('schedule-view-alert'); + + if (!dateInput.value) { + const message = 'Выберите дату в периоде'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + return; + } + + if (role === ROLE_DEPARTMENT && !userDepartmentId) { + const message = 'Не удалось определить кафедру пользователя'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + return; + } + + const target = activeTarget(); + const range = twoWeekRange(dateInput.value); + const previousKey = state.sections[state.activeSectionIndex]?.key; + state.range = range; + updatePeriodLabel(range); + setTableState('Загрузка...'); + setActiveSummary('Загрузка расписаний...'); + countLabel.textContent = 'Загрузка...'; + resultTabs.innerHTML = ''; + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + + const params = new URLSearchParams({ + startDate: range.startDate, + endDate: range.endDate + }); + applyTargetFilter(params, target); + addOptional(params, 'subjectId', valueOf('schedule-view-subject')); + addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type')); + addOptional(params, 'parity', valueOf('schedule-view-parity')); + + try { + const lessons = await api.get(`/api/schedule/search?${params}`); + renderLessons(lessons, range, target, previousKey); + } catch (error) { + const message = error.message || 'Ошибка загрузки расписания'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + } + } + + function applyTargetFilter(params, target) { + if (role === ROLE_DEPARTMENT) { + params.set('departmentId', userDepartmentId); + return; + } + if (target === TARGET_GROUPS) { + addOptional(params, 'groupId', valueOf('schedule-view-group')); + return; + } + if (target === TARGET_TEACHERS) { + addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); + return; + } + if (target === TARGET_CLASSROOMS) { + addOptional(params, 'classroomId', valueOf('schedule-view-classroom')); + return; + } + if (target === TARGET_DEPARTMENTS) { + addOptional(params, 'departmentId', valueOf('schedule-view-department')); + } + } + + function renderLessons(lessons, range, target, previousKey) { + state.totalLessons = lessons.length; + if (!lessons.length) { + state.sections = []; + state.activeSectionIndex = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = periodCaption(range); + setTableState('Занятия не найдены'); + countLabel.textContent = '0 занятий'; + return; + } + + const sections = buildScheduleSections(lessons, splitTypeForTarget(target), buildSectionContext(target)); + if (!sections.length) { + state.sections = []; + state.activeSectionIndex = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = periodCaption(range); + setTableState('Нет данных для выбранного режима просмотра'); + countLabel.textContent = lessonCountLabel(lessons.length); + return; + } + + state.sections = sections; + state.activeSectionIndex = Math.max(0, sections.findIndex(section => section.key === previousKey)); + if (state.activeSectionIndex < 0) { + state.activeSectionIndex = 0; + } + state.mobileDay = preferredMobileDay(range); + renderResultNavigation(); + renderActiveSection(); + } + + function buildSectionContext(target) { + const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : ''; + const selectedDepartmentId = role === ROLE_DEPARTMENT + ? userDepartmentId + : (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : ''); + const allowedGroupIds = new Set(); + if (selectedGroupId) { + allowedGroupIds.add(selectedGroupId); + } else if (selectedDepartmentId) { + state.dictionaries.groups + .filter(group => String(group.departmentId) === String(selectedDepartmentId)) + .forEach(group => allowedGroupIds.add(String(group.id))); + } + return { + selectedGroupId, + selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '', + allowedGroupIds, + groupNamesById: new Map(state.dictionaries.groups.map(group => [String(group.id), group.name])) + }; + } + + function splitTypeForTarget(target) { + if (target === TARGET_TEACHERS) return TARGET_TEACHERS; + if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS; + return TARGET_GROUPS; + } + + function renderResultNavigation() { + const section = activeSection(); + const totalSections = state.sections.length; + countLabel.textContent = state.totalLessons + ? `${lessonCountLabel(state.totalLessons)}, ${sectionCountLabel(totalSections)}` + : '0 занятий'; + + resultPreviousButton.disabled = totalSections < 2; + resultNextButton.disabled = totalSections < 2; + + if (!section) { + setActiveSummary('Нет выбранного расписания'); + resultTabs.innerHTML = 'Ничего не найдено'; + return; + } + + setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`); + resultTabs.innerHTML = state.sections.map((item, index) => ` + + `).join(''); + resultTabs.querySelectorAll('[data-schedule-view-section]').forEach(button => { + button.addEventListener('click', () => { + state.activeSectionIndex = Number(button.dataset.scheduleViewSection); + state.mobileDay = preferredMobileDay(state.range); + renderResultNavigation(); + renderActiveSection(); + }); + }); + } + + function setActiveSummary(text) { + activeLabel.textContent = text; + } + + function moveResult(direction) { + if (state.sections.length < 2) return; + state.activeSectionIndex = (state.activeSectionIndex + direction + state.sections.length) % state.sections.length; + state.mobileDay = preferredMobileDay(state.range); + renderResultNavigation(); + renderActiveSection(); + } + + function activeSection() { + return state.sections[state.activeSectionIndex] || null; + } + + function renderActiveSection() { + const section = activeSection(); + if (!section || !state.range) { + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + return; + } + + tableTitle.textContent = section.title; + tableCaption.textContent = `${lessonCountLabel(section.lessons.length)} · ${periodCaption(state.range)}`; + + if (mobileMedia.matches) { + renderMobileDayTabs(); + renderMobileSchedule(section, state.range); + return; + } + + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + renderDesktopSchedule(section, state.range); + } + + function renderDesktopSchedule(section, range) { + const slots = collectSlots(section.lessons); + const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); + const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); + if (!blocks.length || !slots.length) { + setTableState('Занятия не найдены'); + return; + } + tables.innerHTML = blocks + .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) + .join(''); + } + + function renderMobileDayTabs() { + dayTabs.hidden = false; + dayTabs.innerHTML = DAY_ORDER.map(day => ` + + `).join(''); + dayTabs.querySelectorAll('[data-schedule-view-day]').forEach(button => { + button.addEventListener('click', () => { + state.mobileDay = Number(button.dataset.scheduleViewDay); + renderActiveSection(); + }); + }); + } + + function renderMobileSchedule(section, range) { + const slots = collectSlots(section.lessons); + const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); + const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); + if (!blocks.length || !slots.length) { + setTableState('Занятия не найдены'); + return; + } + tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join(''); + } + + function renderMobileDayBlock(block, slots, lessonsByDateSlot) { + const dayDates = block.datesByDay[state.mobileDay] || {}; + const rows = slots.map(slot => { + const states = slotStatesForDates(dayDates, slot, lessonsByDateSlot); + return ` +
+
+ ${escapeHtml(slot.orderLabel)} + ${escapeHtml(slot.timeLabel)} +
+
+ ${renderSlotContent(states)} +
+
+ `; + }).join(''); + + return ` +
+
+ ${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))} + ${escapeHtml(mobileDayCaption(dayDates))} +
+ ${rows} +
+ `; + } + + function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { + const dayHeaders = DAY_ORDER.map(day => ` + + ${escapeHtml(dayShort(day))} + ${escapeHtml(DAY_FULL_LABELS[day] || '')} +
+ ${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')} +
+ + `).join(''); + const rows = slots.map(slot => ` + + + ${escapeHtml(slot.orderLabel)} + ${escapeHtml(slot.timeLabel)} + ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} + + ${DAY_ORDER.map(day => renderCombinedSlotCell(block.datesByDay[day] || {}, slot, lessonsByDateSlot)).join('')} + + `).join(''); + + return ` +
+
+

${escapeHtml(section.title)}

+ ${escapeHtml(combinedWeekCaption(block))} +
+
+ + + + + ${dayHeaders} + + + ${rows} +
Время
+
+
+ `; + } + + function renderDayParityDate(date, label) { + return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; + } + + function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { + const states = slotStatesForDates(datesByParity, slot, lessonsByDateSlot); + const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : ''; + return `${renderSlotContent(states)}`; + } + + function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { + return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ + parity, + label, + state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) + })); + } + + function renderSlotContent(states) { + if (slotStatesEqual(states[0].state, states[1].state)) { + return renderUnifiedSlot(states[0].state); + } + return states.map(({ parity, label, state }) => + renderSlotHalf(state, parity, label) + ).join(''); + } + + function scheduleSlotState(date, slot, lessonsByDateSlot) { + if (!date) { + return { kind: 'empty', lessons: [] }; + } + const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || []; + if (lessons.length) { + return { kind: 'busy', lessons }; + } + return { kind: 'free', lessons: [] }; + } + + function renderUnifiedSlot(state) { + if (state.kind === 'busy') { + return `
${state.lessons.map(renderLessonCard).join('')}
`; + } + if (state.kind === 'empty') { + return '
Нет даты
'; + } + return '
Нет занятий
'; + } + + function renderSlotHalf(state, parity, label) { + if (state.kind === 'busy') { + return ` +
+ ${escapeHtml(label)} + ${state.lessons.map(renderLessonCard).join('')} +
+ `; + } + if (state.kind === 'empty') { + return ` +
+ ${escapeHtml(label)} + Нет даты +
+ `; + } + return ` +
+ ${escapeHtml(label)} + Нет занятий +
+ `; + } + + function slotStatesEqual(first, second) { + if (first.kind !== second.kind) return false; + if (first.kind !== 'busy') return true; + return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons); + } + + function lessonSetSignature(lessons) { + return lessons + .map(lessonContentSignature) + .sort((a, b) => naturalCompare(a, b)) + .join('|'); + } + + function lessonContentSignature(lesson) { + return [ + lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', + lesson.subjectId || lesson.subjectName || '', + lesson.teacherId || lesson.teacherName || '', + lesson.classroomId || lesson.classroomName || '', + lesson.lessonTypeId || lesson.lessonTypeName || '', + lesson.lessonFormat || '', + valueListSignature(lesson.groupIds, lesson.groupNames), + valueListSignature(lesson.subgroupIds, lesson.subgroupNames) + ].join(':'); + } + + function valueListSignature(primary = [], fallback = []) { + const primaryList = Array.isArray(primary) ? primary : []; + const fallbackList = Array.isArray(fallback) ? fallback : []; + const source = primaryList.length ? primaryList : fallbackList; + return [...source] + .map(value => String(value)) + .sort((a, b) => naturalCompare(a, b)) + .join(','); + } + + function renderLessonCard(lesson) { + const subgroupText = (lesson.subgroupNames || []).join(', '); + const meta = [ + lesson.lessonTypeName, + lesson.lessonFormat, + PARITY_LABELS[lesson.parity] || lesson.parity + ].filter(Boolean).join(' · '); + + return ` +
+
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
+
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
+ ${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''} +
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
+
${escapeHtml(lesson.classroomName || 'Аудитория не указана')}
+ ${meta ? `
${escapeHtml(meta)}
` : ''} + ${lesson.scheduleRuleSlotId ? `
ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}
` : ''} +
+ `; + } + + function setTableState(message) { + if (!tables) return; + tables.innerHTML = `
${escapeHtml(message)}
`; + } + + function resetResults(message) { + state.sections = []; + state.activeSectionIndex = 0; + state.mobileDay = 1; + state.totalLessons = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = message; + setTableState(message); + countLabel.textContent = '0 занятий'; + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + } + + function updatePeriodLabel(range) { + if (!periodLabel) return; + if (!dateInput.value) { + periodLabel.textContent = 'Период не выбран'; + return; + } + periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value)); + } + + function groupNameById(id) { + return state.dictionaries.groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`; + } + + function preferredMobileDay(range) { + if (!range) return 1; + return isoDay(dateInput.value || range.startDate); + } +} + +async function loadDictionaries(role, userDepartmentId) { + const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([ + api.get('/api/groups'), + api.get('/api/users/teachers'), + api.get('/api/classrooms'), + api.get('/api/departments'), + api.get('/api/subjects'), + api.get('/api/lesson-types') + ]); + + const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId + ? groups.filter(group => String(group.departmentId) === String(userDepartmentId)) + : groups; + + fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы'); + fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели'); + fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории'); + fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры'); + fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); + fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); + + return { + groups, + teachers, + classrooms, + departments + }; +} + +function setDefaultDate(dateInput) { + const today = new Date(); + dateInput.value = toIsoDate(today); +} + +function fillSelect(id, items, valueFn, labelFn, emptyLabel) { + const select = document.getElementById(id); + if (!select) return; + select.innerHTML = `` + + (items || []).map(item => ``).join(''); +} + +function addOptional(params, key, value) { + if (value) params.set(key, value); +} + +function valueOf(id) { + return document.getElementById(id)?.value || ''; +} + +function normalizeId(value) { + if (!value || value === 'null' || value === 'undefined') return ''; + return String(value); +} + +function roomLabel(room) { + return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); +} + +function collectSlots(lessons) { + const slots = new Map(); + lessons.forEach(lesson => { + const key = lessonSlotKey(lesson); + const existing = slots.get(key); + const next = { + key, + id: lesson.timeSlotId, + orderNumber: lesson.timeSlotOrder, + startTime: lesson.startTime, + endTime: lesson.endTime, + orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара', + timeLabel: lesson.startTime || lesson.endTime + ? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}` + : 'Время не указано' + }; + if (!existing || existing.timeLabel === 'Время не указано') { + slots.set(key, next); + } + }); + return Array.from(slots.values()).sort(compareSlots); +} + +function groupLessonsByDateSlot(lessons) { + const grouped = new Map(); + lessons.forEach(lesson => { + if (!lesson.date) return; + const key = dateSlotKey(lesson.date, lessonSlotKey(lesson)); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(lesson); + }); + grouped.forEach(items => { + items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName) + || naturalCompare(a.teacherName, b.teacherName) + || naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(','))); + }); + return grouped; +} + +function buildScheduleSections(lessons, splitType, context) { + const sections = new Map(); + + lessons.forEach(lesson => { + splitEntitiesForLesson(lesson, splitType, context).forEach(entity => { + if (!sections.has(entity.key)) { + sections.set(entity.key, { + key: entity.key, + title: entity.title, + lessons: [] + }); + } + sections.get(entity.key).lessons.push(lesson); + }); + }); + + return Array.from(sections.values()) + .map(section => ({ + ...section, + lessons: sortLessons(section.lessons) + })) + .sort((a, b) => naturalCompare(a.title, b.title)); +} + +function splitEntitiesForLesson(lesson, splitType, context) { + if (splitType === TARGET_TEACHERS) { + return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; + } + if (splitType === TARGET_CLASSROOMS) { + return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; + } + if (context.selectedGroupId) { + return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')]; + } + return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById); +} + +function singleEntity(prefix, id, name, fallback) { + const title = name || fallback; + return { + id, + key: `${prefix}:${id ?? title}`, + title + }; +} + +function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) { + const safeIds = Array.isArray(ids) ? ids : []; + const safeNames = Array.isArray(names) ? names : []; + if (!safeIds.length && !safeNames.length) { + return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)]; + } + const maxLength = Math.max(safeIds.length, safeNames.length); + const entities = []; + for (let index = 0; index < maxLength; index += 1) { + const id = safeIds[index] ?? null; + if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) { + continue; + } + const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`); + entities.push(singleEntity(prefix, id, name, fallback)); + } + return entities; +} + +function sortLessons(lessons) { + return [...lessons].sort((a, b) => + naturalCompare(a.date, b.date) + || compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b)) + || naturalCompare(a.subjectName, b.subjectName) + || naturalCompare(a.teacherName, b.teacherName) + || naturalCompare(a.classroomName, b.classroomName) + ); +} + +function lessonSlotDescriptor(lesson) { + return { + key: lessonSlotKey(lesson), + orderNumber: lesson.timeSlotOrder, + startTime: lesson.startTime + }; +} + +function buildCombinedWeekBlocks(startDate, endDate, lessons) { + const dates = datesBetween(startDate, endDate); + const weekGroups = new Map(); + dates.forEach(date => { + const key = weekStart(date); + if (!weekGroups.has(key)) weekGroups.set(key, []); + weekGroups.get(key).push(date); + }); + + const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b)); + const blocks = []; + for (let index = 0; index < weekStarts.length; index += 2) { + const firstWeek = weekGroups.get(weekStarts[index]) || []; + const secondWeek = weekGroups.get(weekStarts[index + 1]) || []; + const blockDates = [...firstWeek, ...secondWeek]; + const firstParity = weekParity(firstWeek, lessons) || 'ODD'; + const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD'); + const datesWithFallback = [ + ...firstWeek.map(date => ({ date, fallbackParity: firstParity })), + ...secondWeek.map(date => ({ date, fallbackParity: secondParity })) + ]; + blocks.push({ + startDate: blockDates[0], + endDate: blockDates[blockDates.length - 1], + datesByDay: buildDatesByDay(datesWithFallback, lessons), + label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}` + }); + } + return blocks; +} + +function weekParity(dates, lessons) { + for (const date of dates) { + const parity = parityForDate(date, lessons); + if (parity) return parity; + } + return null; +} + +function buildDatesByDay(datesWithFallback, lessons) { + const datesByDay = {}; + datesWithFallback.forEach(({ date, fallbackParity }) => { + const day = isoDay(date); + const parity = parityForDate(date, lessons) || fallbackParity; + if (!datesByDay[day]) datesByDay[day] = {}; + datesByDay[day][parity] = date; + }); + return datesByDay; +} + +function parityForDate(date, lessons) { + const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity)); + if (lesson?.parity) return lesson.parity; + const withWeek = lessons.find(item => item.date === date && item.weekNumber != null); + if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD'; + return null; +} + +function combinedWeekCaption(block) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const dates = DAY_ORDER.map(day => block.datesByDay[day]?.[parity]).filter(Boolean); + if (!dates.length) return `${label}: дат нет`; + return `${label}: ${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`; + }) + .join('; '); +} + +function mobileDayCaption(datesByParity) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const date = datesByParity?.[parity]; + return `${label}: ${date ? formatDate(date) : 'даты нет'}`; + }) + .join(' · '); +} + +function periodCaption(range) { + return `${formatDate(range.startDate)} - ${formatDate(range.endDate)}`; +} + +function lessonSlotKey(lesson) { + const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`; + return value ? String(value) : 'unknown'; +} + +function dateSlotKey(date, slotKey) { + return `${date}:${slotKey}`; +} + +function weekStart(value) { + const date = parseIsoDate(value); + const day = date.getDay() || 7; + date.setDate(date.getDate() + 1 - day); + return toIsoDate(date); +} + +function twoWeekRange(value) { + const selected = parseIsoDate(value); + const start = addDays(selected, 1 - isoDay(value)); + const end = addDays(start, 13); + return { + startDate: toIsoDate(start), + endDate: toIsoDate(end) + }; +} + +function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function parseIsoDate(value) { + const [year, month, day] = String(value).split('-').map(Number); + return new Date(year, month - 1, day); +} + +function datesBetween(startDate, endDate) { + const dates = []; + for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) { + dates.push(toIsoDate(date)); + } + return dates; +} + +function isoDay(value) { + const day = parseIsoDate(value).getDay(); + return day === 0 ? 7 : day; +} + +function dayShort(dayOfWeek) { + return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; +} + +function formatDate(value) { + if (!value) return '-'; + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; +} + +function formatDateShort(value) { + const [, month, day] = String(value).split('-'); + return `${day}.${month}`; +} + +function trimTime(value) { + return String(value || '').slice(0, 5); +} + +function toIsoDate(date) { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); +} + +function lessonCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return `${count} занятий`; + if (count % 10 === 1) return `${count} занятие`; + if ([2, 3, 4].includes(count % 10)) return `${count} занятия`; + return `${count} занятий`; +} + +function sectionCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return `${count} расписаний`; + if (count % 10 === 1) return `${count} расписание`; + if ([2, 3, 4].includes(count % 10)) return `${count} расписания`; + return `${count} расписаний`; +} + +function compareSlots(a, b) { + const first = Number(a.orderNumber || a.key); + const second = Number(b.orderNumber || b.key); + if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) { + return first - second; + } + return naturalCompare(a.startTime, b.startTime) + || naturalCompare(a.key, b.key); +} + +function naturalCompare(a, b) { + return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); +} diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js old mode 100755 new mode 100644 index 57ca394..490fc83 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -1,684 +1,715 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; +const DAY_OPTIONS = [ + { value: 1, label: 'Понедельник' }, + { value: 2, label: 'Вторник' }, + { value: 3, label: 'Среда' }, + { value: 4, label: 'Четверг' }, + { value: 5, label: 'Пятница' }, + { value: 6, label: 'Суббота' }, + { value: 7, label: 'Воскресенье' } +]; + +const PARITY_LABELS = { + BOTH: 'каждую неделю', + ODD: 'нечётная', + EVEN: 'чётная' +}; + +const SEMESTER_LABELS = { + autumn: 'осенний', + spring: 'весенний' +}; + +const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; + +const LESSON_TYPE_LIMITS = [ + { + key: 'lecture', + label: 'Лекции', + hoursField: 'lectureAcademicHours', + startWeekField: 'lectureStartWeek', + hoursInputId: 'schedule-rule-lecture-hours', + startWeekInputId: 'schedule-rule-lecture-start-week' + }, + { + key: 'laboratory', + label: 'Лабораторные', + hoursField: 'laboratoryAcademicHours', + startWeekField: 'laboratoryStartWeek', + hoursInputId: 'schedule-rule-laboratory-hours', + startWeekInputId: 'schedule-rule-laboratory-start-week' + }, + { + key: 'practice', + label: 'Практики', + hoursField: 'practiceAcademicHours', + startWeekField: 'practiceStartWeek', + hoursInputId: 'schedule-rule-practice-hours', + startWeekInputId: 'schedule-rule-practice-start-week' + } +]; + export async function initSchedule() { - const tbody = document.getElementById('schedule-tbody'); - const table = document.getElementById('schedule-table'); + const ruleForm = document.getElementById('schedule-rule-form'); + const ruleFormTitle = document.getElementById('schedule-rule-form-title'); + const ruleIdInput = document.getElementById('schedule-rule-id'); + const ruleSubjectSelect = document.getElementById('schedule-rule-subject'); + const ruleSemesterSelect = document.getElementById('schedule-rule-semester'); + const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, { + hours: document.getElementById(config.hoursInputId), + startWeek: document.getElementById(config.startWeekInputId) + }])); + const ruleGroupsBox = document.getElementById('schedule-rule-groups-box'); + const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu'); + const ruleGroupsText = document.getElementById('schedule-rule-groups-text'); + const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes'); + const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); + const ruleResetButton = document.getElementById('schedule-rule-reset'); + const slotAddButton = document.getElementById('schedule-slot-add'); + const rulesTbody = document.getElementById('schedule-tbody'); + const refreshRulesButton = document.getElementById('schedule-refresh'); - let lessonsData = []; - let sortKey = null; - let sortDir = 'asc'; - - // Активные фильтры: { teacher: Set, group: Set, subject: Set, day: Set } - const activeFilters = {}; - - // Маппинг дней недели для корректной сортировки - const dayOrder = { - 'понедельник': 1, 'вторник': 2, 'среда': 3, - 'четверг': 4, 'пятница': 5, 'суббота': 6, 'воскресенье': 7 - }; - - // ===================== Фильтрация ===================== - - function getDisplayValue(lesson, key) { - switch (key) { - case 'teacher': - return lesson.teacher?.username || lesson.teacherName || '—'; - case 'group': - return lesson.group?.name || lesson.groupName || '—'; - case 'subject': - return lesson.subject?.name || lesson.subjectName || '—'; - case 'day': - return lesson.day || '—'; - case 'educationForm': - return lesson.educationForm?.name || lesson.educationFormName || '—'; - default: - return ''; - } - } - - function getUniqueValues(key) { - const vals = new Set(); - lessonsData.forEach(lesson => { - vals.add(getDisplayValue(lesson, key)); - }); - if (key === 'day') { - return [...vals].sort((a, b) => (dayOrder[a.toLowerCase()] ?? 99) - (dayOrder[b.toLowerCase()] ?? 99)); - } - return [...vals].sort((a, b) => a.localeCompare(b, 'ru')); - } - - function applyFilters(lessons) { - return lessons.filter(lesson => { - for (const key of Object.keys(activeFilters)) { - const filterSet = activeFilters[key]; - if (filterSet && filterSet.size > 0) { - const val = getDisplayValue(lesson, key); - if (!filterSet.has(val)) return false; - } - } - return true; - }); - } - - // ===================== Попап фильтра ===================== - - let currentPopup = null; - - function closePopup() { - if (currentPopup) { - currentPopup.remove(); - currentPopup = null; - } - document.removeEventListener('click', onDocumentClick, true); - } - - function onDocumentClick(e) { - if (currentPopup && !currentPopup.contains(e.target)) { - if (!e.target.closest('.filter-icon')) { - closePopup(); - } - } - } - - function openFilterPopup(th, filterKey) { - if (currentPopup && currentPopup.dataset.filterKey === filterKey) { - closePopup(); - return; - } - closePopup(); - - const uniqueValues = getUniqueValues(filterKey); - const currentFilter = activeFilters[filterKey]; - - const popup = document.createElement('div'); - popup.className = 'filter-popup'; - popup.dataset.filterKey = filterKey; - - const searchInput = document.createElement('input'); - searchInput.type = 'text'; - searchInput.className = 'filter-search'; - searchInput.placeholder = 'Поиск...'; - popup.appendChild(searchInput); - - const btnRow = document.createElement('div'); - btnRow.className = 'filter-btn-row'; - - const btnAll = document.createElement('button'); - btnAll.className = 'filter-btn-action'; - btnAll.textContent = 'Все'; - btnAll.addEventListener('click', (e) => { - e.stopPropagation(); - checkboxes.forEach(cb => { cb.checked = true; }); - }); - - const btnNone = document.createElement('button'); - btnNone.className = 'filter-btn-action'; - btnNone.textContent = 'Сбросить'; - btnNone.addEventListener('click', (e) => { - e.stopPropagation(); - checkboxes.forEach(cb => { cb.checked = false; }); - }); - - btnRow.appendChild(btnAll); - btnRow.appendChild(btnNone); - popup.appendChild(btnRow); - - const listWrap = document.createElement('div'); - listWrap.className = 'filter-list'; - - const checkboxes = []; - - uniqueValues.forEach(val => { - const label = document.createElement('label'); - label.className = 'filter-item'; - - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.value = val; - cb.checked = currentFilter ? currentFilter.has(val) : true; - - const span = document.createElement('span'); - span.textContent = val; - - label.appendChild(cb); - label.appendChild(span); - listWrap.appendChild(label); - checkboxes.push(cb); - }); - - popup.appendChild(listWrap); - - const btnApply = document.createElement('button'); - btnApply.className = 'filter-btn-apply'; - btnApply.textContent = 'Применить'; - btnApply.addEventListener('click', (e) => { - e.stopPropagation(); - const selected = new Set(); - checkboxes.forEach(cb => { - if (cb.checked) selected.add(cb.value); - }); - - if (selected.size === uniqueValues.length) { - delete activeFilters[filterKey]; - th.classList.remove('filter-active'); - } else { - activeFilters[filterKey] = selected; - th.classList.add('filter-active'); - } - - closePopup(); - renderSchedule(lessonsData); - }); - popup.appendChild(btnApply); - - searchInput.addEventListener('input', () => { - const query = searchInput.value.toLowerCase(); - listWrap.querySelectorAll('.filter-item').forEach(item => { - const text = item.querySelector('span').textContent.toLowerCase(); - item.style.display = text.includes(query) ? '' : 'none'; - }); - }); - - popup.addEventListener('click', (e) => e.stopPropagation()); - searchInput.addEventListener('click', (e) => e.stopPropagation()); - - th.style.position = 'relative'; - th.appendChild(popup); - currentPopup = popup; - - setTimeout(() => searchInput.focus(), 50); - - setTimeout(() => { - document.addEventListener('click', onDocumentClick, true); - }, 10); - } - - table.querySelectorAll('thead th.filterable').forEach(th => { - th.addEventListener('click', (e) => { - if (e.target.closest('.filter-popup')) return; - const filterKey = th.dataset.filterKey; - openFilterPopup(th, filterKey); - }); - }); - - // ===================== Сортировка ===================== - - function getSortValue(lesson, key) { - switch (key) { - case 'id': - return lesson.id ?? 0; - case 'teacher': - return (lesson.teacher?.username || lesson.teacherName || '').toLowerCase(); - case 'group': - return (lesson.group?.name || lesson.groupName || '').toLowerCase(); - case 'classroomName': - return (lesson.classroomName?.name || lesson.classroomName || '').toLowerCase(); - case 'educationForm': - return (lesson.educationForm?.name || lesson.educationFormName || '').toLowerCase(); - case 'subject': - return (lesson.subject?.name || lesson.subjectName || '').toLowerCase(); - case 'lessonFormat': - return (lesson.lessonFormat?.name || lesson.lessonFormat || '').toLowerCase(); - case 'typeLesson': - return (lesson.typeLesson?.name || lesson.typeLesson || '').toLowerCase(); - case 'day': { - const d = (lesson.day || '').toLowerCase(); - return dayOrder[d] ?? 99; - } - case 'week': - return (lesson.week || '').toLowerCase(); - case 'time': { - const d = (lesson.day || '').toLowerCase(); - const dayNum = dayOrder[d] ?? 99; - const t = lesson.time || '99:99'; - return String(dayNum).padStart(2, '0') + '_' + t; - } - default: - return ''; - } - } - - function sortLessons(lessons) { - if (!sortKey) return lessons; - - return [...lessons].sort((a, b) => { - let va = getSortValue(a, sortKey); - let vb = getSortValue(b, sortKey); - - if (typeof va === 'number' && typeof vb === 'number') { - return sortDir === 'asc' ? va - vb : vb - va; - } - - va = String(va); - vb = String(vb); - const cmp = va.localeCompare(vb, 'ru'); - return sortDir === 'asc' ? cmp : -cmp; - }); - } - - function updateSortHeaders() { - table.querySelectorAll('thead th.sortable').forEach(th => { - th.classList.remove('sort-asc', 'sort-desc', 'sort-active'); - if (th.dataset.sortKey === sortKey) { - th.classList.add('sort-active', sortDir === 'asc' ? 'sort-asc' : 'sort-desc'); - } - }); - } - - table.querySelectorAll('thead th.sortable').forEach(th => { - th.addEventListener('click', (e) => { - if (e.target.closest('.filter-icon') || e.target.closest('.filter-popup')) return; - - const key = th.dataset.sortKey; - if (sortKey === key) { - if (sortDir === 'asc') { - sortDir = 'desc'; - } else { - sortKey = null; - sortDir = 'asc'; - } - } else { - sortKey = key; - sortDir = 'asc'; - } - updateSortHeaders(); - renderSchedule(lessonsData); - }); - }); - - // ===================== Загрузка и рендер таблицы ===================== - - async function loadSchedule() { - try { - const lessons = await api.get('/api/users/lessons'); - lessonsData = lessons; - renderSchedule(lessons); - } catch (e) { - tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(e.message)}`; - } - } - - function renderSchedule(lessons) { - if (!lessons || !lessons.length) { - tbody.innerHTML = 'Нет занятий'; - return; - } - - const filtered = applyFilters(lessons); - - if (!filtered.length) { - tbody.innerHTML = 'Нет занятий по выбранным фильтрам'; - return; - } - - const sorted = sortLessons(filtered); - - tbody.innerHTML = sorted.map(lesson => { - const teacherName = lesson.teacher?.username || lesson.teacherName || '—'; - const groupName = lesson.group?.name || lesson.groupName || '—'; - const classroomName = lesson.classroom?.name || lesson.classroomName || '—'; - const educationForm = lesson.educationForm?.name || lesson.educationFormName || '-'; - const subjectName = lesson.subject?.name || lesson.subjectName || '—'; - const lessonFormat = lesson.lessonFormat?.name || lesson.lessonFormat || '—'; - const typeLesson = lesson.typeLesson?.name || lesson.typeLesson || '—'; - const day = lesson.day || '—'; - const week = lesson.week || '—'; - const time = lesson.time || '—'; - - return ` - ${escapeHtml(lesson.id)} - ${escapeHtml(teacherName)} - ${escapeHtml(groupName)} - ${escapeHtml(classroomName)} - ${escapeHtml(educationForm)} - ${escapeHtml(subjectName)} - ${escapeHtml(lessonFormat)} - ${escapeHtml(typeLesson)} - ${escapeHtml(day)} - ${escapeHtml(week)} - ${escapeHtml(time)} - `; - }).join(''); - } - - // ===================== Модалки добавления занятия ===================== - - const overlay = document.getElementById('sch-overlay'); - const modalForm = document.getElementById('sch-modal-form'); - const modalLessons = document.getElementById('sch-modal-lessons'); - const btnAddLesson = document.getElementById('sch-btn-add-lesson'); - const btnClose = document.getElementById('sch-modal-close'); - const addForm = document.getElementById('sch-add-lesson-form'); - - const schTeacherSelect = document.getElementById('sch-teacher'); - const schGroupSelect = document.getElementById('sch-group'); - const schDisciplineSelect = document.getElementById('sch-discipline'); - const schClassroomSelect = document.getElementById('sch-classroom'); - const schDaySelect = document.getElementById('sch-day'); - const schTimeSelect = document.getElementById('sch-time'); - const schTypeSelect = document.getElementById('sch-type'); - const schWeekUpper = document.getElementById('sch-week-upper'); - const schWeekLower = document.getElementById('sch-week-lower'); - const schFormatOffline = document.getElementById('sch-format-offline'); - - const schTeacherName = document.getElementById('sch-teacher-name'); - const schLessonsContainer = document.getElementById('sch-lessons-container'); - - let groups = []; + let rules = []; let subjects = []; - let classrooms = []; + let groups = []; let teachers = []; + let classrooms = []; + let lessonTypes = []; + let subgroups = []; + let timeSlots = []; + let semesters = []; - const weekdaysTimes = [ - "8:00-9:30", "9:40-11:10", "11:40-13:10", - "13:20-14:50", "15:00-16:30", "16:50-18:20", "18:30-19:00" - ]; - const saturdayTimes = [ - "8:20-9:50", "10:00-11:30", "11:40-13:10", "13:20-14:50" - ]; + bindEvents(); - // ===== Загрузка справочников ===== - async function loadGroups() { - try { - groups = await api.get('/api/groups'); - schGroupSelect.innerHTML = '' + - groups.map(g => { - let text = escapeHtml(g.name); - if (g.groupSize) text += ` (числ: ${g.groupSize} чел.)`; - return ``; - }).join(''); - } catch (e) { console.error('Ошибка загрузки групп:', e); } + try { + await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]); + renderRuleGroups([]); + renderRuleSlots([{}]); + await loadRules(); + } catch (error) { + showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); } - async function loadSubjects() { - try { - subjects = await api.get('/api/subjects'); - schDisciplineSelect.innerHTML = '' + - subjects.map(s => ``).join(''); - } catch (e) { console.error('Ошибка загрузки дисциплин:', e); } - } + function bindEvents() { + refreshRulesButton?.addEventListener('click', loadRules); + ruleResetButton?.addEventListener('click', resetRuleForm); + slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); - async function loadClassrooms() { - try { - classrooms = await api.get('/api/classrooms'); - renderClassroomOptions(); - } catch (e) { console.error('Ошибка загрузки аудиторий:', e); } - } + ruleSlotsContainer.addEventListener('click', (event) => { + const removeButton = event.target.closest('.schedule-slot-remove'); + const subgroupBox = event.target.closest('.slot-subgroup-box'); + const subgroupMenu = event.target.closest('.slot-subgroup-menu'); - async function loadTeachers() { - try { - teachers = await api.get('/api/users/teachers'); - schTeacherSelect.innerHTML = '' + - teachers.map(t => ``).join(''); - } catch (e) { console.error('Ошибка загрузки преподавателей:', e); } - } - - function renderClassroomOptions() { - if (!classrooms || classrooms.length === 0) { - schClassroomSelect.innerHTML = ''; - return; - } - const selectedGroupId = schGroupSelect.value; - const selectedGroup = groups?.find(g => g.id == selectedGroupId); - const groupSize = selectedGroup?.groupSize || 0; - - schClassroomSelect.innerHTML = '' + - classrooms.map(c => { - let text = escapeHtml(c.name); - if (c.capacity) text += ` (вместимость: ${c.capacity} чел.)`; - if (c.isAvailable === false) { - text += ` ❌ Занята`; - } else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) { - text += ` ⚠️ Недостаточно места`; - } - return ``; - }).join(''); - } - - schGroupSelect.addEventListener('change', () => renderClassroomOptions()); - - function updateTimeOptions(dayValue) { - let times = []; - if (dayValue === "Суббота") { - times = saturdayTimes; - } else if (dayValue && dayValue !== '') { - times = weekdaysTimes; - } else { - schTimeSelect.innerHTML = ''; - schTimeSelect.disabled = true; - return; - } - schTimeSelect.innerHTML = '' + - times.map(t => ``).join(''); - schTimeSelect.disabled = false; - } - - schDaySelect.addEventListener('change', function () { - updateTimeOptions(this.value); - }); - - // ===== Автозаполнение преподавателя из фильтра ===== - function getFilteredTeacherId() { - const teacherFilter = activeFilters['teacher']; - if (teacherFilter && teacherFilter.size === 1) { - const teacherName = [...teacherFilter][0]; - // Сопоставляем по username, fullName и их комбинациям - const match = teachers.find(t => - t.username === teacherName || - t.fullName === teacherName || - (t.fullName || t.username) === teacherName - ); - return match ? String(match.id) : ''; - } - return ''; - } - - // ===== Загрузка занятий преподавателя ===== - async function loadTeacherLessons(teacherId) { - const teacher = teachers.find(t => t.id == teacherId); - const name = teacher ? (teacher.fullName || teacher.username) : ''; - schTeacherName.textContent = name - ? `Занятия преподавателя: ${name}` - : 'Занятия преподавателя'; - - modalLessons.style.display = ''; - schLessonsContainer.innerHTML = '
Загрузка занятий...
'; - - try { - const lessons = await api.get(`/api/users/lessons/${teacherId}`); - - if (!lessons || !Array.isArray(lessons) || lessons.length === 0) { - schLessonsContainer.innerHTML = '
У преподавателя пока нет занятий
'; + if (removeButton) { + const slots = readRuleSlots(false); + slots.splice(Number(removeButton.dataset.index), 1); + renderRuleSlots(slots.length ? slots : [{}]); return; } - const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']; - const lessonsByDay = {}; - lessons.forEach(l => { - if (!lessonsByDay[l.day]) lessonsByDay[l.day] = []; - lessonsByDay[l.day].push(l); - }); - Object.keys(lessonsByDay).forEach(day => { - lessonsByDay[day].sort((a, b) => a.time.localeCompare(b.time)); - }); + if (subgroupBox) { + toggleSlotSubgroupMenu(event, subgroupBox); + return; + } - let html = ''; - daysOrder.forEach(day => { - if (!lessonsByDay[day]) return; - html += `
${day}
`; - lessonsByDay[day].forEach(lesson => { - html += ` -
-
- ${escapeHtml(lesson.groupName)} - ${escapeHtml(lesson.time)} -
-
-
${escapeHtml(lesson.subjectName)}
-
- ${escapeHtml(lesson.typeLesson)} - ${escapeHtml(lesson.lessonFormat)} - ${escapeHtml(lesson.week)} - ${escapeHtml(lesson.classroomName)} -
-
-
`; - }); - }); + if (subgroupMenu) { + event.stopPropagation(); + } + }); - schLessonsContainer.innerHTML = html; - } catch (e) { - schLessonsContainer.innerHTML = `
Ошибка загрузки: ${escapeHtml(e.message)}
`; + ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu); + ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown); + ruleGroupsMenu.addEventListener('click', event => event.stopPropagation()); + ruleGroupCheckboxes.addEventListener('change', () => { + updateRuleGroupsText(); + renderRuleSlots(readRuleSlots(false)); + }); + document.addEventListener('click', () => { + closeRuleGroupsMenu(); + closeSlotSubgroupMenus(); + }); + ruleSlotsContainer.addEventListener('change', (event) => { + if (event.target.closest('.slot-lesson-type')) { + renderRuleSlots(readRuleSlots(false)); + return; + } + if (event.target.closest('.slot-subgroup-checkboxes')) { + updateSlotSubgroupText(event.target.closest('.schedule-slot-row')); + } + }); + ruleForm.addEventListener('submit', saveRule); + rulesTbody.addEventListener('click', handleRuleTableClick); + } + + async function loadBaseLists() { + [subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([ + api.get('/api/subjects'), + api.get('/api/groups'), + api.get('/api/users/teachers'), + api.get('/api/classrooms'), + api.get('/api/lesson-types'), + api.get('/api/subgroups') + ]); + populateSubjectSelects(); + } + + async function loadYears() { + const academicYears = await api.get('/api/admin/calendar/years'); + semesters = academicYears.flatMap(year => year.semesters || []); + populateSemesterSelects(); + } + + async function loadRules() { + rulesTbody.innerHTML = 'Загрузка...'; + try { + rules = await api.get('/api/admin/schedule-rules'); + renderRules(); + } catch (error) { + rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } - // ===== При смене преподавателя — подгрузить его занятия ===== - schTeacherSelect.addEventListener('change', function () { - const teacherId = this.value; - if (teacherId) { - loadTeacherLessons(teacherId); - } else { - modalLessons.style.display = 'none'; - schLessonsContainer.innerHTML = '
Выберите преподавателя для просмотра занятий
'; + function renderRules() { + if (!rules.length) { + rulesTbody.innerHTML = 'Правила расписания не созданы'; + return; } - }); - - // ===== Открытие / закрытие оверлея ===== - function openOverlay() { - // Автозаполнение преподавателя из фильтра таблицы - const autoTeacherId = getFilteredTeacherId(); - if (autoTeacherId) { - schTeacherSelect.value = autoTeacherId; - loadTeacherLessons(autoTeacherId); - } - - overlay.classList.add('open'); + rulesTbody.innerHTML = rules.map(rule => ` + + ${rule.id} + ${escapeHtml(rule.subjectName || '-')} + ${escapeHtml(formatSemester(rule))} + ${escapeHtml((rule.groupNames || []).join(', ') || '-')} + ${renderRuleLoadSummary(rule)} + ${renderRuleSlotsSummary(rule.slots)} + + + + + + `).join(''); } - function closeOverlay() { - overlay.classList.remove('open'); - resetForm(); + function renderRuleSlotsSummary(slots) { + if (!slots || !slots.length) return 'Нет слотов'; + return slots.map(slot => { + const parity = PARITY_LABELS[slot.parity] || slot.parity || '-'; + const parts = [ + slot.dayName, + slot.timeSlotLabel, + parity, + slotSubgroupNames(slot).join(', '), + slot.teacherName, + slot.classroomName, + slot.lessonTypeName, + slot.lessonFormat + ].filter(Boolean); + return `
${escapeHtml(parts.join(' · '))}
`; + }).join(''); } - function resetForm() { - addForm.reset(); - schTeacherSelect.value = ''; - schGroupSelect.value = ''; - schDisciplineSelect.value = ''; - schClassroomSelect.value = ''; - schDaySelect.value = ''; - schTypeSelect.value = ''; - schTimeSelect.innerHTML = ''; - schTimeSelect.disabled = true; - if (schWeekUpper) schWeekUpper.checked = false; - if (schWeekLower) schWeekLower.checked = false; - if (schFormatOffline) schFormatOffline.checked = true; - modalLessons.style.display = 'none'; - schLessonsContainer.innerHTML = '
Выберите преподавателя для просмотра занятий
'; - hideAlert('sch-add-alert'); + function renderRuleLoadSummary(rule) { + const rows = LESSON_TYPE_LIMITS + .map(config => ({ + label: config.label, + hours: Number(rule[config.hoursField] || 0), + startWeek: Number(rule[config.startWeekField] || 1) + })) + .filter(item => item.hours > 0); + if (!rows.length) return 'Часы не указаны'; + return rows.map(item => ( + `
${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}
` + )).join(''); } - btnAddLesson.addEventListener('click', openOverlay); - btnClose.addEventListener('click', closeOverlay); + async function handleRuleTableClick(event) { + const editButton = event.target.closest('.btn-edit-rule'); + const deleteButton = event.target.closest('.btn-delete-rule'); - // Закрытие по клику на оверлей (мимо модалок) - overlay.addEventListener('click', (e) => { - if (e.target === overlay || e.target.classList.contains('cs-overlay-scroll')) { - closeOverlay(); - } - }); - - // Закрытие по Escape - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && overlay.classList.contains('open')) { - closeOverlay(); - } - }); - - // ===== Отправка формы ===== - addForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('sch-add-alert'); - - const teacherId = schTeacherSelect.value; - const groupId = schGroupSelect.value; - const subjectId = schDisciplineSelect.value; - const classroomId = schClassroomSelect.value; - const lessonType = schTypeSelect.value; - const dayOfWeek = schDaySelect.value; - const timeSlot = schTimeSelect.value; - const lessonFormat = document.querySelector('input[name="schLessonFormat"]:checked')?.value; - - if (!teacherId) { showAlert('sch-add-alert', 'Выберите преподавателя', 'error'); return; } - if (!groupId) { showAlert('sch-add-alert', 'Выберите группу', 'error'); return; } - if (!subjectId) { showAlert('sch-add-alert', 'Выберите дисциплину', 'error'); return; } - if (!classroomId) { showAlert('sch-add-alert', 'Выберите аудиторию', 'error'); return; } - if (!dayOfWeek) { showAlert('sch-add-alert', 'Выберите день недели', 'error'); return; } - if (!timeSlot) { showAlert('sch-add-alert', 'Выберите время', 'error'); return; } - - const weekUpperChecked = schWeekUpper?.checked || false; - const weekLowerChecked = schWeekLower?.checked || false; - - if (!weekUpperChecked && !weekLowerChecked) { - showAlert('sch-add-alert', 'Не выбран тип недели', 'error'); + if (editButton) { + const rule = rules.find(item => item.id == editButton.dataset.id) + || await api.get('/api/admin/schedule-rules/' + editButton.dataset.id); + fillRuleForm(rule); return; } - let weekType = null; - if (weekUpperChecked && weekLowerChecked) weekType = 'Обе'; - else if (weekUpperChecked) weekType = 'Верхняя'; - else if (weekLowerChecked) weekType = 'Нижняя'; - - try { - await api.post('/api/users/lessons/create', { - teacherId: parseInt(teacherId), - groupId: parseInt(groupId), - subjectId: parseInt(subjectId), - classroomId: parseInt(classroomId), - typeLesson: lessonType, - lessonFormat: lessonFormat, - day: dayOfWeek, - week: weekType, - time: timeSlot - }); - - showAlert('sch-add-alert', 'Занятие добавлено ✓', 'success'); - - // Очистить все поля кроме преподавателя (для массового добавления) - schGroupSelect.selectedIndex = 0; - schDisciplineSelect.selectedIndex = 0; - schClassroomSelect.selectedIndex = 0; - schTypeSelect.selectedIndex = 0; - schDaySelect.selectedIndex = 0; - schTimeSelect.innerHTML = ''; - schTimeSelect.disabled = true; - schWeekUpper.checked = false; - schWeekLower.checked = false; - document.querySelector('input[name="schLessonFormat"][value="Очно"]').checked = true; - - // Обновить занятия преподавателя в модалке 2 - if (teacherId) { - await loadTeacherLessons(teacherId); + if (deleteButton) { + if (!confirm('Удалить правило расписания?')) return; + try { + await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id); + showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); + await loadRules(); + } catch (error) { + showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error'); } - - // Обновить основную таблицу - await loadSchedule(); - - setTimeout(() => { - hideAlert('sch-add-alert'); - }, 4000); - } catch (err) { - showAlert('sch-add-alert', err.message || 'Ошибка добавления занятия', 'error'); } - }); + } - // ===================== Инициализация ===================== - await Promise.all([ - loadSchedule(), - loadGroups(), - loadSubjects(), - loadClassrooms(), - loadTeachers() - ]); -} \ No newline at end of file + async function saveRule(event) { + event.preventDefault(); + hideAlert('schedule-rule-alert'); + try { + const payload = buildRulePayload(); + const id = ruleIdInput.value; + const saved = id + ? await api.put('/api/admin/schedule-rules/' + id, payload) + : await api.post('/api/admin/schedule-rules', payload); + showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success'); + resetRuleForm(); + await loadRules(); + } catch (error) { + showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error'); + } + } + + function buildRulePayload() { + const groupIds = selectedRuleGroupIds(); + const slots = readRuleSlots(true); + if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); + if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); + const typeLimits = readTypeLimits(); + validateTypeLimits(typeLimits, slots); + if (!groupIds.length) throw new Error('Выберите хотя бы одну группу'); + if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия'); + return { + id: ruleIdInput.value ? Number(ruleIdInput.value) : null, + subjectId: Number(ruleSubjectSelect.value), + semesterId: Number(ruleSemesterSelect.value), + ...typeLimits, + groupIds, + slots + }; + } + + function fillRuleForm(rule) { + ruleFormTitle.textContent = 'Редактирование правила расписания'; + ruleIdInput.value = rule.id; + ruleSubjectSelect.value = rule.subjectId || ''; + ruleSemesterSelect.value = rule.semesterId || ''; + fillTypeLimits(rule); + syncSelects(ruleSubjectSelect, ruleSemesterSelect); + renderRuleGroups(rule.groupIds || []); + renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]); + hideAlert('schedule-rule-alert'); + ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function resetRuleForm() { + ruleForm.reset(); + ruleFormTitle.textContent = 'Новое правило расписания'; + ruleIdInput.value = ''; + fillTypeLimits({}); + renderRuleGroups([]); + renderRuleSlots([{}]); + syncSelects(ruleSubjectSelect, ruleSemesterSelect); + hideAlert('schedule-rule-alert'); + } + + function renderRuleGroups(selectedIds) { + if (!groups.length) { + ruleGroupCheckboxes.innerHTML = '
Нет групп
'; + updateRuleGroupsText(); + return; + } + const selected = new Set((selectedIds || []).map(String)); + ruleGroupCheckboxes.innerHTML = groups.map(group => ` + + `).join(''); + updateRuleGroupsText(); + } + + function renderRuleSlots(slots) { + ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => ` +
+
+
+
+
+
+
+ ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''} +
+ +
+ `).join(''); + } + + function readRuleSlots(validate) { + const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); + return rows.map((row, index) => { + const subgroupIds = readSlotSubgroupIds(row); + const slot = { + id: null, + dayOfWeek: Number(row.querySelector('.slot-day').value), + parity: row.querySelector('.slot-parity').value, + timeSlotId: Number(row.querySelector('.slot-time').value), + subgroupId: subgroupIds[0] || null, + subgroupIds, + teacherId: Number(row.querySelector('.slot-teacher').value), + classroomId: Number(row.querySelector('.slot-classroom').value), + lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), + lessonFormat: row.querySelector('.slot-format').value + }; + if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId + || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) { + throw new Error(`Заполните слот ${index + 1}`); + } + if (validate) { + validateSlotSubgroup(slot, index); + } + return slot; + }); + } + + function readTypeLimits() { + return LESSON_TYPE_LIMITS.reduce((payload, config) => { + const inputs = ruleTypeInputs[config.key]; + payload[config.hoursField] = Number(inputs.hours.value || 0); + payload[config.startWeekField] = Number(inputs.startWeek.value || 1); + return payload; + }, {}); + } + + function fillTypeLimits(rule) { + LESSON_TYPE_LIMITS.forEach(config => { + const inputs = ruleTypeInputs[config.key]; + inputs.hours.value = rule[config.hoursField] ?? 0; + inputs.startWeek.value = rule[config.startWeekField] ?? 1; + }); + } + + function validateTypeLimits(typeLimits, slots) { + const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean)); + let hasHours = false; + for (const config of LESSON_TYPE_LIMITS) { + const hours = typeLimits[config.hoursField]; + const startWeek = typeLimits[config.startWeekField]; + if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным'); + if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля'); + if (hours > 0) hasHours = true; + if (hours > 0 && !selectedTypeKeys.has(config.key)) { + throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`); + } + if (hours === 0 && selectedTypeKeys.has(config.key)) { + throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`); + } + } + if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий'); + } + + function validateSlotSubgroup(slot, index) { + const typeKey = lessonTypeKeyById(slot.lessonTypeId); + const subgroupIds = slotSubgroupIds(slot); + if (subgroupIds.length && typeKey !== 'laboratory') { + throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`); + } + if (!subgroupIds.length) return; + + const selectedGroups = new Set(selectedRuleGroupIds().map(String)); + const subgroupGroupIds = new Set(); + for (const subgroupId of subgroupIds) { + const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId)); + if (!selectedSubgroup) { + throw new Error(`В слоте ${index + 1} подгруппа не найдена`); + } + if (!selectedGroups.has(String(selectedSubgroup.groupId))) { + throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`); + } + if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) { + throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`); + } + subgroupGroupIds.add(String(selectedSubgroup.groupId)); + } + } + + async function loadTimeSlots() { + const slots = await api.get('/api/admin/time-slots'); + timeSlots = slots + .filter(slot => slot.scopeApplyMode === 'DEFAULT') + .sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0)); + } + + function populateSubjectSelects() { + ruleSubjectSelect.innerHTML = '' + + subjects.map(subject => ``).join(''); + syncSelects(ruleSubjectSelect); + } + + function populateSemesterSelects() { + const semesterOptions = '' + + semesters.map(semester => ``).join(''); + ruleSemesterSelect.innerHTML = semesterOptions; + syncSelects(ruleSemesterSelect); + } + + function options(items, selectedValue) { + const selected = selectedValue == null ? '' : String(selectedValue); + return '' + items.map(item => { + const value = String(item.value); + return ``; + }).join(''); + } + + function subgroupOptions(slot) { + if (!isLaboratorySlot(slot)) { + return ''; + } + const selectedGroupIds = selectedRuleGroupIds(); + if (selectedGroupIds.length !== 1) { + return ''; + } + const selected = slotSubgroupIds(slot)[0] == null + ? WHOLE_GROUP_SUBGROUP_VALUE + : String(slotSubgroupIds(slot)[0]); + const selectedGroups = new Set(selectedGroupIds.map(String)); + const matchingSubgroups = subgroups + .filter(subgroup => selectedGroups.has(String(subgroup.groupId))) + .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); + return `` + matchingSubgroups.map(subgroup => { + const value = String(subgroup.id); + return ``; + }).join(''); + } + + function renderSubgroupField(slot, index) { + return selectedRuleGroupIds().length > 1 + ? renderSubgroupMultiSelect(slot, index) + : `
`; + } + + function renderSubgroupMultiSelect(slot, index) { + const selected = new Set(slotSubgroupIds(slot).map(String)); + const selectedGroups = new Set(selectedRuleGroupIds().map(String)); + const matchingSubgroups = subgroups + .filter(subgroup => selectedGroups.has(String(subgroup.groupId))) + .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); + const items = matchingSubgroups.length + ? matchingSubgroups.map(subgroup => { + const value = String(subgroup.id); + return ` + + `; + }).join('') + : '
Нет подгрупп выбранных групп
'; + + return ` +
+ +
+ + +
+
+ `; + } + + function readSlotSubgroupIds(row) { + const subgroupValue = row.querySelector('.slot-subgroup')?.value || ''; + const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked')) + .map(input => Number(input.value)) + .filter(Boolean); + return checkboxValues.length + ? checkboxValues + : (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []); + } + + function slotSubgroupIds(slot) { + if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) { + return slot.subgroupIds.map(Number).filter(Boolean); + } + return slot.subgroupId ? [Number(slot.subgroupId)] : []; + } + + function slotSubgroupNames(slot) { + if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) { + return slot.subgroupNames.filter(Boolean); + } + return slot.subgroupName ? [slot.subgroupName] : []; + } + + function slotSubgroupText(slot) { + const selectedIds = slotSubgroupIds(slot); + if (!selectedIds.length) return 'Все выбранные группы'; + if (selectedIds.length === 1) { + const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0])); + return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа'; + } + return `Выбрано подгрупп: ${selectedIds.length}`; + } + + function selectedRuleGroupIds() { + return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked')) + .map(input => Number(input.value)); + } + + function toggleRuleGroupsMenu(event) { + event.stopPropagation(); + const isOpen = ruleGroupsMenu.classList.contains('open'); + document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open')); + document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active')); + if (isOpen) { + closeRuleGroupsMenu(); + return; + } + ruleGroupsMenu.classList.add('open'); + ruleGroupsBox.classList.add('active'); + ruleGroupsBox.setAttribute('aria-expanded', 'true'); + } + + function handleRuleGroupsKeydown(event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleRuleGroupsMenu(event); + } + if (event.key === 'Escape') { + closeRuleGroupsMenu(); + } + } + + function closeRuleGroupsMenu() { + ruleGroupsMenu.classList.remove('open'); + ruleGroupsBox.classList.remove('active'); + ruleGroupsBox.setAttribute('aria-expanded', 'false'); + } + + function toggleSlotSubgroupMenu(event, box) { + event.stopPropagation(); + const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu'); + if (!menu) return; + const isOpen = menu.classList.contains('open'); + document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open')); + document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active')); + if (isOpen) { + box.setAttribute('aria-expanded', 'false'); + return; + } + menu.classList.add('open'); + box.classList.add('active'); + box.setAttribute('aria-expanded', 'true'); + } + + function closeSlotSubgroupMenus() { + ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open')); + ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => { + box.classList.remove('active'); + box.setAttribute('aria-expanded', 'false'); + }); + } + + function updateSlotSubgroupText(row) { + if (!row) return; + const text = row.querySelector('.slot-subgroup-box .select-text'); + if (!text) return; + const selectedIds = readSlotSubgroupIds(row); + if (!selectedIds.length) { + text.textContent = 'Все выбранные группы'; + return; + } + if (selectedIds.length === 1) { + const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0])); + text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа'; + return; + } + text.textContent = `Выбрано подгрупп: ${selectedIds.length}`; + } + + function updateRuleGroupsText() { + const selectedIds = selectedRuleGroupIds(); + if (!groups.length) { + ruleGroupsText.textContent = 'Нет групп'; + return; + } + if (!selectedIds.length) { + ruleGroupsText.textContent = 'Выберите группы...'; + return; + } + if (selectedIds.length === 1) { + const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0])); + ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа'; + return; + } + ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`; + } + + function toTimeSlotOption(slot) { + return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; + } + + function toTeacherOption(teacher) { + return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` }; + } + + function toClassroomOption(classroom) { + const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : ''; + return { value: classroom.id, label: `${classroom.name}${capacity}` }; + } + + function toLessonTypeOption(lessonType) { + return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; + } + + function lessonTypeKeyById(lessonTypeId) { + const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId)); + return lessonType ? lessonTypeKey(lessonType) : null; + } + + function isLaboratorySlot(slot) { + return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory'; + } + + function subgroupLabel(subgroup) { + return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`; + } + + function lessonTypeKey(lessonType) { + const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase(); + if (name.includes('лекц')) return 'lecture'; + if (name.includes('лаб')) return 'laboratory'; + if (name.includes('практ')) return 'practice'; + return null; + } + + function semesterLabel(semester) { + const type = normalizeSemesterType(semester.semesterType); + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; + } + + function formatSemester(rule) { + const type = normalizeSemesterType(rule.semesterType); + return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`; + } + + function normalizeSemesterType(type) { + return String(type || '').toLowerCase(); + } + + function trimTime(value) { + if (!value) return ''; + return String(value).slice(0, 5); + } + + function syncSelects(...selects) { + selects.filter(Boolean).forEach(select => { + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } +} diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js old mode 100755 new mode 100644 index 2189442..5fc555d --- a/frontend/admin/js/views/users.js +++ b/frontend/admin/js/views/users.js @@ -1,180 +1,41 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; -const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' }; -const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' }; +const ROLE_LABELS = { + ADMIN: 'Администратор', + EDUCATION_OFFICE: 'Учебный отдел', + DEPARTMENT: 'Кафедра', + SCHEDULE_VIEWER: 'Просмотр расписаний', + TEACHER: 'Преподаватель', + STUDENT: 'Студент' +}; +const ROLE_BADGE = { + ADMIN: 'badge-admin', + EDUCATION_OFFICE: 'badge-admin', + DEPARTMENT: 'badge-teacher', + SCHEDULE_VIEWER: 'badge-student', + TEACHER: 'badge-teacher', + STUDENT: 'badge-student' +}; export async function initUsers() { const usersTbody = document.getElementById('users-tbody'); const createForm = document.getElementById('create-form'); - // ===== Оверлей (cs-overlay) ===== - const usersOverlay = document.getElementById('users-overlay'); - - // ===== 1-е модальное окно: Добавить занятие ===== - const modalAddLesson = document.getElementById('modal-add-lesson'); - const modalAddLessonClose = document.getElementById('modal-add-lesson-close'); - const addLessonForm = document.getElementById('add-lesson-form'); - - const lessonGroupSelect = document.getElementById('lesson-group'); - const lessonDisciplineSelect = document.getElementById('lesson-discipline'); - const lessonClassroomSelect = document.getElementById('lesson-classroom'); - const lessonTypeSelect = document.getElementById('lesson-type'); - const lessonOnlineFormat = document.getElementById('format-online'); - const lessonOfflineFormat = document.getElementById('format-offline'); - const lessonUserId = document.getElementById('lesson-user-id'); - const lessonDaySelect = document.getElementById('lesson-day'); - const weekUpper = document.getElementById('week-upper'); - const weekLower = document.getElementById('week-lower'); - const lessonTimeSelect = document.getElementById('lesson-time'); - - // ===== 2-е модальное окно: Просмотр занятий ===== - const modalViewLessons = document.getElementById('modal-view-lessons'); - const lessonsContainer = document.getElementById('lessons-container'); - const modalTeacherName = document.getElementById('modal-teacher-name'); - - let currentLessonsTeacherId = null; - let currentLessonsTeacherName = ''; - // ===== Данные ===== - let groups = []; - let subjects = []; - let classrooms = []; - - const weekdaysTimes = [ - "8:00-9:30", - "9:40-11:10", - "11:40-13:10", - "13:20-14:50", - "15:00-16:30", - "16:50-18:20", - "18:30-19:00" - ]; - - const saturdayTimes = [ - "8:20-9:50", - "10:00-11:30", - "11:40-13:10", - "13:20-14:50" - ]; - - // ========================================================= - // Загрузка справочников - // ========================================================= - async function loadGroups() { - try { - groups = await api.get('/api/groups'); - renderGroupOptions(); - } catch (e) { - console.error('Ошибка загрузки групп:', e); - } - } - - async function loadSubjects() { - try { - subjects = await api.get('/api/subjects'); - renderSubjectOptions(); - } catch (e) { - console.error('Ошибка загрузки дисциплин:', e); - } - } - - async function loadClassrooms() { - try { - classrooms = await api.get('/api/classrooms'); - renderClassroomsOptions(); - } catch (e) { - console.error('Ошибка загрузки аудиторий:', e); - } - } - - function renderGroupOptions() { - if (!groups || groups.length === 0) { - lessonGroupSelect.innerHTML = ''; - return; - } - - lessonGroupSelect.innerHTML = - '' + - groups.map(g => { - let optionText = escapeHtml(g.name); - if (g.groupSize) optionText += ` (численность: ${g.groupSize} чел.)`; - return ``; - }).join(''); - } - - function renderSubjectOptions() { - lessonDisciplineSelect.innerHTML = - '' + - subjects.map(s => ``).join(''); - } - - function renderClassroomsOptions() { - if (!classrooms || classrooms.length === 0) { - lessonClassroomSelect.innerHTML = ''; - return; - } - - const selectedGroupId = lessonGroupSelect.value; - const selectedGroup = groups?.find(g => g.id == selectedGroupId); - const groupSize = selectedGroup?.groupSize || 0; - - lessonClassroomSelect.innerHTML = - '' + - classrooms.map(c => { - let optionText = escapeHtml(c.name); - - if (c.capacity) optionText += ` (вместимость: ${c.capacity} чел.)`; - - if (c.isAvailable === false) { - optionText += ` ❌ Занята`; - } else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) { - optionText += ` ⚠️ Недостаточно места`; - } - - return ``; - }).join(''); - } - - lessonGroupSelect.addEventListener('change', function () { - renderClassroomsOptions(); - requestAnimationFrame(() => syncAddLessonHeight()); - }); - - function updateTimeOptions(dayValue) { - let times = []; - if (dayValue === "Суббота") { - times = saturdayTimes; - } else if (dayValue && dayValue !== '') { - times = weekdaysTimes; - } else { - lessonTimeSelect.innerHTML = ''; - lessonTimeSelect.disabled = true; - return; - } - - lessonTimeSelect.innerHTML = - '' + - times.map(t => ``).join(''); - lessonTimeSelect.disabled = false; - } - - // ========================================================= - // Пользователи - // ========================================================= async function loadUsers() { try { - const users = await api.get('/api/users'); + const users = await api.get('/api/users?includeArchived=true'); renderUsers(users); } catch (e) { usersTbody.innerHTML = - 'Ошибка загрузки: ' + + 'Ошибка загрузки: ' + escapeHtml(e.message) + ''; } } function renderUsers(users) { if (!users || !users.length) { - usersTbody.innerHTML = 'Нет пользователей'; + usersTbody.innerHTML = 'Нет пользователей'; return; } @@ -184,148 +45,17 @@ export async function initUsers() { ${escapeHtml(u.username)} ${escapeHtml(u.fullName || '-')} ${escapeHtml(u.jobTitle || '-')} - ${u.departmentName || '-'} + ${escapeHtml(u.departmentName || '-')} ${ROLE_LABELS[u.role] || escapeHtml(u.role)} - - - - + ${u.status === 'ARCHIVED' + ? `` + : ``} `).join(''); } - // ===== Открытие / закрытие оверлея ===== - function openOverlay() { - if (usersOverlay) usersOverlay.classList.add('open'); - } - function closeOverlay() { - if (usersOverlay) usersOverlay.classList.remove('open'); - if (modalViewLessons) modalViewLessons.style.display = 'none'; - resetLessonForm(); - } - - // ========================================================= - // 1-я модалка: добавление занятия - // ========================================================= - function resetLessonForm() { - addLessonForm.reset(); - lessonUserId.value = ''; - - if (weekUpper) weekUpper.checked = false; - if (weekLower) weekLower.checked = false; - - if (lessonOfflineFormat) lessonOfflineFormat.checked = true; - if (lessonOnlineFormat) lessonOnlineFormat.checked = false; - - lessonTimeSelect.innerHTML = ''; - lessonTimeSelect.disabled = true; - - hideAlert('add-lesson-alert'); - } - - function openAddLessonModal(userId) { - lessonUserId.value = userId; - - lessonDaySelect.value = ''; - updateTimeOptions(''); - - openOverlay(); - } - - addLessonForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('add-lesson-alert'); - - const userId = lessonUserId.value; - const groupId = lessonGroupSelect.value; - const subjectId = lessonDisciplineSelect.value; - const classroomId = lessonClassroomSelect.value; - const lessonType = lessonTypeSelect.value; - const dayOfWeek = lessonDaySelect.value; - const timeSlot = lessonTimeSelect.value; - - const lessonFormat = document.querySelector('input[name="lessonFormat"]:checked')?.value; - - if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); return; } - if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); return; } - if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); return; } - if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); return; } - if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); return; } - - const weekUpperChecked = weekUpper?.checked || false; - const weekLowerChecked = weekLower?.checked || false; - - if (!weekUpperChecked && !weekLowerChecked) { - showAlert('add-lesson-alert', 'Не выбран тип недели', 'error'); - return; - } - - let weekType = null; - if (weekUpperChecked && weekLowerChecked) weekType = 'Обе'; - else if (weekUpperChecked) weekType = 'Верхняя'; - else if (weekLowerChecked) weekType = 'Нижняя'; - - try { - await api.post('/api/users/lessons/create', { - teacherId: parseInt(userId), - groupId: parseInt(groupId), - subjectId: parseInt(subjectId), - classroomId: parseInt(classroomId), - typeLesson: lessonType, - lessonFormat: lessonFormat, - day: dayOfWeek, - week: weekType, - time: timeSlot - }); - - if (modalViewLessons?.style.display !== 'none' && currentLessonsTeacherId == userId) { - await loadTeacherLessons(currentLessonsTeacherId, currentLessonsTeacherName); - } - - showAlert('add-lesson-alert', 'Занятие добавлено ✓', 'success'); - - lessonGroupSelect.selectedIndex = 0; - lessonDisciplineSelect.selectedIndex = 0; - lessonClassroomSelect.selectedIndex = 0; - lessonTypeSelect.selectedIndex = 0; - lessonDaySelect.selectedIndex = 0; - lessonTimeSelect.innerHTML = ''; - lessonTimeSelect.disabled = true; - - weekUpper.checked = false; - weekLower.checked = false; - document.querySelector('input[name="lessonFormat"][value="Очно"]').checked = true; - - setTimeout(() => { - hideAlert('add-lesson-alert'); - }, 3000); - } catch (err) { - showAlert('add-lesson-alert', err.message || 'Ошибка добавления занятия', 'error'); - } - }); - - lessonDaySelect.addEventListener('change', function () { - updateTimeOptions(this.value); - }); - - if (modalAddLessonClose) { - modalAddLessonClose.addEventListener('click', () => closeOverlay()); - } - - // Клик по оверлею (мимо модалок) закрывает всё - if (usersOverlay) { - usersOverlay.querySelector('.cs-overlay-scroll')?.addEventListener('click', (e) => { - if (e.target.classList.contains('cs-overlay-scroll')) { - closeOverlay(); - } - }); - } - - // ========================================================= - // Создание пользователя - // ========================================================= createForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-alert'); @@ -343,9 +73,9 @@ export async function initUsers() { } try { - const data = await api.post('/api/users', { - username, - password, + const data = await api.post('/api/users', { + username, + password, role, fullName, jobTitle, @@ -359,133 +89,30 @@ export async function initUsers() { } }); - // ========================================================= - // Инициализация - // ========================================================= - await Promise.all([loadUsers(), loadGroups(), loadSubjects(), loadClassrooms()]); - - // ========================================================= - // 2-я модалка: просмотр занятий - // ========================================================= - async function loadTeacherLessons(teacherId, teacherName) { - try { - lessonsContainer.innerHTML = '
Загрузка занятий...
'; - - modalTeacherName.textContent = teacherName - ? `Занятия преподавателя: ${teacherName}` - : 'Занятия преподавателя'; - - const lessons = await api.get(`/api/users/lessons/${teacherId}`); - - if (!lessons || lessons.length === 0) { - lessonsContainer.innerHTML = '
У преподавателя пока нет занятий
'; - return; - } - - const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']; - const lessonsByDay = {}; - - lessons.forEach(lesson => { - if (!lessonsByDay[lesson.day]) lessonsByDay[lesson.day] = []; - lessonsByDay[lesson.day].push(lesson); - }); - - Object.keys(lessonsByDay).forEach(day => { - lessonsByDay[day].sort((a, b) => a.time.localeCompare(b.time)); - }); - - let html = ''; - - daysOrder.forEach(day => { - if (!lessonsByDay[day]) return; - - html += `
${day}
`; - - lessonsByDay[day].forEach(lesson => { - html += ` -
-
- ${escapeHtml(lesson.groupName)} - ${escapeHtml(lesson.time)} -
-
-
${escapeHtml(lesson.subjectName)}
-
- ${escapeHtml(lesson.typeLesson)} - ${escapeHtml(lesson.lessonFormat)} - ${escapeHtml(lesson.week)} - ${escapeHtml(lesson.classroomName)} -
-
-
- `; - }); - }); - - lessonsContainer.innerHTML = html; - } catch (e) { - lessonsContainer.innerHTML = `
Ошибка загрузки: ${escapeHtml(e.message)}
`; - console.error('Ошибка загрузки занятий:', e); - } - } - - function openViewLessonsModal(teacherId, teacherName) { - currentLessonsTeacherId = teacherId; - currentLessonsTeacherName = teacherName || ''; - - if (modalViewLessons) modalViewLessons.style.display = ''; - loadTeacherLessons(teacherId, teacherName); - } - - function closeViewLessonsModal() { - if (modalViewLessons) modalViewLessons.style.display = 'none'; - currentLessonsTeacherId = null; - currentLessonsTeacherName = ''; - } - - document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - if (usersOverlay?.classList.contains('open')) { - closeOverlay(); - } - }); - - // ========================================================= - // ЕДИНЫЙ обработчик кликов по таблице (ВАЖНО: без дубля) - // ========================================================= usersTbody.addEventListener('click', async (e) => { const deleteBtn = e.target.closest('.btn-delete'); - if (deleteBtn) { - if (!confirm('Удалить пользователя?')) return; - try { - await api.delete('/api/users/' + deleteBtn.dataset.id); - loadUsers(); - } catch (err) { - alert(err.message || 'Ошибка удаления'); - } - return; - } + if (!deleteBtn) return; - const addLessonBtn = e.target.closest('.btn-add-lesson'); - if (addLessonBtn) { - e.preventDefault(); - - const teacherId = addLessonBtn.dataset.id; - const teacherName = addLessonBtn.dataset.name; - - openAddLessonModal(teacherId); - openViewLessonsModal(teacherId, teacherName); - - return; - } - - const viewLessonsBtn = e.target.closest('.btn-view-lessons'); - if (viewLessonsBtn) { - e.preventDefault(); - const teacherId = viewLessonsBtn.dataset.id; - const teacherName = viewLessonsBtn.dataset.name; - openViewLessonsModal(teacherId, teacherName); - return; + if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return; + try { + await api.delete('/api/users/' + deleteBtn.dataset.id); + loadUsers(); + } catch (err) { + alert(err.message || 'Ошибка архивирования'); } }); + + usersTbody.addEventListener('click', async (e) => { + const restoreBtn = e.target.closest('.btn-restore-user'); + if (!restoreBtn) return; + + try { + await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {}); + loadUsers(); + } catch (err) { + alert(err.message || 'Ошибка восстановления'); + } + }); + + await loadUsers(); } diff --git a/frontend/admin/settings/css/layout.css b/frontend/admin/settings/css/layout.css index 0c66136..7261429 100644 --- a/frontend/admin/settings/css/layout.css +++ b/frontend/admin/settings/css/layout.css @@ -72,6 +72,10 @@ overflow: hidden; } +.nav-item[hidden] { + display: none !important; +} + .nav-item::before { content: ''; position: absolute; diff --git a/frontend/admin/settings/css/main.css b/frontend/admin/settings/css/main.css index f491eef..8406f80 100644 --- a/frontend/admin/settings/css/main.css +++ b/frontend/admin/settings/css/main.css @@ -1,4 +1,4 @@ -/* ===== Reset & Base ===== */ +/* ===== Сброс и база ===== */ *, *::before, *::after { @@ -34,7 +34,7 @@ --transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } -/* ===== Light Theme ===== */ +/* ===== Светлая тема ===== */ [data-theme="light"] { --bg-primary: #f8fafc; --bg-sidebar: rgba(255, 255, 255, 0.7); @@ -69,13 +69,13 @@ body { transition: background 0.4s ease, color 0.4s ease; } -/* ===== Animations ===== */ +/* ===== Анимации ===== */ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } -/* ===== Theme Toggle ===== */ +/* ===== Переключатель темы ===== */ .theme-toggle { width: 42px; height: 42px; @@ -116,7 +116,7 @@ body { right: 1.25rem; } -/* ===== Settings Placeholder ===== */ +/* ===== Заглушка настроек ===== */ .settings-placeholder { display: flex; flex-direction: column; @@ -151,3 +151,484 @@ body { max-width: 400px; line-height: 1.6; } + +/* ===== Рабочие области настроек ===== */ +.settings-section { + display: flex; + flex-direction: column; + gap: 1.25rem; + animation: fadeIn 0.25s ease both; +} + +.settings-header-row, +.card-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.settings-header-row h2, +.settings-card h3 { + margin: 0; + color: var(--text-primary); +} + +.settings-header-row h2 { + font-size: 1.35rem; + font-weight: 700; +} + +.settings-header-row p { + margin-top: 0.25rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.settings-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); + gap: 1.25rem; + align-items: start; +} + +.settings-grid-tables { + grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.9fr); +} + +.settings-card { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-card); + padding: 1rem; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.settings-card-compact { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.settings-form { + display: grid; + grid-template-columns: minmax(160px, 1.3fr) minmax(100px, 0.7fr) repeat(3, minmax(120px, 1fr)); + gap: 0.85rem; + margin-top: 1rem; + align-items: end; +} + +.settings-form-compact { + grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; +} + +.settings-form-single { + grid-template-columns: minmax(240px, 1fr); +} + +.settings-form-assignment { + grid-template-columns: minmax(150px, 0.8fr) minmax(190px, 1fr) auto; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.4rem; + color: var(--text-secondary); + font-size: 0.82rem; + font-weight: 600; +} + +.form-field input, +.form-field select { + width: 100%; + min-height: 42px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + padding: 0 0.75rem; + font: inherit; + outline: none; + transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); +} + +.form-field input:focus, +.form-field select:focus { + border-color: var(--accent); + background: var(--bg-input-focus); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); +} + +.native-select-control { + cursor: pointer; +} + +.btn-primary, +.btn-secondary, +.btn-delete { + min-height: 40px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 0 0.9rem; + color: var(--text-primary); + font: inherit; + font-weight: 700; + cursor: pointer; + transition: transform var(--transition), background var(--transition), border-color var(--transition); + white-space: nowrap; +} + +.btn-primary { + background: var(--accent); +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--bg-input); + border-color: var(--bg-card-border); +} + +.btn-secondary:hover { + background: var(--bg-hover); + transform: translateY(-1px); +} + +.btn-delete { + background: rgba(239, 68, 68, 0.13); + border-color: rgba(239, 68, 68, 0.25); + color: #fecaca; +} + +[data-theme="light"] .btn-delete { + color: #991b1b; +} + +.btn-delete:hover { + background: rgba(239, 68, 68, 0.2); + transform: translateY(-1px); +} + +.btn-primary:disabled, +.btn-secondary:disabled, +.btn-delete:disabled { + cursor: not-allowed; + opacity: 0.45; + transform: none; +} + +.form-alert { + grid-column: 1 / -1; + min-height: 0; + border-radius: var(--radius-sm); + font-size: 0.9rem; +} + +.form-alert.success, +.form-alert.error { + padding: 0.75rem 0.9rem; +} + +.form-alert.success { + color: var(--success); + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.24); +} + +.form-alert.error { + color: var(--error); + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.24); +} + +.scope-summary { + display: flex; + align-items: center; + gap: 0.7rem; + margin-top: 1rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + padding: 0.75rem; + background: rgba(255, 255, 255, 0.02); + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.45; +} + +.scope-header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.6rem; +} + +.table-wrap { + overflow-x: auto; +} + +.settings-table { + width: 100%; + border-collapse: collapse; + min-width: 680px; +} + +.settings-table-compact { + min-width: 420px; +} + +.settings-table th, +.settings-table td { + border-bottom: 1px solid var(--bg-card-border); + padding: 0.7rem 0.75rem; + text-align: left; + vertical-align: middle; +} + +.settings-table th { + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0; +} + +.settings-table td { + color: var(--text-primary); + font-size: 0.92rem; +} + +.settings-table tr:last-child td { + border-bottom: none; +} + +.loading-row { + text-align: center; + color: var(--text-secondary); + padding: 1.25rem !important; +} + +.scope-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + min-height: 28px; + border-radius: 999px; + background: rgba(236, 72, 153, 0.14); + color: #f9a8d4; + border: 1px solid rgba(236, 72, 153, 0.25); + font-weight: 800; +} + +.scope-badge-base { + min-width: 86px; + background: rgba(139, 92, 246, 0.15); + color: var(--accent-hover); + border-color: rgba(139, 92, 246, 0.28); +} + +.actions-cell { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.custom-select-wrapper { + position: relative; + width: 100%; +} + +.select-layer-active { + position: relative; + z-index: 300; +} + +.custom-select-trigger { + width: 100%; + min-height: 42px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + padding: 0 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + cursor: pointer; + transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); +} + +.custom-select-trigger:hover, +.custom-select-trigger:focus, +.custom-select-wrapper.open .custom-select-trigger { + border-color: var(--accent); + background: var(--bg-input-focus); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); +} + +.custom-select-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.custom-select-icon { + display: flex; + color: var(--text-secondary); + transition: transform var(--transition); +} + +.custom-select-wrapper.open .custom-select-icon { + transform: rotate(180deg); +} + +.custom-select-menu { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 0.35rem; + list-style: none; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: rgba(10, 10, 15, 0.96); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.24); + opacity: 0; + visibility: hidden; + transform: translateY(-4px); + transition: opacity var(--transition), transform var(--transition), visibility var(--transition); + z-index: 200; +} + +[data-theme="light"] .custom-select-menu { + background: rgba(255, 255, 255, 0.98); +} + +.custom-select-wrapper.open .custom-select-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.custom-select-item { + padding: 0.65rem 0.7rem; + border-radius: calc(var(--radius-sm) - 2px); + color: var(--text-primary); + cursor: pointer; + font-size: 0.9rem; +} + +.custom-select-item:hover:not(.disabled), +.custom-select-item.selected { + background: var(--bg-hover); + color: var(--accent-hover); +} + +.custom-select-item.disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.settings-modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: none; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(2, 6, 23, 0.64); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.settings-modal-overlay.open { + display: flex; +} + +.settings-modal-card { + width: min(100%, 460px); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + background: var(--bg-primary); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + padding: 1.25rem; +} + +.settings-modal-subtitle { + margin-top: 0.35rem; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.45; +} + +.settings-modal-close { + width: 36px; + height: 36px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + font-size: 1.3rem; + line-height: 1; + cursor: pointer; + transition: color var(--transition), border-color var(--transition), background var(--transition); +} + +.settings-modal-close:hover, +.settings-modal-close:focus { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.12); + color: var(--error); +} + +.settings-modal-form { + display: grid; + gap: 1rem; + margin-top: 1.2rem; +} + +.settings-modal-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +@media (max-width: 1180px) { + .settings-grid, + .settings-form { + grid-template-columns: 1fr; + } + + .settings-form .btn-primary { + width: fit-content; + } + + .scope-header-actions { + justify-content: flex-start; + } +} + +@media (max-width: 640px) { + .settings-header-row, + .card-header-row { + align-items: flex-start; + flex-direction: column; + } + + .settings-header-row .btn-primary, + .card-header-row .btn-secondary, + .settings-form .btn-primary { + width: 100%; + } +} diff --git a/frontend/admin/settings/index.html b/frontend/admin/settings/index.html index 398526a..3c10116 100644 --- a/frontend/admin/settings/index.html +++ b/frontend/admin/settings/index.html @@ -14,7 +14,7 @@ - +
- + - +
- +
diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js index 61a1ac3..3f99b58 100644 --- a/frontend/admin/settings/js/main.js +++ b/frontend/admin/settings/js/main.js @@ -1,27 +1,42 @@ -// Settings page main.js +// Основной модуль страницы настроек import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js'; -// Auth check +// Проверка авторизации const token = localStorage.getItem('token'); const role = localStorage.getItem('role'); -if (!token || role !== 'ADMIN') { +if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) { window.location.href = '/'; } -// Global initialization for Custom Selects +// Глобальная инициализация кастомных списков document.addEventListener('DOMContentLoaded', () => { initAllCustomDropdowns(document.body); startDropdownAutoObserver(); }); -// Configuration +// Настройка вкладок const ROUTES = { general: { title: 'Общие настройки', file: 'views/general.html' }, + 'time-slots': { + title: 'Временные слоты', + file: 'views/time-slots.html', + init: async () => { + const module = await import('./views/time-slots.js'); + await module.initTimeSlotsSettings(); + } + }, }; +const ROLE_TABS = { + ADMIN: ['general', 'time-slots'], + EDUCATION_OFFICE: ['time-slots'] +}; +const allowedTabs = new Set(ROLE_TABS[role] || []); +const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general'; + let currentTab = null; -// DOM Elements +// DOM-элементы const appContent = document.getElementById('app-content'); const pageTitle = document.getElementById('page-title'); const navItems = document.querySelectorAll('.nav-item[data-tab]'); @@ -31,13 +46,13 @@ const menuToggle = document.getElementById('menu-toggle'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const main = document.querySelector('.main'); -// Init sidebar state from localStorage +// Состояние боковой панели из localStorage if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { sidebar.classList.add('collapsed'); main.classList.add('sidebar-collapsed'); } -// Menu Toggle (Hamburger) +// Кнопка меню menuToggle.addEventListener('click', () => { if (window.innerWidth <= 768) { sidebar.classList.toggle('open'); @@ -49,7 +64,7 @@ menuToggle.addEventListener('click', () => { } }); -// Sidebar Close (X button) +// Сворачивание боковой панели sidebarCloseBtn?.addEventListener('click', () => { if (window.innerWidth <= 768) { sidebar.classList.remove('open'); @@ -66,8 +81,9 @@ sidebarOverlay.addEventListener('click', () => { sidebarOverlay.classList.remove('open'); }); -// Navigation +// Навигация navItems.forEach(item => { + item.hidden = !allowedTabs.has(item.dataset.tab); item.addEventListener('click', (e) => { e.preventDefault(); const tab = item.dataset.tab; @@ -76,7 +92,7 @@ navItems.forEach(item => { }); async function switchTab(tab) { - if (currentTab === tab || !ROUTES[tab]) return; + if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return; navItems.forEach(n => n.classList.remove('active')); document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active'); @@ -85,10 +101,14 @@ async function switchTab(tab) { try { appContent.innerHTML = '
Загрузка...
'; const response = await fetch(ROUTES[tab].file); - if (!response.ok) throw new Error('Failed to load view'); + if (!response.ok) throw new Error('Не удалось загрузить вкладку'); const html = await response.text(); appContent.innerHTML = html; + initAllCustomDropdowns(appContent); + if (ROUTES[tab].init) { + await ROUTES[tab].init(); + } currentTab = tab; } catch (e) { @@ -96,10 +116,10 @@ async function switchTab(tab) { console.error(e); } - // Close mobile menu if open + // Закрываем мобильное меню после перехода sidebar.classList.remove('open'); sidebarOverlay.classList.remove('open'); } -// Load default tab -switchTab('general'); +// Вкладка по умолчанию +switchTab(defaultTab); diff --git a/frontend/admin/settings/js/views/time-slots.js b/frontend/admin/settings/js/views/time-slots.js new file mode 100644 index 0000000..84932e3 --- /dev/null +++ b/frontend/admin/settings/js/views/time-slots.js @@ -0,0 +1,317 @@ +import { api } from '../../../js/api.js'; +import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js'; + +const APPLY_MODE_LABELS = { + DEFAULT: 'применяется по умолчанию', + WEEKDAY: 'применяется автоматически', + MANUAL: 'применяется вручную' +}; + +const DAY_SHORT_LABELS = { + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + 7: 'Вс' +}; + +export async function initTimeSlotsSettings() { + const scopeSelect = document.getElementById('time-slot-scope-select'); + const scopeSummary = document.getElementById('time-slot-scope-summary'); + const scopeOpenCreateButton = document.getElementById('time-slot-scope-open-create'); + const scopeModal = document.getElementById('time-slot-scope-modal'); + const scopeModalCloseButton = document.getElementById('time-slot-scope-modal-close'); + const scopeModalCancelButton = document.getElementById('time-slot-scope-modal-cancel'); + const scopeForm = document.getElementById('time-slot-scope-form'); + const scopeNameInput = document.getElementById('time-slot-scope-name'); + const scopeDeleteButton = document.getElementById('time-slot-scope-delete'); + + const form = document.getElementById('time-slot-form'); + const title = document.getElementById('time-slot-form-title'); + const idInput = document.getElementById('time-slot-id'); + const orderInput = document.getElementById('time-slot-order'); + const startInput = document.getElementById('time-slot-start'); + const endInput = document.getElementById('time-slot-end'); + const durationInput = document.getElementById('time-slot-duration'); + const resetButton = document.getElementById('time-slot-reset'); + const refreshButton = document.getElementById('time-slots-refresh'); + const tbody = document.getElementById('time-slots-tbody'); + + let scopes = []; + let slots = []; + let activeScopeId = null; + + scopeSelect?.addEventListener('change', () => { + activeScopeId = Number(scopeSelect.value); + resetForm(); + renderActiveScope(); + }); + scopeOpenCreateButton?.addEventListener('click', openScopeModal); + scopeModalCloseButton?.addEventListener('click', closeScopeModal); + scopeModalCancelButton?.addEventListener('click', closeScopeModal); + scopeModal?.addEventListener('click', (event) => { + if (event.target === scopeModal) closeScopeModal(); + }); + scopeModal?.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeScopeModal(); + }); + scopeForm?.addEventListener('submit', createScope); + scopeDeleteButton?.addEventListener('click', deleteActiveScope); + form?.addEventListener('submit', saveSlot); + resetButton?.addEventListener('click', resetForm); + refreshButton?.addEventListener('click', loadAll); + startInput?.addEventListener('change', fillDurationIfEmpty); + endInput?.addEventListener('change', fillDurationIfEmpty); + tbody?.addEventListener('click', handleSlotTableClick); + + await loadAll(); + + async function loadAll() { + tbody.innerHTML = 'Загрузка...'; + hideAlert('time-slot-alert'); + try { + [scopes, slots] = await Promise.all([ + api.get('/api/admin/time-slots/scopes'), + api.get('/api/admin/time-slots') + ]); + if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) { + activeScopeId = defaultScope()?.id || scopes[0]?.id || null; + } + render(); + } catch (error) { + tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function render() { + renderScopeSelects(); + renderActiveScope(); + } + + function renderActiveScope() { + updateScopeActions(); + renderScopeSummary(); + renderSlots(); + } + + function renderScopeSelects() { + scopeSelect.innerHTML = scopes.map(scope => ` + + `).join(''); + scopeSelect.value = activeScopeId ? String(activeScopeId) : ''; + + updateScopeActions(); + } + + function updateScopeActions() { + const activeScope = selectedScope(); + scopeDeleteButton.disabled = !activeScope || activeScope.systemScope; + } + + function renderScopeSummary() { + const scope = selectedScope(); + if (!scope) { + scopeSummary.innerHTML = 'Сетки времени не настроены'; + return; + } + + const mode = APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode; + const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : ''; + const slotCount = slots.filter(slot => slot.scopeId === scope.id).length; + scopeSummary.innerHTML = ` + ${escapeHtml(scopeBadgeLabel(scope))} + ${escapeHtml(mode + day)}. Слотов: ${slotCount}. + `; + } + + function renderSlots() { + const scopeSlots = slots + .filter(slot => slot.scopeId === activeScopeId) + .sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0)); + + if (!scopeSlots.length) { + tbody.innerHTML = 'В выбранной сетке нет слотов'; + return; + } + + tbody.innerHTML = scopeSlots.map(slot => ` + + ${escapeHtml(String(slot.orderNumber ?? '-'))} + ${escapeHtml(trimTime(slot.startTime))} + ${escapeHtml(trimTime(slot.endTime))} + ${escapeHtml(String(slot.durationMinutes ?? '-'))} + + + + + + `).join(''); + } + + function openScopeModal() { + scopeForm.reset(); + hideAlert('time-slot-scope-alert'); + scopeModal.classList.add('open'); + scopeModal.setAttribute('aria-hidden', 'false'); + window.setTimeout(() => scopeNameInput?.focus(), 0); + } + + function closeScopeModal() { + scopeModal.classList.remove('open'); + scopeModal.setAttribute('aria-hidden', 'true'); + hideAlert('time-slot-scope-alert'); + } + + async function createScope(event) { + event.preventDefault(); + const name = scopeNameInput.value.trim(); + if (!name) { + showAlert('time-slot-scope-alert', 'Введите название ручной сетки', 'error'); + return; + } + try { + const saved = await api.post('/api/admin/time-slots/scopes', { name }); + activeScopeId = saved.id; + closeScopeModal(); + showAlert('time-slot-alert', 'Сетка времени создана', 'success'); + await loadAll(); + } catch (error) { + showAlert('time-slot-scope-alert', error.message || 'Ошибка создания сетки времени', 'error'); + } + } + + async function deleteActiveScope() { + const scope = selectedScope(); + if (!scope || scope.systemScope) return; + if (!confirm(`Удалить сетку "${scope.name}" вместе с её слотами и ручными назначениями?`)) return; + try { + await api.delete('/api/admin/time-slots/scopes/' + scope.id); + activeScopeId = defaultScope()?.id || null; + resetForm(); + showAlert('time-slot-alert', 'Сетка времени удалена', 'success'); + await loadAll(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка удаления сетки времени', 'error'); + } + } + + async function saveSlot(event) { + event.preventDefault(); + hideAlert('time-slot-alert'); + + const payload = { + id: idInput.value ? Number(idInput.value) : null, + orderNumber: Number(orderInput.value), + scopeId: activeScopeId, + startTime: startInput.value, + endTime: endInput.value, + durationMinutes: durationInput.value ? Number(durationInput.value) : null + }; + + try { + validateSlotPayload(payload); + const id = idInput.value; + await (id + ? api.put('/api/admin/time-slots/' + id, payload) + : api.post('/api/admin/time-slots', payload)); + showAlert('time-slot-alert', 'Временной слот сохранён', 'success'); + resetForm(); + await loadAll(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error'); + } + } + + async function handleSlotTableClick(event) { + const editButton = event.target.closest('.btn-edit-time-slot'); + const deleteButton = event.target.closest('.btn-delete-time-slot'); + + if (editButton) { + const slot = slots.find(item => String(item.id) === String(editButton.dataset.id)); + if (!slot) return; + activeScopeId = slot.scopeId; + title.textContent = 'Редактирование слота'; + idInput.value = slot.id; + orderInput.value = slot.orderNumber || ''; + startInput.value = trimTime(slot.startTime); + endInput.value = trimTime(slot.endTime); + durationInput.value = slot.durationMinutes || ''; + hideAlert('time-slot-alert'); + render(); + form.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteButton) { + if (!confirm('Удалить временной слот?')) return; + try { + await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id); + showAlert('time-slot-alert', 'Временной слот удалён', 'success'); + resetForm(); + await loadAll(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error'); + } + } + } + + function validateSlotPayload(payload) { + if (!payload.scopeId) { + throw new Error('Выберите сетку времени'); + } + if (!payload.orderNumber || payload.orderNumber <= 0) { + throw new Error('Укажите номер пары'); + } + if (!payload.startTime || !payload.endTime) { + throw new Error('Укажите время начала и окончания'); + } + if (payload.startTime >= payload.endTime) { + throw new Error('Время начала должно быть раньше времени окончания'); + } + } + + function fillDurationIfEmpty() { + if (durationInput.value || !startInput.value || !endInput.value || startInput.value >= endInput.value) return; + const [startHour, startMinute] = startInput.value.split(':').map(Number); + const [endHour, endMinute] = endInput.value.split(':').map(Number); + durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute)); + } + + function resetForm() { + form.reset(); + idInput.value = ''; + title.textContent = 'Новый слот'; + hideAlert('time-slot-alert'); + } + + function selectedScope() { + return scopes.find(scope => scope.id === activeScopeId) || null; + } + + function defaultScope() { + return scopes.find(scope => scope.applyMode === 'DEFAULT') || null; + } +} + +function scopeOptionLabel(scope) { + const suffix = scope.applyMode === 'WEEKDAY' + ? `, ${dayShort(scope.dayOfWeek)}` + : ''; + return `${scope.name} - ${APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode}${suffix}`; +} + +function scopeBadgeLabel(scope) { + if (scope.applyMode === 'DEFAULT') return 'Базовая'; + if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek); + return 'Ручная'; +} + +function dayShort(dayOfWeek) { + return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; +} + +function trimTime(value) { + return value ? String(value).slice(0, 5) : ''; +} diff --git a/frontend/admin/settings/views/time-slots.html b/frontend/admin/settings/views/time-slots.html new file mode 100644 index 0000000..a6800a1 --- /dev/null +++ b/frontend/admin/settings/views/time-slots.html @@ -0,0 +1,127 @@ +
+
+
+

Временные слоты

+

Настройка базовой, субботней и ручных сеток времени.

+
+ +
+ +
+
+
+

Сетка времени

+
+ + +
+
+ +
+ +
+ +
+
+ +
+

Как применяется

+
+ Базовая + Используется по умолчанию для всех дат. +
+
+ Сб + Субботняя сетка применяется автоматически по субботам. +
+
+ Ручная + Ручные сетки назначаются в ячейке редактора календарного графика. +
+
+
+ +
+
+

Новый слот

+ +
+ +
+ + + + + + + + + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + +
ПараНачалоОкончаниеМинутДействия
Загрузка...
+
+
+ + +
diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html new file mode 100644 index 0000000..daf0a10 --- /dev/null +++ b/frontend/admin/views/academic-calendar.html @@ -0,0 +1,238 @@ +
+

Учебный год

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+ +
+
+

Учебные годы

+ +
+
+ + + + + + + + + + + + + + +
НазваниеПериодСеместрыДействия
Загрузка...
+
+
+ +
+

Семестр

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+ +
+
+

Календарный учебный график

+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Календарные графики

+ +
+
+ + + + + + + + + + + + + + + + + +
НазваниеУчебный годСпециальностьПрофильФормаКурсовДействия
Загрузка...
+
+
+ +
+

Редактор годового графика

+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+ + diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html index 8671b26..71755ad 100644 --- a/frontend/admin/views/auditorium-workload.html +++ b/frontend/admin/views/auditorium-workload.html @@ -1,14 +1,31 @@
-

Загруженность аудиторий

+

Загруженность

+
-
-
+
+
+ + +
+ +
+ + +
+ +
- Выберите корпуса... + Все корпуса @@ -19,11 +36,11 @@
-
+
- Выберите вместимость... + Любая вместимость @@ -34,11 +51,11 @@
-
+
- Выберите оборудование... + Любое оборудование @@ -55,21 +72,28 @@
- -
- - - - - - - - - - -
- Аудитория - Время -
+ + +
+
+ +
+ + + + + + + +
+ Время + Сущность +
+
+
+ +
diff --git a/frontend/admin/views/department-workspace.html b/frontend/admin/views/department-workspace.html new file mode 100644 index 0000000..781f56e --- /dev/null +++ b/frontend/admin/views/department-workspace.html @@ -0,0 +1,89 @@ +
+
+

Рабочая область кафедры

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+

Загрузка дисциплин

+
+
+
+ + +
+ + +
+
+ +
+
+

Преподаватели и нагрузка

+
+
Загрузка...
+
Загрузка...
+
+
+ +
+
+

Дисциплины кафедры

+ 0 дисциплин +
+
+ + + + + + + + + + + + +
КодНазваниеОписаниеКомментарии
Загрузка...
+
+
+ + diff --git a/frontend/admin/views/department.html b/frontend/admin/views/department.html deleted file mode 100644 index 11bf25b..0000000 --- a/frontend/admin/views/department.html +++ /dev/null @@ -1,180 +0,0 @@ -
-
-

Запрос расписания кафедры

- -
-
-
-
- - -
- -
- -
- - -
-
- -
- - -
- - -
- -
-
- - -
-
- - - - - - - -
-
- -
- -
\ No newline at end of file diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html index a7ae105..dd31814 100644 --- a/frontend/admin/views/departments-data.html +++ b/frontend/admin/views/departments-data.html @@ -28,11 +28,12 @@ ID Название Код + Действия - Загрузка... + Загрузка... @@ -68,13 +69,58 @@ ID Название Код специальности + Действия - Загрузка... + Загрузка...
+ + + + diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 1bb9015..879269a 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -1,4 +1,4 @@ - +

Новая группа

@@ -9,7 +9,7 @@
- +
@@ -18,16 +18,26 @@
- - + +
- +
- - + + +
+
+ +
@@ -53,17 +63,160 @@ Название Численность (чел.) Форма обучения - ID кафедры + Кафедра Курс - Код специальности + Специальность + Профиль Действия - Загрузка... + Загрузка...
-
\ No newline at end of file +
+ +
+

Подгруппы для лабораторных

+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + +
ГруппаПодгруппаЧисленностьДействия
Загрузка...
+
+
+ +
+

Назначение календарного графика

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + + +
Учебный годГрафикДействия
Выберите группу
+
+
+ + + diff --git a/frontend/admin/views/profiles.html b/frontend/admin/views/profiles.html new file mode 100644 index 0000000..b2a40ac --- /dev/null +++ b/frontend/admin/views/profiles.html @@ -0,0 +1,72 @@ +
+
+

Создание профиля обучения

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Профили выбранной специальности

+ +
+
+
+ + + + + + + + + + + + + + +
IDПрофильОписаниеДействия
Загрузка...
+
+
+ + diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html new file mode 100644 index 0000000..a409229 --- /dev/null +++ b/frontend/admin/views/schedule-view.html @@ -0,0 +1,122 @@ +
+
+
+
+

Расписания

+ Период не выбран +
+ +
+ +
+
+ + +
+ +
+ + +
+ + + + + + + +
+ + +
+ +
+ Переход +
+ + + +
+
+
+ +
+ Дополнительные фильтры +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
+

Найденные расписания

+ 0 занятий +
+
+ + Нет выбранного расписания + +
+
+
+
+ +
+
+
+

Расписание

+ Выберите параметры и нажмите «Показать» +
+ +
+
+
Загрузка...
+
+
+
diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html old mode 100755 new mode 100644 index 8104356..fe8637d --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -1,179 +1,119 @@ +
+
+

Новое правило расписания

+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
Лекции
+
+ + +
+
+ + +
+
+
+
Лабораторные
+
+ + +
+
+ + +
+
+
+
Практики
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ + +
+
+ +
+
+

Слоты правила

+ +
+
+
+ +
+ +
+ +
+
+
-

Расписание занятий

- +

Правила динамического расписания

+
- - - - - - - - - - - + + + + + + + - +
ID - Преподаватель - - Группа - - Аудитория - - Форма обучения - - Дисциплина - - Формат занятия - - Тип занятия - - День недели - НеделяВремяIDДисциплинаСеместрГруппыНагрузкаСлотыДействия
Загрузка...Загрузка...
- - -
-
- - -
-
-

Добавить занятие

- -
- -
-
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- -
- - -
-
- - -
- - -
- - -
- -
- - -
-
- - -
- - -
- - -
- -
- -
- - -
-
- - - - -
-
\ No newline at end of file diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index bf282f3..b3e093c 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -13,11 +13,14 @@
- +
@@ -49,141 +52,14 @@ Должность Кафедра Роль - Действия + Действия - Загрузка... + Загрузка...
-
- - -
-
- - - - - - - -
-
\ No newline at end of file +
diff --git a/frontend/department/index.html b/frontend/department/index.html new file mode 100644 index 0000000..4c02630 --- /dev/null +++ b/frontend/department/index.html @@ -0,0 +1,14 @@ + + + + + + Кабинет кафедры + + + + + + diff --git a/frontend/edu-office/index.html b/frontend/edu-office/index.html new file mode 100644 index 0000000..66e6d71 --- /dev/null +++ b/frontend/edu-office/index.html @@ -0,0 +1,14 @@ + + + + + + Учебный отдел + + + + + + diff --git a/frontend/script.js b/frontend/script.js index 80bdf31..cf3632f 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -144,6 +144,7 @@ if (data.token) localStorage.setItem('token', data.token); if (data.role) localStorage.setItem('role', data.role); if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); + if (data.userId) localStorage.setItem('userId', data.userId); const redirect = data.redirect || '/'; setTimeout(() => { window.location.href = redirect; }, 400); diff --git a/frontend/student/index.html b/frontend/student/index.html old mode 100755 new mode 100644 index 131afe2..34219aa --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -3,240 +3,502 @@ - Панель студента - + Расписание студента - -
-
-
-
- -
- +
+ + Выйти +
+ - +
+ +
+ + + +
+ +
+ +
+
+ + + - - \ No newline at end of file + diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html old mode 100755 new mode 100644 index de0df15..e06adab --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -1,263 +1,531 @@ - - Панель преподавателя - + Расписание преподавателя - -
-
-
-
- -
-
- - - - +
+
+
+

Расписание преподавателя

+ Загрузка периода...
-

Панель преподавателя

-

Раздел в разработке.
Ожидайте обновлений!

- Выйти -
+
+ + Выйти +
+ - +
+
+ + + +
+ +
+ +
+
+ + + - - \ No newline at end of file +