Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule

This commit is contained in:
dipatrik10
2026-05-25 13:40:30 +03:00
93 changed files with 5291 additions and 699 deletions

View File

@@ -27,6 +27,8 @@ magistr/
├── frontend/ # Статические файлы
│ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация проекта

View File

@@ -1,541 +1,248 @@
# План реализации задач после созвона с научным руководителем
# Отчёт об изменениях по задачам научного руководителя
Дата: 2026-05-19
## 1. Короткий вывод
## Краткий итог
В проекте уже есть основа для большей части требований: справочники, пользователи, кафедры, дисциплины, аудитории, динамическое расписание на базе `schedule_rules`, календарный учебный график, временные сетки и экран загруженности аудиторий. Главные недостающие архитектурные слои:
Выполнен первый цельный релиз архитектурной основы:
- нормальная ролевая модель и серверная авторизация;
- темпоральность: история принадлежности, версионирование связей и запрет ретроактивного изменения прошлого расписания;
- мягкое удаление справочников вместо физического удаления;
- отдельные рабочие места для кафедры и учебного отдела;
- расширенные просмотры расписания и загруженности по преподавателям, аудиториям, кафедрам, парам и периодам.
- добавлены роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`;
- добавлена backend-проверка bearer-токена и ролей через `AuthorizationInterceptor` и `@RequireRoles`;
- добавлен жизненный цикл справочников: `ACTIVE` / `ARCHIVED`;
- физическое удаление ключевых сущностей заменено архивированием там, где это влияет на историю;
- добавлена история переводов преподавателей между кафедрами;
- добавлены точечные изменения расписания: перенос, отмена, замена;
- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности;
- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям;
- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям;
- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр;
- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки;
- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`;
- обновлена документация проекта.
Рекомендуемый порядок: сначала ввести темпоральный слой, жизненный цикл записей и роли, затем уже строить новые кабинеты и редакторы. Если начать с экранов, старые связи будут продолжать теряться при удалениях, переносах преподавателей и правках расписания.
## Backend
## 2. Расшифровка задач руководителя в терминах системы
### Роли и авторизация
| Формулировка | Что реализуем |
|-------------|---------------|
| Загруженность по паре или преподавателю | Аналитика занятости по временным слотам, преподавателям, аудиториям, кафедрам, дисциплинам и периодам. |
| Темпоральные базы данных | Не отдельная БД, а темпоральная модель внутри текущей PostgreSQL БД каждого тенанта: периоды действия записей, история связей, версионирование расписания. |
| Сделать удаление аудиторий и т.д., чтобы нельзя было использовать в дальнейшем | Мягкое удаление/архивация справочников. Исторические записи остаются, новые назначения на архивные сущности запрещены. |
| Перенос преподавателей с одной кафедры в другую без потери связи с прошлыми записями | История принадлежности преподавателя к кафедрам через интервалы действия, а не простое перезаписывание `users.department_id`. |
| Уровни пользователей | Расширение ролей и прав: администратор, кафедра, учебный отдел, преподаватель, студент. Проверки должны быть на backend, не только в UI. |
| Администратор: создание аудиторий, специальностей, кафедр и т.д. | Довести справочники до полного CRUD + архив/восстановление + фильтр архивных записей. |
| Кафедра: смотреть предметы кафедры, грузить, связывать предметы, разделять, комментарии, расписания и загруженность | Новый кабинет кафедры: дисциплины кафедры, импорт, описание дисциплин, привязки преподавателей и типов занятий, комментарии, расписание и нагрузка кафедры. |
| Учебный отдел: закреплять время и аудитории, редактировать расписание, закреплять предметы и преподавателей в аудитории в определённое время | Новый кабинет учебного отдела: редактор расписания, фиксация времени/аудиторий/преподавателей, точечные переносы и отмены, проверка конфликтов. |
| Просмотр расписания разных аудиторий, преподавателей, кафедр | Универсальный просмотр расписания с фильтрами по аудитории, преподавателю, группе, кафедре, дисциплине и периоду. |
Поддерживаются роли:
## 3. Текущее состояние проекта
- `ADMIN`;
- `EDUCATION_OFFICE`;
- `DEPARTMENT`;
- `SCHEDULE_VIEWER`;
- `TEACHER`;
- `STUDENT`.
### Уже есть
Добавлены:
- Backend: Java 17, Spring Boot, JPA, Flyway, мультитенантность через отдельную БД на тенант.
- Frontend: Vanilla JS, отдельные интерфейсы `/admin/`, `/teacher/`, `/student/`.
- Роли сейчас ограничены `ADMIN`, `TEACHER`, `STUDENT`.
- Авторизация возвращает UUID-токен и роль, но полноценной серверной проверки прав по эндпоинтам пока нет.
- Динамическое расписание строится из:
- `schedule_rules`;
- `schedule_rule_groups`;
- `schedule_rule_slots`;
- `schedule_rule_slot_subgroups`;
- `time_slots`;
- `academic_years` / `semesters`;
- `academic_calendars`.
- `GET /api/schedule` сейчас принимает ровно один фильтр: `groupId` или `teacherId`.
- Экран загруженности аудиторий уже есть и использует реальные аудитории, временные слоты и сгенерированное расписание.
- У аудиторий есть `is_available`, но `DELETE /api/classrooms/{id}` физически удаляет запись.
- Преподаватель сейчас имеет одно поле `users.department_id`; история переводов не хранится.
- Дисциплины связаны с кафедрой через `subjects.department_id`; история смены кафедры дисциплины не хранится.
- Привязка преподавателей к дисциплинам есть через `teacher_subjects`, но это простая текущая связь без периода действия, комментариев и разбиения по ответственности.
- `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/`.
## 4. Целевая архитектурная идея
### Миграция БД
Ввести три базовых понятия:
Отдельная миграция `V2__roles_lifecycle_temporal_schedule.sql` удалена по текущему решению проекта.
1. **Жизненный цикл записи**: активна, архивирована, недоступна для новых назначений.
2. **Период действия связи**: преподаватель относится к кафедре не "навсегда", а с даты по дату.
3. **Версия расписания**: прошлое расписание должно оставаться воспроизводимым, даже если текущие правила изменились.
Все изменения схемы внесены в:
После этого все новые экраны работают с одной логикой:
- активные справочники доступны для выбора;
- архивные записи видны в истории, но не выбираются для будущих назначений;
- отчёты за прошлый период используют связи, действовавшие на дату занятия;
- правки будущего расписания не ломают прошлые отчёты.
## 5. Этап 1. Роли и права доступа
### Новые роли
| Роль | Назначение |
|------|------------|
| `ADMIN` | Полный доступ внутри тенанта: справочники, пользователи, роли, настройки. |
| `EDUCATION_OFFICE` | Учебный отдел: расписание, аудитории, временные слоты, закрепления, конфликты. |
| `DEPARTMENT` | Кафедра: дисциплины своей кафедры, преподаватели, комментарии, нагрузка, просмотр расписания. |
| `TEACHER` | Преподаватель: личное расписание и, позже, заявки на изменения. |
| `STUDENT` | Просмотр расписания. |
`ADMIN` можно оставить как суперроль внутри одной БД тенанта. Управление тенантами через `/api/database` стоит разрешить только `ADMIN`.
### Backend
- Расширить `Role`.
- Обновить `AuthController.ROLE_REDIRECTS`:
- `ADMIN` -> `/admin/`;
- `EDUCATION_OFFICE` -> `/edu-office/`;
- `DEPARTMENT` -> `/department/`;
- `TEACHER` -> `/teacher/`;
- `STUDENT` -> `/student/`.
- Добавить серверную авторизацию:
- хранение активных сессий/токенов или проверяемый токен;
- фильтр/интерцептор, который получает пользователя по `Authorization`;
- аннотации или централизованные проверки прав в контроллерах.
- Не полагаться на `localStorage.role` как на источник прав.
### Frontend
- Добавить оболочки `/department/` и `/edu-office/`.
- В админке скрывать пункты меню по роли, но считать это только UX-ограничением.
- Все ошибки доступа показывать на русском: `Недостаточно прав для выполнения операции`.
## 6. Этап 2. Темпоральный слой и жизненный цикл справочников
### Базовые поля жизненного цикла
Для справочников, которые могут участвовать в расписании или отчётах, добавить:
- `status` (`ACTIVE`, `ARCHIVED`);
- `active_from`;
- `active_to`;
- `archived_at`;
- `archived_by`;
- `archive_reason`.
Минимальный набор для первого внедрения:
- `classrooms`;
- `equipments`;
- `departments`;
- `specialties`;
- `specialty_profiles`;
- `education_forms`;
- `student_groups`;
- `subgroups`;
- `subjects`;
- `users`.
Для сущностей, где достаточно простого вывода из эксплуатации, можно начать с `status`, `archived_at`, `archive_reason`, а `active_from/active_to` добавить там, где требуется отчётность по датам.
### Правила использования
- `GET` по умолчанию возвращает только активные записи.
- Для админских экранов добавить `includeArchived=true`.
- `DELETE` заменить на архивирование.
- Физическое удаление оставить только для записей, которые точно нигде не используются, либо убрать из публичных API.
- В формах будущего расписания нельзя выбирать архивные аудитории, дисциплины, группы, подгруппы, кафедры и пользователей.
- В историческом расписании архивные записи отображаются с пометкой, например `Аудитория 101 (архив)`.
### Аудитории
`is_available` и архивирование должны означать разные вещи:
- `is_available=false` — аудитория временно недоступна, но может вернуться в работу;
- `status=ARCHIVED` — аудитория выведена из эксплуатации и не может использоваться в новых назначениях.
UI-кнопку `Удалить` лучше заменить на `Вывести из эксплуатации`. Для администратора можно добавить отдельное действие `Восстановить`.
## 7. Этап 3. История переводов преподавателей между кафедрами
### Модель данных
Добавить таблицу:
```sql
teacher_department_assignments
```text
backend/src/main/resources/db/migration/V1__init.sql
```
Поля:
- `id`;
- `teacher_id`;
- `department_id`;
- `valid_from`;
- `valid_to`;
- `is_primary`;
- `comment`;
- `created_at`;
- `created_by`.
Текущий `users.department_id` можно временно оставить как денормализованное поле "текущая кафедра", но источником правды для отчётов должна стать таблица истории.
### Правила
- У преподавателя должна быть ровно одна основная кафедра на дату.
- При переводе преподавателя:
- закрывается старая запись `valid_to`;
- создаётся новая запись `valid_from`;
- старые расписания и отчёты используют кафедру, актуальную на дату занятия.
- Привязки `teacher_subjects` не удаляются автоматически при переводе, иначе потеряется история. Для будущих назначений можно показывать предупреждение, если дисциплина не относится к текущей кафедре преподавателя.
### API
- `GET /api/users/{id}/department-history`
- `POST /api/users/{id}/department-transfer`
- `GET /api/departments/{id}/teachers?date=YYYY-MM-DD`
- `GET /api/users/teachers?departmentId=&date=`
## 8. Этап 4. Версионирование расписания и точечные изменения
Текущая модель `schedule_rules` хороша для генерации будущего расписания, но недостаточна для неизменяемого прошлого и точечных переносов.
### Версии правил
Добавить к правилам и слотам период действия:
- `valid_from`;
- `valid_to`;
- `status`;
- `version_group_id` или `parent_rule_id`;
- `created_by`;
- `change_reason`.
При редактировании правила:
- если правка относится к будущему периоду, закрывать старую версию и создавать новую;
- если правка исправляет ошибку текущего будущего правила, разрешить обновление только до даты начала действия;
- прошлые периоды не пересчитывать по новым данным.
### Точечные изменения расписания
Добавить таблицу:
```sql
schedule_overrides
```
Назначение: перенос, отмена или замена конкретной сгенерированной пары.
Поля:
- `id`;
- `base_rule_slot_id`;
- `lesson_date`;
- `action` (`MOVE`, `CANCEL`, `REPLACE`);
- `new_time_slot_id`;
- `new_classroom_id`;
- `new_teacher_id`;
- `new_lesson_format`;
- `comment`;
- `created_by`;
- `created_at`.
Генератор расписания должен сначала строить базовые пары из правил, затем применять точечные изменения.
### Закрепление времени, аудитории, преподавателя
В `schedule_rule_slots` или отдельной таблице закреплений добавить признаки:
- `time_locked`;
- `classroom_locked`;
- `teacher_locked`;
- `locked_by`;
- `locked_at`;
- `lock_comment`.
Учебный отдел сможет фиксировать назначения, а автоматические или массовые операции не должны менять закреплённые поля без явного подтверждения.
## 9. Этап 5. Проверка конфликтов
Перед сохранением правила, версии или override проверять:
- преподаватель не занят в другом месте в тот же день/пару;
- аудитория не занята другой парой;
- вместимость аудитории не меньше суммы групп/подгрупп;
- преподаватель привязан к дисциплине и нужному типу занятия;
- выбранная аудитория активна и доступна на дату;
- группа/подгруппа активна на дату;
- кафедральные ограничения не нарушены, если дисциплина принадлежит другой кафедре.
Конфликты делить на:
- `ERROR` — сохранение запрещено;
- `WARNING` — можно сохранить с комментарием, если роль имеет право.
API:
- `POST /api/admin/schedule-rules/validate`
- `POST /api/edu-office/schedule/validate`
- `GET /api/schedule/conflicts?startDate=&endDate=`
## 10. Этап 6. Кабинет кафедры
### Функции
- Просмотр дисциплин своей кафедры.
- Создание и редактирование описания дисциплины.
- Импорт дисциплин из файла. Формат нужно согласовать отдельно: CSV/XLSX, обязательные колонки, правила обновления.
- Разделение данных дисциплины:
- карточка дисциплины: название, код, кафедра, описание, комментарии;
- преподаватели дисциплины;
- типы занятий, которые может вести каждый преподаватель;
- история изменений.
- Комментарии к дисциплине, привязке или расписанию.
- Просмотр расписания кафедры.
- Просмотр загруженности кафедры:
- по преподавателям;
- по дисциплинам;
- по неделям;
- по типам занятий.
### Backend
Новые или расширенные сущности:
`V1__init.sql` теперь сразу создаёт:
- lifecycle-поля к справочникам и пользователям;
- `teacher_department_assignments`;
- `subject_comments`;
- `subject_department_assignments` или темпоральные поля у `subjects`;
- версионированные `teacher_subjects`;
- `teacher_lesson_types` вывести в полноценный API, если таблица уже есть в схеме.
- version-поля к `schedule_rules`;
- lock-поля к `schedule_rule_slots`;
- `schedule_overrides`;
- стартовых пользователей `учебный_отдел`, афедраб` и `просмотр_расписаний`.
API:
### Архивирование вместо удаления
- `GET /api/department/subjects`
- `POST /api/department/subjects/import`
- `GET /api/department/subjects/{id}`
- `PUT /api/department/subjects/{id}`
- `GET /api/department/subjects/{id}/teachers`
- `PUT /api/department/subjects/{id}/teachers`
- `GET /api/department/schedule`
- `GET /api/department/workload`
- `POST /api/department/comments`
Архивирование добавлено для пользователей, аудиторий, оборудования, кафедр, специальностей, групп, подгрупп, дисциплин и правил расписания.
### Frontend
Архивные аудитории, преподаватели, группы и дисциплины запрещены для новых назначений. Историческое расписание при этом не теряет ссылки.
Создать `/department/` как отдельный кабинет:
### История кафедр преподавателя
- `subjects` — дисциплины кафедры;
- `teachers` — преподаватели кафедры и их дисциплины;
- `schedule` — расписание кафедры;
- `workload` — нагрузка;
- `comments` или встроенные комментарии в карточках.
Добавлены endpoint:
## 11. Этап 7. Кабинет учебного отдела
- `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` обновляется как текущая кафедра.
- Просмотр расписания по группам, аудиториям, преподавателям и кафедрам.
- Редактирование правил расписания.
- Точечный перенос пары.
- Отмена пары.
- Замена преподавателя.
- Замена аудитории.
- Закрепление времени, аудитории и преподавателя.
- Работа с конфликтами.
- Просмотр загруженности аудиторий и преподавателей.
### Расписание и загруженность
### Backend
Добавлены:
API:
- `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/edu-office/schedule`
- `POST /api/edu-office/schedule/overrides`
- `PUT /api/edu-office/schedule/overrides/{id}`
- `DELETE /api/edu-office/schedule/overrides/{id}`
- `POST /api/edu-office/schedule/locks`
- `DELETE /api/edu-office/schedule/locks/{id}`
- `GET /api/edu-office/workload/classrooms`
- `GET /api/edu-office/workload/teachers`
- `GET /api/edu-office/conflicts`
`GET /api/schedule` теперь использует общий слой поиска, поэтому учитывает точечные изменения расписания.
### Frontend
Роль `SCHEDULE_VIEWER` имеет доступ только к read-only просмотру расписаний и справочникам-фильтрам.
Создать `/edu-office/` с вкладками:
### Кабинет кафедры 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`.
## 12. Этап 8. Универсальный просмотр расписания
Для роли `DEPARTMENT` кафедра берётся из текущего пользователя. Для администратора можно выбрать кафедру в UI.
Сейчас `GET /api/schedule` требует ровно `groupId` или `teacherId`. Для новых требований нужен отдельный эндпоинт или расширение текущего.
## Frontend
Рекомендуется добавить новый эндпоинт:
### Единая панель по ролям
```http
GET /api/schedule/search
Отдельные интерфейсы `/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
```
Фильтры:
Возможности:
- `startDate`;
- `endDate`;
- `groupId`;
- `teacherId`;
- `classroomId`;
- `departmentId`;
- `subjectId`;
- `lessonTypeId`;
- `timeSlotId`;
- `parity`;
- `includeArchived`.
- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности;
- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели;
- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям;
- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели;
- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку;
- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
Правило: можно передавать один или несколько фильтров. Если фильтров нет, доступ зависит от роли:
### Загруженность
- `ADMIN` и `EDUCATION_OFFICE` могут видеть всё;
- `DEPARTMENT` видит свою кафедру;
- `TEACHER` видит себя;
- `STUDENT` видит доступные группы или свою группу, когда появится привязка студента к группе.
Вкладка `auditorium-workload` оставлена техническим tab id, но в UI называется `Загруженность`.
Форматы отображения:
Возможности:
- недельная сетка;
- табличный список;
- вид по аудиториям;
- вид по преподавателям;
- вид по кафедрам.
- выбор типа загруженности: аудитории, преподаватели или кафедры;
- сводная матрица по выбранной дате: строки — выбранные сущности, столбцы — пары;
- кафедральная матрица группирует занятия по кафедре преподавателя;
- для аудиторий сохранены фильтры корпуса, вместимости и оборудования;
- выбор конкретной аудитории, преподавателя или кафедры заменяет обзор двухнедельной таблицей по дням недели и времени;
- чётная и нечётная недели показываются в одной ячейке: если состояние одинаковое, ячейка цельная, если отличается — делится вертикально.
## 13. Этап 9. Загруженность по паре, преподавателю и кафедре
### Кабинет кафедры
### Показатели
Добавлена вкладка:
- занятость аудитории по дате и паре;
- занятость преподавателя по дате и паре;
- часы преподавателя за период;
- часы кафедры за период;
- распределение по дисциплинам;
- распределение по типам занятий;
- свободные аудитории на выбранную дату/пару;
- перегрузки и конфликты.
```text
frontend/admin/views/department-workspace.html
frontend/admin/js/views/department-workspace.js
```
### API
Возможности:
- `GET /api/workload/classrooms`
- `GET /api/workload/teachers`
- `GET /api/workload/departments`
- `GET /api/workload/time-slots`
- `GET /api/workload/free-classrooms`
- просмотр дисциплин кафедры;
- загрузка дисциплин из списка `код; название`;
- просмотр и добавление комментариев к дисциплинам;
- просмотр преподавателей кафедры;
- просмотр нагрузки преподавателей кафедры за период.
Общие параметры:
### Админка
- `startDate`;
- `endDate`;
- `date`;
- `timeSlotId`;
- `teacherId`;
- `departmentId`;
- `classroomId`;
- `groupId`;
- `lessonTypeId`.
Обновлено:
### Реализация
- создание пользователей теперь поддерживает роли `DEPARTMENT`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`;
- удаление пользователей заменено на архивирование;
- вкладка аудиторий показывает архивные записи и умеет восстанавливать аудиторию;
- удаление аудитории заменено на вывод из эксплуатации;
- настройки временных слотов доступны `ADMIN` и `EDUCATION_OFFICE`.
На первом этапе считать загруженность из `ScheduleGeneratorService` и фильтровать уже сгенерированные пары. Если отчёты станут тяжёлыми, добавить кэш или материализованную таблицу фактических занятий на период.
## Документация
Важно: после внедрения `schedule_overrides` отчёты должны считать уже итоговое расписание, а не только базовые правила.
Обновлены:
## 14. Миграции Flyway
Существующие миграции не изменять. Новые изменения оформлять новыми файлами:
1. `V2__roles_and_lifecycle.sql`
- новые роли;
- поля жизненного цикла для справочников;
- индексы по `status`, `active_from`, `active_to`.
2. `V3__teacher_department_history.sql`
- история принадлежности преподавателей к кафедрам;
- перенос текущих `users.department_id` в первую историческую запись.
3. `V4__subject_and_teacher_temporal_links.sql`
- комментарии;
- версионированные связи преподавателей и дисциплин;
- связь преподаватель + дисциплина + тип занятия с периодом действия.
4. `V5__schedule_versions_and_overrides.sql`
- версии правил;
- точечные изменения расписания;
- закрепления.
5. `V6__workload_indexes.sql`
- индексы под отчёты и фильтры расписания.
Нумерация может измениться, если в проекте появятся другие миграции до начала реализации.
## 15. Изменения документации
После каждого этапа обновлять:
- `docs/DATABASE.md`;
- `AGENTS.md`;
- `docs/README.md`;
- `docs/API.md`;
- `docs/DATABASE.md`;
- `docs/BUSINESS_LOGIC.md`;
- `docs/FRONTEND.md`;
- `docs/ARCHITECTURE.md`, если меняются роли, авторизация или темпоральная модель.
- `docs/ARCHITECTURE.md`.
Для больших этапов лучше добавлять отдельные документы:
## Проверки
- `docs/RBAC.md`;
- `docs/TEMPORAL_MODEL.md`;
- `docs/SCHEDULE_EDITING.md`;
- `docs/WORKLOAD_ANALYTICS.md`.
Статически проверены новые и изменённые JS-файлы:
## 16. Проверка и критерии готовности
```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
Backend-компиляция проверяется через Docker Maven:
- `docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q clean compile`
- Прогон Flyway на чистой PostgreSQL БД.
- Проверка, что старые данные мигрировали в темпоральные таблицы.
- Тесты или ручные проверки:
- архивная аудитория не выбирается для будущей пары;
- старая пара с архивной аудиторией отображается;
- перевод преподавателя не меняет кафедру в отчёте за прошлый период;
- правка будущего правила не меняет прошлое расписание;
- конфликт преподавателя/аудитории блокируется или предупреждается.
```bash
docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile
```
### Frontend
Результат: компиляция прошла успешно.
- `node --check` для изменённых JS-файлов.
- `git diff --check`.
- Ручной smoke-test в браузере:
- вход под каждой ролью;
- видимость нужных вкладок;
- запрет доступа к чужим эндпоинтам;
- создание/архивирование/восстановление справочников;
- редактирование расписания учебным отделом;
- просмотр расписания и загруженности по разным фильтрам.
Единая `V1__init.sql` проверена на пустой PostgreSQL 16 внутри Docker: SQL применился успешно.
## 17. Риски и вопросы, которые нужно уточнить
Локальный `mvn` в окружении отсутствует, поэтому используется Docker.
1. Что именно руководитель имеет в виду под "темпоральными базами данных": только история изменений или полноценная bitemporal-модель с `valid_time` и `transaction_time`.
2. Нужно ли хранить фактические проведённые занятия отдельно от планового расписания.
3. Какие форматы файлов кафедра должна "грузить": XLSX, CSV, шаблон учебного плана, свободная таблица.
4. Кто именно является пользователем роли `DEPARTMENT`: заведующий кафедрой, оператор кафедры или несколько сотрудников.
5. Нужна ли студентам привязка к конкретной группе. Сейчас студент выбирает группу вручную.
6. Должно ли удаление/архивирование кафедры запрещаться при наличии активных преподавателей, групп и дисциплин или разрешаться с каскадной архивацией.
7. Нужно ли вести журнал всех действий пользователя для юридически значимой истории изменений.
## Что осталось следующим этапом
## 18. Рекомендуемый порядок внедрения
Остались задачи, которые требуют отдельного цикла проработки:
1. Роли и серверная авторизация.
2. Мягкое удаление аудиторий и справочников.
3. История переводов преподавателей.
4. Версионирование расписания и точечные изменения.
5. Проверка конфликтов.
6. Универсальный `schedule/search`.
7. Загруженность преподавателей, кафедр и пар.
8. Кабинет кафедры.
9. Кабинет учебного отдела.
10. Финальная синхронизация документации и регрессионный smoke-test.
Такой порядок закрывает базовую целостность данных до того, как появятся новые роли и экраны, которые начнут массово создавать и менять расписание.
- UI для просмотра истории переводов преподавателя в админке;
- UI для списка и редактирования уже созданных `schedule_overrides`;
- более строгая проверка конфликтов перед сохранением переносов;
- полноценный импорт XLSX/CSV для кафедры вместо текстовой загрузки;
- браузерный smoke-test и проверка ролевых вкладок после запуска `localhost:80`.

View File

@@ -0,0 +1,26 @@
package com.magistr.app.config.auth;
public final class AuthContext {
private static final ThreadLocal<AuthenticatedUser> 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();
}
}

View File

@@ -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<String, AuthenticatedUser> 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<AuthenticatedUser> findByToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(sessions.get(token));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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.*;
@@ -13,6 +14,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/admin/calendar")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarAdminController {
private final AcademicYearRepository academicYearRepository;

View File

@@ -1,5 +1,6 @@
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.*;
@@ -13,6 +14,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/admin/academic-calendars")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;

View File

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

View File

@@ -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<ClassroomResponse> getAllClassrooms() {
return classroomRepository.findAll().stream()
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<ClassroomResponse> getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) {
List<Classroom> classrooms = includeArchived
? classroomRepository.findAll()
: classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
return classrooms.stream()
.map(this::mapToResponse)
.toList();
}
@@ -52,7 +60,9 @@ public class ClassroomController {
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -96,6 +106,9 @@ public class ClassroomController {
if (request.getEquipmentIds() != null) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
equipments = equipments.stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments));
}
@@ -105,15 +118,33 @@ public class ClassroomController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
if (!classroomRepository.existsById(id)) {
Optional<Classroom> 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<Classroom> 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.getBuilding(), c.getFloor(), c.getIsAvailable(),
c.getStatus(), c.getArchivedAt(), c.getArchiveReason(),
new java.util.ArrayList<>(c.getEquipments()));
}

View File

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

View File

@@ -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<Department> getAllDepartments() {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Department> getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка кафедр");
try {
List<Department> departments = departmentRepository.findAll();
List<Department> departments = includeArchived
? departmentRepository.findAll()
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Department> response = departments.stream()
.map( d -> new Department(
d.getId(),
@@ -150,12 +157,29 @@ public class DepartmentController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
if (!departmentRepository.existsById(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", "Кафедра удалена"));
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()
));
}
}

View File

@@ -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<Subject> 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<CreateSubjectRequest> 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<Subject> 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<String, String> 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()));
}
}

View File

@@ -1,6 +1,8 @@
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;
@@ -13,6 +15,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/education-forms")
@RequireRoles({Role.ADMIN})
public class EducationFormController {
private final EducationFormRepository educationFormRepository;
@@ -28,6 +31,7 @@ public class EducationFormController {
}
@GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Map<String, Object>> getAll() {
return educationFormRepository.findAllByOrderByNameAsc().stream()
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))

View File

@@ -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<Equipment> getAllEquipments() {
return equipmentRepository.findAll();
public List<Equipment> 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);
}
}

View File

@@ -1,11 +1,14 @@
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;
@@ -25,6 +28,7 @@ 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);
@@ -57,11 +61,13 @@ public class GroupController {
}
@GetMapping
public List<GroupResponse> getAllGroups() {
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех групп");
try {
List<StudentGroup> groups = groupRepository.findAll();
List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<GroupResponse> response = groups.stream()
.map(this::mapToResponse)
@@ -78,7 +84,7 @@ public class GroupController {
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId);
List<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
@@ -101,6 +107,7 @@ 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());
@@ -146,6 +153,7 @@ public class GroupController {
}
@PutMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на обновление группы с ID - {}", id);
try {
@@ -195,15 +203,30 @@ public class GroupController {
}
@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) {
@@ -260,6 +283,7 @@ public class GroupController {
}
@GetMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
@@ -270,6 +294,7 @@ public class GroupController {
}
@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) {
@@ -309,6 +334,7 @@ public class GroupController {
}
@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)) {

View File

@@ -1,7 +1,9 @@
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;
@@ -12,6 +14,7 @@ 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;

View File

@@ -1,7 +1,9 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.service.ScheduleGeneratorService;
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;
@@ -14,14 +16,15 @@ 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 ScheduleGeneratorService scheduleGeneratorService;
private final ScheduleQueryService scheduleQueryService;
public ScheduleController(ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleGeneratorService = scheduleGeneratorService;
public ScheduleController(ScheduleQueryService scheduleQueryService) {
this.scheduleQueryService = scheduleQueryService;
}
@GetMapping
@@ -40,9 +43,18 @@ public class ScheduleController {
}
try {
List<RenderedLessonDto> lessons = groupId != null
? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate)
: scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
List<RenderedLessonDto> lessons = scheduleQueryService.search(
groupId,
teacherId,
null,
null,
null,
null,
null,
null,
startDate,
endDate
);
return ResponseEntity.ok(lessons);
} catch (IllegalArgumentException e) {
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());

View File

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

View File

@@ -1,5 +1,6 @@
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.*;
@@ -14,6 +15,7 @@ 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";
@@ -112,12 +114,14 @@ public class ScheduleRuleAdminController {
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long id) {
if (!scheduleRuleRepository.existsById(id)) {
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
if (rule == null) {
return ResponseEntity.notFound().build();
}
scheduleRuleRepository.deleteById(id);
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Правило расписания удалено"));
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
}
private String validateRule(ScheduleRuleDto request) {
@@ -143,12 +147,18 @@ public class ScheduleRuleAdminController {
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<StudentGroup> 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);
@@ -186,8 +196,14 @@ public class ScheduleRuleAdminController {
}
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);

View File

@@ -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", "Произошла ошибка при поиске расписания"));
}
}
}

View File

@@ -1,8 +1,11 @@
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;
@@ -18,6 +21,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/specialties")
@RequireRoles({Role.ADMIN})
public class SpecialityController {
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
@@ -32,10 +36,12 @@ public class SpecialityController {
}
@GetMapping
public List<Speciality> getAllSpecialties() {
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей");
try {
List<Speciality> specialities = specialtiesRepository.findAll();
List<Speciality> specialities = includeArchived
? specialtiesRepository.findAll()
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Speciality> response = specialities.stream()
.map( s -> new Speciality(
s.getId(),
@@ -161,13 +167,30 @@ public class SpecialityController {
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id);
if (!specialtiesRepository.existsById(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", "Специальность удалена"));
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")

View File

@@ -1,6 +1,8 @@
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;
@@ -15,6 +17,7 @@ import java.util.List;
import java.util.Map;
@RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController {
private final SubgroupRepository subgroupRepository;
@@ -50,6 +53,7 @@ public class SubgroupController {
}
@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) {
@@ -73,6 +77,7 @@ public class SubgroupController {
}
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> update(@PathVariable Long groupId,
@PathVariable Long id,
@RequestBody SubgroupDto request) {
@@ -99,6 +104,7 @@ public class SubgroupController {
}
@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) {
@@ -108,9 +114,10 @@ public class SubgroupController {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
try {
subgroupRepository.delete(subgroup);
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа удалена"));
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}

View File

@@ -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<Subject> getAllSubjects() {
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Subject> getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех дисциплин");
try {
List<Subject> subjects = subjectRepository.findAll();
List<Subject> subjects = includeArchived
? subjectRepository.findAll()
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Subject> 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<Subject> subjects = subjectRepository.findByDepartmentId(departmentId);
List<Subject> 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()
));
}
}

View File

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

View File

@@ -1,11 +1,13 @@
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;
@@ -23,6 +25,7 @@ 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";

View File

@@ -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<UserResponse> getAllUsers() {
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей");
try {
List<User> users = userRepository.findAll();
List<User> users = includeArchived
? userRepository.findAll()
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> 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<UserResponse> getTeachers() {
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
try {
List<User> users = userRepository.findByRole(Role.TEACHER);
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> 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<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId);
List<User> 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()
);
}
}

View File

@@ -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<WorkloadSummaryDto> teacherWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, User> 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<WorkloadSummaryDto> classroomWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> 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<WorkloadSummaryDto> departmentWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
null, null, null, null, startDate, endDate);
Map<Long, String> departments = departmentRepository.findAll().stream()
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
Map<Long, List<RenderedLessonDto>> 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<WorkloadSummaryDto> timeSlotWorkload(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) Long departmentId
) {
List<RenderedLessonDto> 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<ClassroomResponse> freeClassrooms(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam Long timeSlotId
) {
Set<Long> 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<WorkloadSummaryDto> summarize(List<RenderedLessonDto> lessons,
Function<RenderedLessonDto, Long> idFn,
Function<RenderedLessonDto, String> nameFn,
Function<Long, Long> departmentFn) {
Map<Long, List<RenderedLessonDto>> 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<RenderedLessonDto> 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())
);
}
}

View File

@@ -1,6 +1,7 @@
package com.magistr.app.dto;
import com.magistr.app.model.Equipment;
import java.time.LocalDateTime;
import java.util.List;
public class ClassroomResponse {
@@ -10,6 +11,9 @@ public class ClassroomResponse {
private String building;
private Integer floor;
private Boolean isAvailable;
private String status;
private LocalDateTime archivedAt;
private String archiveReason;
private List<Equipment> equipments;
public ClassroomResponse() {
@@ -17,12 +21,21 @@ public class ClassroomResponse {
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, List<Equipment> 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<Equipment> 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;
}
@@ -74,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<Equipment> getEquipments() {
return equipments;
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record DepartmentTransferRequest(
Long departmentId,
LocalDate validFrom,
String comment
) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name="departments")
public class Department {
public class Department extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

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

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "equipments")
public class Equipment {
public class Equipment extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

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

View File

@@ -2,6 +2,9 @@ package com.magistr.app.model;
public enum Role {
ADMIN,
EDUCATION_OFFICE,
DEPARTMENT,
SCHEDULE_VIEWER,
TEACHER,
STUDENT
}

View File

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

View File

@@ -2,6 +2,7 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
@@ -21,6 +22,15 @@ public class ScheduleRule {
@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;
@@ -74,6 +84,30 @@ public class ScheduleRule {
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;
}

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name="specialties")
public class Speciality {
public class Speciality extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "specialty_profiles")
public class SpecialtyProfile {
public class SpecialtyProfile extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "student_groups")
public class StudentGroup {
public class StudentGroup extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "subgroups")
public class Subgroup {
public class Subgroup extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "subjects")
public class Subject {
public class Subject extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
public class User extends LifecycleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -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<Classroom, Long> {
Optional<Classroom> findByName(String name);
List<Classroom> findByStatusNot(String status);
}

View File

@@ -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<Department, Long> {
Optional<Department> findByDepartmentName(String departmentName);
Optional<Department> findByDepartmentCode(Long departmentCode);
List<Department> findByStatusNot(String status);
}

View File

@@ -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<Equipment, Long> {
Optional<Equipment> findByName(String name);
List<Equipment> findByStatusNot(String status);
}

View File

@@ -10,4 +10,8 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
List<StudentGroup> findByEducationFormId(Long educationFormId);
List<StudentGroup> findByDepartmentId(Long departmentId);
List<StudentGroup> findByStatusNot(String status);
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
}

View File

@@ -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<ScheduleOverride, Long> {
@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<ScheduleOverride> findByLessonDateBetweenWithDetails(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
}

View File

@@ -26,6 +26,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where g.id = :groupId
and sem.id = :semesterId
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdAndSemesterId(
@Param("groupId") Long groupId,
@@ -49,6 +50,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId
and sem.id = :semesterId
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterId(
@Param("teacherId") Long teacherId,
@@ -69,6 +71,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
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<ScheduleRule> findAllWithDetails();

View File

@@ -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<Speciality, Long> {
Optional<Speciality> findBySpecialityName(String specialityName);
Optional<Speciality> findBySpecialityCode(String specialityCode);
List<Speciality> findByStatusNot(String status);
}

View File

@@ -14,6 +14,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
select subgroup
from Subgroup subgroup
join fetch subgroup.studentGroup studentGroup
where subgroup.status <> 'ARCHIVED'
order by studentGroup.name, subgroup.name
""")
List<Subgroup> findAllWithGroupsOrderByGroupNameAndName();
@@ -23,6 +24,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
from Subgroup subgroup
join fetch subgroup.studentGroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
order by subgroup.name
""")
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
@@ -41,6 +43,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
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);
}

View File

@@ -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<SubjectComment, Long> {
List<SubjectComment> findBySubjectIdOrderByCreatedAtDesc(Long subjectId);
}

View File

@@ -10,4 +10,8 @@ public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);
List<Subject> findByDepartmentId(Long departmentId);
List<Subject> findByStatusNot(String status);
List<Subject> findByDepartmentIdAndStatusNot(Long departmentId, String status);
}

View File

@@ -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<TeacherDepartmentAssignment, Long> {
@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<TeacherDepartmentAssignment> 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<TeacherDepartmentAssignment> 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<TeacherDepartmentAssignment> findDepartmentTeachersAtDate(
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
}

View File

@@ -14,4 +14,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByRole(Role role);
List<User> findByRoleAndDepartmentId(Role role, Long departmentId);
List<User> findByStatusNot(String status);
List<User> findByRoleAndStatusNot(Role role, String status);
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
}

View File

@@ -167,6 +167,9 @@ public class ScheduleGeneratorService {
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
return;
}
if (!rule.getSubject().isActiveOn(date)) {
return;
}
Semester semester = semesterOpt.get();
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
@@ -193,6 +196,12 @@ public class ScheduleGeneratorService {
Map<String, Integer> 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;
@@ -254,10 +263,13 @@ public class ScheduleGeneratorService {
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
if (targetGroup != null) {
return academicDateService.isTheoryDay(targetGroup, semester, date) ? List.of(targetGroup) : List.of();
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();

View File

@@ -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<RenderedLessonDto> search(Long groupId,
Long teacherId,
Long classroomId,
Long departmentId,
Long subjectId,
Long lessonTypeId,
Long timeSlotId,
String parity,
LocalDate startDate,
LocalDate endDate) {
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
List<RenderedLessonDto> generatedLessons = groups.stream()
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList();
List<RenderedLessonDto> 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<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons, LocalDate startDate, LocalDate endDate) {
Map<String, ScheduleOverride> 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<StudentGroup> 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<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
Map<String, MutableLesson> 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<Long> 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<Long> groupIds = new LinkedHashSet<>();
private final LinkedHashSet<String> groupNames = new LinkedHashSet<>();
private final LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
private final LinkedHashSet<String> 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()
);
}
}
}

View File

@@ -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,7 +26,13 @@ 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) UNIQUE 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
@@ -35,6 +47,12 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
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)
);
@@ -50,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
);
-- ==========================================
-- Образовательные формы
-- ==========================================
@@ -71,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
@@ -92,7 +158,13 @@ CREATE TABLE IF NOT EXISTS student_groups (
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
);
-- Тестовая базовая группа для работы
@@ -116,6 +188,12 @@ 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)
);
@@ -146,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
@@ -157,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,
@@ -176,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
@@ -197,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
@@ -229,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);
-- ==========================================
-- Связи для преподавателей
-- ==========================================
@@ -483,6 +601,13 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
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
@@ -495,11 +620,14 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
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,
@@ -519,6 +647,12 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
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 ('Очно', 'Онлайн'))
@@ -537,6 +671,28 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups (
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
@@ -762,12 +918,17 @@ 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 'Подгруппы';
@@ -782,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 'ФИО пользователя';
@@ -833,6 +995,7 @@ 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 оборудования';

View File

@@ -46,6 +46,32 @@
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>`
Поддерживаемые роли: `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
}
```
---
## Пользователи
@@ -89,13 +115,50 @@
**Валидация:**
- `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`
Список преподавателей кафедры на конкретную дату по таблице истории.
---
## Права ролей на API
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`.
Важные ограничения:
- `DEPARTMENT` не может создавать аудитории, кафедры, специальности, группы, пользователей или правила расписания через API;
- `DEPARTMENT` создаёт и комментирует дисциплины через `/api/department/*`, где кафедра берётся из текущего пользователя;
- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только преподавателей и дисциплины своей кафедры;
- `SCHEDULE_VIEWER` имеет read-only доступ к просмотру расписаний, справочникам-фильтрам и загруженности.
---
@@ -334,6 +397,97 @@ CRUD доступен по:
]
```
### `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
{
"baseRuleSlotId": 31,
"lessonDate": "2026-05-21",
"action": "REPLACE",
"newClassroomId": 2,
"newTeacherId": 5,
"comment": "Замена аудитории и преподавателя"
}
```
`action=CANCEL` отменяет конкретную пару. `MOVE` и `REPLACE` могут менять аудиторию, преподавателя, формат и временной слот.
## Загруженность
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/workload/teachers` | Загруженность преподавателей |
| `GET` | `/api/workload/classrooms` | Загруженность аудиторий |
| `GET` | `/api/workload/departments` | Загруженность кафедр |
| `GET` | `/api/workload/time-slots` | Загруженность по парам |
| `GET` | `/api/workload/free-classrooms` | Свободные аудитории на дату и пару |
Общие параметры для отчётов: `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` | Расписание кафедры |
---
## Кафедры и специальности
@@ -557,7 +711,11 @@ CRUD доступен по:
### `DELETE /api/classrooms/{id}`
Удаление аудитории.
Архивирование аудитории. Архивная аудитория остаётся в историческом расписании, но не выбирается в новых назначениях.
### `POST /api/classrooms/{id}/restore`
Восстановление архивной аудитории.
---

View File

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

View File

@@ -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` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя.
---
## Управление ресурсами
@@ -52,6 +66,7 @@
- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность
- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества
- **Статус:** Флаг `is_available` для блокирования назначения пар
- **Жизненный цикл:** `status=ARCHIVED` означает вывод из эксплуатации. Такая аудитория отображается в прошлом расписании, но запрещена для новых назначений.
### Оборудование (Equipments)
@@ -64,6 +79,26 @@
- **Поля:** Название (уникальное), код, кафедра, описание
- Привязка преподавателей через `teacher_subjects` (Many-to-Many)
- Кафедра может добавлять комментарии к дисциплине и загружать список дисциплин через кабинет кафедры
### Жизненный цикл справочников
Справочники, которые участвуют в расписании и отчётах, не удаляются физически. Для них используется архивирование:
- `ACTIVE` — запись доступна для выбора;
- `ARCHIVED` — запись остаётся в истории, но не используется в будущих назначениях.
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
### Перевод преподавателей между кафедрами
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
Правила:
- у преподавателя должна быть одна открытая основная кафедра;
- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
---
@@ -86,6 +121,7 @@
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона.
@@ -98,6 +134,7 @@
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
@@ -129,6 +166,18 @@
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
### Точечные изменения расписания
Учебный отдел может создать изменение конкретной пары:
- `CANCEL` — отменить пару;
- `MOVE` — перенести пару на другой временной слот или в другую аудиторию;
- `REPLACE` — заменить преподавателя, аудиторию или формат.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного расписания на конкретную дату.
---

View File

@@ -41,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
}
@@ -101,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
}
@@ -119,6 +127,24 @@ erDiagram
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
BIGINT subject_id FK,PK
@@ -207,6 +233,9 @@ erDiagram
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
@@ -230,6 +259,21 @@ erDiagram
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 {
@@ -248,8 +292,12 @@ erDiagram
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{ 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_rules : "subject_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
@@ -270,6 +318,10 @@ erDiagram
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"
@@ -317,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 | Дата обновления (авто-триггер) |
@@ -375,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` — Оборудование
@@ -420,6 +482,30 @@ erDiagram
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
#### `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` — Сетки времени
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -524,6 +610,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `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` — Группы правила
| Колонка | Тип | Описание |
@@ -543,6 +634,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `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` — Подгруппы лабораторного слота
| Колонка | Тип | Описание |
@@ -552,6 +649,23 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `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` не позволяет создать две разные правки для одной и той же пары.
---
## Flyway миграции
@@ -570,7 +684,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание с раздельными часами и стартовыми неделями по типам занятий, тестовые правила, триггеры, комментарии |
| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов

View File

@@ -29,7 +29,7 @@ frontend/
│ │ ├── layout.css # Раскладка (sidebar, topbar, content)
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
│ │ ├── modals.css # Модальные окна
│ │ ├── auditorium-workload.css # Таблицы загруженности аудиторий
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
│ │ └── departments-data.css # Стили создания кафедры/специальности
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
@@ -44,9 +44,11 @@ frontend/
│ │ ├── equipments.js # Управление оборудованием
│ │ ├── edu-forms.js # Формы обучения
│ │ ├── profiles.js # Профили обучения специальностей
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
│ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики
│ │ ├── auditorium-workload.js # Загруженность аудиторий
│ │ ├── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ │ ├── database.js # Управление тенантами
│ │ └── departments-data.js # Создание кафедры/специальности
│ ├── views/ # HTML-шаблоны представлений
@@ -57,6 +59,8 @@ frontend/
│ │ ├── equipments.html
│ │ ├── edu-forms.html
│ │ ├── profiles.html
│ │ ├── department-workspace.html
│ │ ├── schedule-view.html
│ │ ├── schedule.html
│ │ ├── academic-calendar.html
│ │ ├── auditorium-workload.html
@@ -78,6 +82,10 @@ frontend/
├── teacher/ # 👩‍🏫 Интерфейс преподавателя
│ └── index.html # Недельный просмотр динамического расписания преподавателя
├── department/ # 🏛 Кабинет кафедры
│ └── index.html # Redirect в `/admin/#department-workspace`
├── edu-office/ # 🗓 Кабинет учебного отдела
│ └── index.html # Redirect в `/admin/#schedule-view`
└── student/ # 🎓 Интерфейс студента
└── index.html # Недельный просмотр динамического расписания группы
@@ -104,6 +112,19 @@ 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 |
@@ -115,30 +136,35 @@ frontend/
| `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` |
| `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/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
| `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` |
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. В поле «Отображение» можно выбрать конкретную аудиторию; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной аудитории растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `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 слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
---
@@ -191,6 +217,9 @@ export const api = {
- `userId` — ID пользователя для личного расписания преподавателя
4. Перенаправляет на соответствующий интерфейс:
- `ADMIN``/admin/`
- `EDUCATION_OFFICE``/admin/#schedule-view`
- `DEPARTMENT``/admin/#department-workspace`
- `SCHEDULE_VIEWER``/admin/#schedule-view`
- `TEACHER``/teacher/`
- `STUDENT``/student/`
@@ -213,6 +242,30 @@ 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`.

View File

@@ -90,6 +90,8 @@ magistr/
│ ├── admin/ # Админ-панель (деканат)
│ │ ├── js/views/ # Модули представлений
│ │ └── css/ # Стили
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация (вы здесь)

View File

@@ -18,6 +18,11 @@
min-width: 240px;
}
.workload-target-control {
flex: 0 1 220px;
min-width: 200px;
}
.workload-display-select {
width: 100%;
min-height: 38px;
@@ -564,6 +569,297 @@
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;
@@ -580,7 +876,14 @@
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%;
}
@@ -596,4 +899,54 @@
.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;
}
}

View File

@@ -104,6 +104,111 @@
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;
@@ -578,6 +683,12 @@
}
@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;
}

View File

@@ -72,6 +72,11 @@
overflow: hidden;
}
.nav-item[hidden],
.settings-menu-item[hidden] {
display: none !important;
}
.nav-item::before {
content: '';
position: absolute;

View File

@@ -109,6 +109,25 @@
</svg>
<span>Дисциплины</span>
</a>
<a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18" />
<path d="M5 21V7l7-4 7 4v14" />
<path d="M9 21v-6h6v6" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
<span>Кабинет кафедры</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
@@ -134,7 +153,7 @@
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Загруженность аудиторий</span>
<span>Загруженность</span>
</a>
<a href="#" class="nav-item" data-tab="database">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">

View File

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

View File

@@ -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 = '/';
}
@@ -30,21 +32,44 @@ 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 },
'academic-calendar': { title: 'Календарный учебный график', file: 'views/academic-calendar.html', init: initAcademicCalendar },
'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload },
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData },
profiles: { title: 'Профили обучения', file: 'views/profiles.html', init: initProfiles },
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;
@@ -59,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') {
@@ -136,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'));
@@ -147,7 +170,7 @@ async function switchTab(tab) {
try {
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
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;
@@ -168,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);

View File

@@ -3,9 +3,44 @@ import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js';
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: 'Вт',
@@ -43,6 +78,7 @@ const CAPACITY_BUCKETS = [
export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date');
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');
@@ -55,14 +91,18 @@ export async function initAuditoriumWorkload() {
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
let classrooms = [];
let teachers = [];
let departments = [];
let timeSlots = [];
let equipments = [];
let groups = [];
let semesters = [];
let lessonsByRoomSlot = new Map();
let lessonsByEntitySlot = new Map();
let failedGroupLoads = 0;
let currentTarget = TARGET_CLASSROOMS;
let currentView = DISPLAY_ALL;
let selectedRoomId = null;
let selectedEntityId = null;
setDefaultDate();
initFilters();
@@ -84,6 +124,16 @@ export async function initAuditoriumWorkload() {
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();
@@ -106,25 +156,30 @@ export async function initAuditoriumWorkload() {
}
async function loadInitialData() {
setTableLoading('Загрузка аудиторий и расписания...');
setTableLoading('Загрузка справочников и расписания...');
try {
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears] = await Promise.all([
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/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 || 'Ошибка загрузки данных загруженности аудиторий';
const message = error.message || 'Ошибка загрузки данных загруженности';
setTableError(message);
setRoomError(message);
}
@@ -137,10 +192,10 @@ export async function initAuditoriumWorkload() {
if (currentView === DISPLAY_ALL) {
await loadScheduleForSelectedDate();
} else {
await loadRoomWorkload();
await loadSelectedEntityWorkload();
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности аудиторий';
const message = error.message || 'Ошибка загрузки загруженности';
if (currentView === DISPLAY_ALL) {
setTableError(message);
} else {
@@ -162,9 +217,9 @@ export async function initAuditoriumWorkload() {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
if (!groups.length) {
lessonsByRoomSlot = new Map();
lessonsByEntitySlot = new Map();
renderGrid();
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
showAlert('workload-alert', 'Группы не созданы, загруженность пуста', 'error');
return;
}
@@ -176,7 +231,7 @@ export async function initAuditoriumWorkload() {
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
lessonsByRoomSlot = groupLessonsByRoomSlot(lessons);
lessonsByEntitySlot = groupLessonsByEntitySlot(lessons);
renderGrid();
if (failedGroupLoads > 0) {
@@ -184,30 +239,31 @@ export async function initAuditoriumWorkload() {
}
}
async function loadRoomWorkload() {
async function loadSelectedEntityWorkload() {
const date = dateInput?.value;
const room = selectedRoom();
const entity = selectedEntity();
const target = targetConfig();
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
setRoomError('Выберите дату для расчёта двухнедельного периода');
return;
}
if (!room) {
showAlert('workload-alert', 'Выберите аудиторию', 'error');
setRoomError('Выберите аудиторию в поле «Отображение»');
if (!entity) {
showAlert('workload-alert', target.selectError, 'error');
setRoomError(`${target.selectError} в поле «Отображение»`);
return;
}
hideAlert('workload-alert');
setRoomLoading('Загрузка расписания аудитории на две недели...');
setRoomLoading('Загрузка расписания на две недели...');
const range = twoWeekRange(date);
const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates),
loadRoomLessons(range, room.id)
loadEntityLessons(range, entity.id)
]);
renderRoomWorkload(room, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
renderEntityWorkload(entity, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
failedSlotLoads: slotLoad.failedLoads,
failedGroupLoads: lessonLoad.failedLoads,
parityLessons: lessonLoad.parityLessons
@@ -236,7 +292,7 @@ export async function initAuditoriumWorkload() {
};
}
async function loadRoomLessons(range, roomId) {
async function loadEntityLessons(range, entityId) {
if (!groups.length) {
return {
lessons: [],
@@ -252,9 +308,8 @@ export async function initAuditoriumWorkload() {
const parityLessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
const lessons = uniqueLessons(
parityLessons.filter(lesson => sameId(lesson.classroomId, roomId))
);
const lessons = uniqueLessons(parityLessons)
.filter(lesson => entityIdsForLesson(lesson).some(id => sameId(id, entityId)));
const lessonsByDateSlot = new Map();
lessons.forEach(lesson => {
const slotKey = lessonSlotKey(lesson);
@@ -311,12 +366,11 @@ export async function initAuditoriumWorkload() {
function populateDisplayOptions() {
if (!displaySelect) return;
const selectedValue = displaySelect.value;
const target = targetConfig();
displaySelect.innerHTML = '';
displaySelect.append(new Option('Все аудитории', DISPLAY_ALL));
classrooms.forEach(room => {
const details = [room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(', ');
const suffix = details ? ` · ${details}` : '';
displaySelect.append(new Option(`${room.name}${suffix}`, `${ROOM_PREFIX}${room.id}`));
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;
@@ -325,19 +379,24 @@ export async function initAuditoriumWorkload() {
function applyDisplaySelection() {
const value = displaySelect?.value || DISPLAY_ALL;
if (value.startsWith(ROOM_PREFIX)) {
currentView = 'room';
selectedRoomId = value.slice(ROOM_PREFIX.length);
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;
selectedRoomId = null;
}
function syncDisplayMode() {
const roomMode = currentView !== DISPLAY_ALL;
overviewFilters.forEach(filter => {
filter.hidden = roomMode;
filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS;
});
if (overviewBlock) overviewBlock.hidden = roomMode;
if (roomBlock) roomBlock.hidden = !roomMode;
@@ -346,9 +405,39 @@ export async function initAuditoriumWorkload() {
}
}
function selectedRoom() {
if (!selectedRoomId) return null;
return classrooms.find(room => sameId(room.id, selectedRoomId)) || null;
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() {
@@ -394,7 +483,7 @@ export async function initAuditoriumWorkload() {
function uniqueLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
if (!lesson.classroomId || !lesson.timeSlotId) return;
if (!lesson.timeSlotId) return;
const key = [
lesson.date,
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
@@ -422,44 +511,58 @@ export async function initAuditoriumWorkload() {
return Array.from(unique.values());
}
function groupLessonsByRoomSlot(lessons) {
function groupLessonsByEntitySlot(lessons) {
const grouped = new Map();
uniqueLessons(lessons).forEach(lesson => {
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId);
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 visibleClassrooms = filteredClassrooms();
const rows = visibleEntities();
const target = targetConfig();
renderHeader();
tbody.innerHTML = '';
if (!visibleClassrooms.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">${escapeHtml(target.emptyLabel)}</td></tr>`;
renderSummary(0, 0, 0);
return;
}
visibleClassrooms.forEach(room => {
rows.forEach(entity => {
const tr = document.createElement('tr');
const roomCell = document.createElement('td');
roomCell.className = `axis-cell${room.isAvailable === false ? ' room-unavailable' : ''}`;
roomCell.innerHTML = `
<strong>${escapeHtml(room.name)}</strong>
<span>${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'}</span>
${room.building ? `<small>${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}</small>` : ''}
`;
tr.appendChild(roomCell);
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 = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || [];
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('')
@@ -470,15 +573,16 @@ export async function initAuditoriumWorkload() {
tbody.appendChild(tr);
});
const occupiedCells = countOccupiedCells(visibleClassrooms);
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length);
const occupiedCells = countOccupiedCells(rows);
renderSummary(rows.length, occupiedCells, rows.length * timeSlots.length);
}
function renderHeader() {
const target = targetConfig();
headerRow.innerHTML = `
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
<span class="bottom-label">${escapeHtml(target.rowLabel)}</span>
</th>
`;
@@ -493,10 +597,51 @@ export async function initAuditoriumWorkload() {
});
}
function renderRoomWorkload(room, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
function entityAxisHtml(entity) {
if (currentTarget === TARGET_TEACHERS) {
return `
<strong>${escapeHtml(userName(entity))}</strong>
<span>${escapeHtml(entity.jobTitle || 'Должность не указана')}</span>
${entity.departmentName ? `<small>${escapeHtml(entity.departmentName)}</small>` : ''}
`;
}
if (currentTarget === TARGET_DEPARTMENTS) {
return `
<strong>${escapeHtml(departmentName(entity))}</strong>
<span>${entity.departmentCode ? `Код: ${escapeHtml(entity.departmentCode)}` : 'Код не указан'}</span>
`;
}
return `
<strong>${escapeHtml(entity.name)}</strong>
<span>${entity.capacity ? `${escapeHtml(entity.capacity)} мест` : 'Вместимость не указана'}</span>
${entity.building ? `<small>${escapeHtml(entity.building)}${entity.floor ? `, ${escapeHtml(entity.floor)} этаж` : ''}</small>` : ''}
`;
}
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(room, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
if (!slotRows.length) {
roomTables.innerHTML = '<div class="loading-row room-workload-state">За выбранный период нет временных слотов</div>';
@@ -506,16 +651,12 @@ export async function initAuditoriumWorkload() {
roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot);
}
function renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
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 roomMeta = [
room.capacity ? `${room.capacity} мест` : null,
room.building || null,
room.floor ? `${room.floor} этаж` : null
].filter(Boolean).join(', ');
const details = entitySummaryDetails(entity);
const warnings = [];
if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`);
if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`);
@@ -523,8 +664,8 @@ export async function initAuditoriumWorkload() {
roomSummary.innerHTML = `
<div class="room-summary-main">
<strong>${escapeHtml(room.name)}</strong>
${roomMeta ? `<span>${escapeHtml(roomMeta)}</span>` : ''}
<strong>${escapeHtml(entityName(entity))}</strong>
${details ? `<span>${escapeHtml(details)}</span>` : ''}
</div>
<span class="room-summary-chip">${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))}</span>
<span class="room-summary-chip">Занято: ${occupiedCells} из ${totalCells} (${percent}%)</span>
@@ -555,7 +696,7 @@ export async function initAuditoriumWorkload() {
return `
<section class="room-week-panel">
<div class="room-week-heading">
<h3>Нагрузка аудитории по неделям</h3>
<h3>${escapeHtml(targetConfig().periodTitle)}</h3>
<span>${escapeHtml(roomWeekCaption(datesByDay))}</span>
</div>
<div class="workload-grid-container room-grid-container">
@@ -852,18 +993,19 @@ export async function initAuditoriumWorkload() {
showAlert('workload-alert', message, 'error');
}
function renderSummary(roomCount, occupiedCells, totalCells) {
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}` : '';
summary.textContent = `Дата: ${date}, ${day}. Аудиторий: ${roomCount}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
const selected = selectedEntityId ? ', выбран один объект' : '';
summary.textContent = `Дата: ${date}, ${day}. ${targetConfig().summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
}
function countOccupiedCells(rooms) {
return rooms.reduce((count, room) => count + timeSlots.filter(slot =>
(lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0
function countOccupiedCells(entities) {
return entities.reduce((count, entity) => count + timeSlots.filter(slot =>
(lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0
).length, 0);
}
@@ -883,8 +1025,8 @@ export async function initAuditoriumWorkload() {
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
}
function roomSlotKey(roomId, timeSlotId) {
return `${roomId}:${timeSlotId}`;
function entitySlotKey(entityId, timeSlotId) {
return `${entityId}:${timeSlotId}`;
}
function dateSlotKey(date, slotKey) {
@@ -905,6 +1047,22 @@ export async function initAuditoriumWorkload() {
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));
}

View File

@@ -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 = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
@@ -57,7 +57,7 @@ export async function initClassrooms() {
<td>
<div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.isAvailable ? 'Доступна' : 'Не доступна'}
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span>
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
@@ -66,7 +66,9 @@ export async function initClassrooms() {
</td>
<td style="text-align: right;">
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button>
<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Удалить</button>
${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
</td>
</tr>`;
}).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;

View File

@@ -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 = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
subjectCount.textContent = 'Загрузка...';
try {
const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`);
subjectCount.textContent = subjectCountLabel(subjects.length);
if (!subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Дисциплины не найдены</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(subject => `
<tr>
<td>${escapeHtml(subject.code || '-')}</td>
<td>${escapeHtml(subject.name || '-')}</td>
<td>${escapeHtml(subject.description || '-')}</td>
<td>
<button type="button" class="btn-edit-classroom department-comments-open"
data-subject-id="${escapeHtml(subject.id)}"
data-subject-name="${escapeHtml(subject.name || '')}">
Открыть
</button>
</td>
</tr>
`).join('');
} catch (error) {
subjectsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}</td></tr>`;
subjectCount.textContent = '0 дисциплин';
}
}
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 => `
<article class="department-comment-item">
<strong>${escapeHtml(comment.authorName || 'Автор не указан')}</strong>
<span>${escapeHtml(formatDateTime(comment.createdAt))}</span>
<p>${escapeHtml(comment.comment)}</p>
</article>
`).join('')
: '<div class="loading-row">Комментариев пока нет</div>';
} catch (error) {
commentsList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки комментариев')}</div>`;
}
}
async function saveComment(event) {
event.preventDefault();
const subjectId = commentsSubjectInput.value;
const comment = commentsTextInput.value.trim();
if (!subjectId) {
showAlert('department-comment-alert', 'Выберите дисциплину', 'error');
return;
}
if (!comment) {
showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error');
return;
}
try {
await api.post(`/api/department/subjects/${subjectId}/comments`, { comment });
showAlert('department-comment-alert', 'Комментарий сохранён', 'success');
await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', ''));
} catch (error) {
showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error');
}
}
async function loadTeachers() {
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 => `
<article class="metric-card">
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
</article>
`).join('')
: '<div class="loading-row">Преподаватели не найдены</div>';
} catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`;
}
}
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 => `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
</article>
`).join('')
: '<div class="loading-row">Нагрузка не найдена</div>';
} catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`;
}
}
function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
}
}
async function loadDepartments(role, userDepartmentId) {
const departments = await api.get('/api/departments');
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
if (role === ROLE_DEPARTMENT && userDepartmentId) {
document.getElementById('department-workspace-department').value = userDepartmentId;
} else if (!document.getElementById('department-workspace-department').value && departments.length) {
document.getElementById('department-workspace-department').value = departments[0].id;
}
}
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
const select = document.getElementById(id);
if (!select) return;
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
}
function setDefaultDates(startInput, endInput) {
const today = new Date();
const nextWeek = new Date();
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} дисциплин`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,22 @@
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');
@@ -10,7 +24,7 @@ export async function initUsers() {
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 =
@@ -34,7 +48,9 @@ export async function initUsers() {
<td>${escapeHtml(u.departmentName || '-')}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td>
<button class="btn-delete" data-id="${u.id}">Удалить</button>
${u.status === 'ARCHIVED'
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
: `<button class="btn-delete" data-id="${u.id}">Архивировать</button>`}
</td>
</tr>
`).join('');
@@ -77,12 +93,24 @@ export async function initUsers() {
const deleteBtn = e.target.closest('.btn-delete');
if (!deleteBtn) return;
if (!confirm('Удалить пользователя?')) return;
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
try {
await api.delete('/api/users/' + deleteBtn.dataset.id);
loadUsers();
} catch (err) {
alert(err.message || 'Ошибка удаления');
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 || 'Ошибка восстановления');
}
});

View File

@@ -72,6 +72,10 @@
overflow: hidden;
}
.nav-item[hidden] {
display: none !important;
}
.nav-item::before {
content: '';
position: absolute;

View File

@@ -4,7 +4,7 @@ import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/drop
// Проверка авторизации
const token = localStorage.getItem('token');
const role = localStorage.getItem('role');
if (!token || role !== 'ADMIN') {
if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
window.location.href = '/';
}
@@ -27,6 +27,13 @@ const ROUTES = {
},
};
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-элементы
@@ -76,6 +83,7 @@ sidebarOverlay.addEventListener('click', () => {
// Навигация
navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
@@ -84,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');
@@ -114,4 +122,4 @@ async function switchTab(tab) {
}
// Вкладка по умолчанию
switchTab('general');
switchTab(defaultTab);

View File

@@ -1,10 +1,19 @@
<div class="card">
<div class="card-header-row" style="margin-bottom: 1.5rem;">
<h2>Загруженность аудиторий</h2>
<h2>Загруженность</h2>
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button>
</div>
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
<div class="form-group workload-target-control">
<label for="workload-target-select">Что смотреть</label>
<select id="workload-target-select" class="workload-display-select" data-native-select="true">
<option value="classrooms">Аудитории</option>
<option value="teachers">Преподаватели</option>
<option value="departments">Кафедры</option>
</select>
</div>
<div class="form-group workload-display-control">
<label for="workload-display-select">Отображение</label>
<select id="workload-display-select" class="workload-display-select" data-native-select="true">
@@ -74,7 +83,7 @@
<tr id="workload-header-row">
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
<span class="bottom-label">Сущность</span>
</th>
</tr>
</thead>

View File

@@ -0,0 +1,89 @@
<div class="card">
<div class="card-header-row">
<h2>Рабочая область кафедры</h2>
<button type="button" class="btn-primary" id="department-workspace-refresh">Обновить</button>
</div>
<div class="schedule-view-filter-grid">
<div class="form-group" id="department-workspace-department-field">
<label for="department-workspace-department">Кафедра</label>
<select id="department-workspace-department" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="department-workspace-start">Начало периода</label>
<input type="date" id="department-workspace-start">
</div>
<div class="form-group">
<label for="department-workspace-end">Окончание периода</label>
<input type="date" id="department-workspace-end">
</div>
</div>
<div class="form-alert" id="department-workspace-alert" role="alert"></div>
</div>
<div class="department-workspace-grid">
<div class="card create-card">
<div class="card-header-row">
<h2>Загрузка дисциплин</h2>
</div>
<form id="department-subject-import-form" class="stack-form">
<div class="form-group">
<label for="department-subject-import-text">Строки в формате «код; название»</label>
<textarea id="department-subject-import-text" rows="8" placeholder="DB201; Базы данных&#10;AI101; Искусственный интеллект"></textarea>
</div>
<button type="submit" class="btn-primary">Загрузить</button>
<div class="form-alert" id="department-subject-import-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Преподаватели и нагрузка</h2>
</div>
<div class="metric-grid" id="department-teachers-list">Загрузка...</div>
<div class="metric-grid department-workload-list" id="department-workload-list">Загрузка...</div>
</div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Дисциплины кафедры</h2>
<span class="muted" id="department-subject-count">0 дисциплин</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Код</th>
<th>Название</th>
<th>Описание</th>
<th>Комментарии</th>
</tr>
</thead>
<tbody id="department-subjects-tbody">
<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card" id="department-comments-card" hidden>
<div class="card-header-row">
<h2 id="department-comments-title">Комментарии</h2>
<button type="button" class="btn-edit-classroom" id="department-comments-close">Скрыть</button>
</div>
<form id="department-comment-form" class="form-row">
<input type="hidden" id="department-comment-subject-id">
<div class="form-group">
<label for="department-comment-text">Новый комментарий</label>
<input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине">
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</form>
<div class="form-alert" id="department-comment-alert" role="alert"></div>
<div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div>
</div>

View File

@@ -0,0 +1,122 @@
<section class="schedule-view-shell">
<div class="card schedule-view-control-card">
<div class="schedule-view-control-header">
<div>
<h2>Расписания</h2>
<span class="muted" id="schedule-view-period-label">Период не выбран</span>
</div>
<button type="button" class="btn-primary" id="schedule-view-load">Показать</button>
</div>
<div class="schedule-view-control-grid">
<div class="form-group">
<label for="schedule-view-target">Что смотреть</label>
<select id="schedule-view-target" data-native-select="true">
<option value="groups">Группа</option>
<option value="teachers">Преподаватель</option>
<option value="classrooms">Аудитория</option>
<option value="departments">Кафедра</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
<label for="schedule-view-group">Группа</label>
<select id="schedule-view-group" data-native-select="true">
<option value="">Все группы</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
<label for="schedule-view-teacher">Преподаватель</label>
<select id="schedule-view-teacher" data-native-select="true">
<option value="">Все преподаватели</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
<label for="schedule-view-classroom">Аудитория</label>
<select id="schedule-view-classroom" data-native-select="true">
<option value="">Все аудитории</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
<label for="schedule-view-department">Кафедра</label>
<select id="schedule-view-department" data-native-select="true">
<option value="">Все кафедры</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-date">Дата в периоде</label>
<input type="date" id="schedule-view-date">
</div>
<div class="form-group schedule-view-period-control">
<span class="form-label">Переход</span>
<div class="schedule-view-period-buttons">
<button type="button" class="btn-secondary" id="schedule-view-prev-period" aria-label="Предыдущий период"></button>
<button type="button" class="btn-secondary" id="schedule-view-today">Сегодня</button>
<button type="button" class="btn-secondary" id="schedule-view-next-period" aria-label="Следующий период"></button>
</div>
</div>
</div>
<details class="schedule-view-extra-filters">
<summary>Дополнительные фильтры</summary>
<div class="schedule-view-extra-grid">
<div class="form-group">
<label for="schedule-view-subject">Дисциплина</label>
<select id="schedule-view-subject" data-native-select="true">
<option value="">Все дисциплины</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-lesson-type">Тип занятия</label>
<select id="schedule-view-lesson-type" data-native-select="true">
<option value="">Все типы</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-parity">Неделя</label>
<select id="schedule-view-parity" data-native-select="true">
<option value="">Любая</option>
<option value="BOTH">Каждая</option>
<option value="ODD">Нечётная</option>
<option value="EVEN">Чётная</option>
</select>
</div>
</div>
</details>
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
</div>
<div class="card schedule-view-results-card">
<div class="schedule-view-results-header">
<div>
<h2>Найденные расписания</h2>
<span class="muted" id="schedule-view-count">0 занятий</span>
</div>
<div class="schedule-view-result-actions">
<button type="button" class="btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание"></button>
<span id="schedule-view-active-label">Нет выбранного расписания</span>
<button type="button" class="btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание"></button>
</div>
</div>
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>
</div>
<div class="card schedule-view-table-card">
<div class="schedule-view-table-header">
<div>
<h2 id="schedule-view-table-title">Расписание</h2>
<span class="muted" id="schedule-view-table-caption">Выберите параметры и нажмите «Показать»</span>
</div>
<div class="schedule-view-day-tabs" id="schedule-view-day-tabs" hidden></div>
</div>
<div class="schedule-view-table-stage" id="schedule-view-tables">
<div class="loading-row schedule-view-state">Загрузка...</div>
</div>
</div>
</section>

View File

@@ -16,6 +16,9 @@
<select id="new-role">
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="ADMIN">Администратор</option>
</select>
</div>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Кабинет кафедры</title>
<meta http-equiv="refresh" content="0; url=/admin/#department-workspace">
</head>
<body>
<script>
window.location.replace('/admin/#department-workspace');
</script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Учебный отдел</title>
<meta http-equiv="refresh" content="0; url=/admin/#schedule-view">
</head>
<body>
<script>
window.location.replace('/admin/#schedule-view');
</script>
</body>
</html>