ролевая моделю доступа + загруженность по кафедрам и преподавателям + просморт расписания

This commit is contained in:
Zuev
2026-05-19 23:10:56 +03:00
parent f010ffc467
commit 91729cff7d
93 changed files with 4264 additions and 699 deletions

View File

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

View File

@@ -1,541 +1,245 @@
# План реализации задач после созвона с научным руководителем # Отчёт об изменениях по задачам научного руководителя
Дата: 2026-05-19 Дата: 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, мультитенантность через отдельную БД на тенант. - `AuthSessionService` — in-memory UUID-сессии;
- Frontend: Vanilla JS, отдельные интерфейсы `/admin/`, `/teacher/`, `/student/`. - `AuthContext` — текущий пользователь в `ThreadLocal`;
- Роли сейчас ограничены `ADMIN`, `TEACHER`, `STUDENT`. - `AuthorizationInterceptor` — проверка `Authorization: Bearer ...`;
- Авторизация возвращает UUID-токен и роль, но полноценной серверной проверки прав по эндпоинтам пока нет. - `@RequireRoles` — ограничение доступа на уровне контроллеров и методов;
- Динамическое расписание строится из: - `GET /api/auth/me` — текущий пользователь по токену.
- `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`, но это простая текущая связь без периода действия, комментариев и разбиения по ответственности.
### Основные проблемы текущей модели 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. **Версия расписания**: прошлое расписание должно оставаться воспроизводимым, даже если текущие правила изменились.
После этого все новые экраны работают с одной логикой: ```text
backend/src/main/resources/db/migration/V1__init.sql
- активные справочники доступны для выбора;
- архивные записи видны в истории, но не выбираются для будущих назначений;
- отчёты за прошлый период используют связи, действовавшие на дату занятия;
- правки будущего расписания не ломают прошлые отчёты.
## 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
``` ```
Поля: `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
Новые или расширенные сущности:
- lifecycle-поля к справочникам и пользователям;
- `teacher_department_assignments`;
- `subject_comments`; - `subject_comments`;
- `subject_department_assignments` или темпоральные поля у `subjects`; - version-поля к `schedule_rules`;
- версионированные `teacher_subjects`; - lock-поля к `schedule_rule_slots`;
- `teacher_lesson_types` вывести в полноценный API, если таблица уже есть в схеме. - `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` — дисциплины кафедры; Добавлены endpoint:
- `teachers` — преподаватели кафедры и их дисциплины;
- `schedule` — расписание кафедры;
- `workload` — нагрузка;
- `comments` или встроенные комментарии в карточках.
## 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` `GET /api/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`
### 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 Отдельные интерфейсы `/department/` и `/edu-office/` заменены на redirect в общую админ-панель:
GET /api/schedule/search
- `/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`; - карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
- `teacherId`;
- `classroomId`;
- `departmentId`;
- `subjectId`;
- `lessonTypeId`;
- `timeSlotId`;
- `parity`;
- `includeArchived`.
Правило: можно передавать один или несколько фильтров. Если фильтров нет, доступ зависит от роли: ### Загруженность
- `ADMIN` и `EDUCATION_OFFICE` могут видеть всё; Вкладка `auditorium-workload` оставлена техническим tab id, но в UI называется `Загруженность`.
- `DEPARTMENT` видит свою кафедру;
- `TEACHER` видит себя;
- `STUDENT` видит доступные группы или свою группу, когда появится привязка студента к группе.
Форматы отображения: Возможности:
- недельная сетка; - выбор типа загруженности: аудитории, преподаватели или кафедры;
- табличный список; - сводная матрица по выбранной дате: строки — выбранные сущности, столбцы — пары;
- вид по аудиториям; - кафедральная матрица группирует занятия по кафедре преподавателя;
- вид по преподавателям; - для аудиторий сохранены фильтры корпуса, вместимости и оборудования;
- вид по кафедрам. - выбор конкретной аудитории, преподавателя или кафедры заменяет обзор двухнедельной таблицей по дням недели и времени;
- чётная и нечётная недели показываются в одной ячейке: если состояние одинаковое, ячейка цельная, если отличается — делится вертикально.
## 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 - `AGENTS.md`;
- `docs/README.md`;
Существующие миграции не изменять. Новые изменения оформлять новыми файлами:
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`;
- `docs/API.md`; - `docs/API.md`;
- `docs/DATABASE.md`;
- `docs/BUSINESS_LOGIC.md`; - `docs/BUSINESS_LOGIC.md`;
- `docs/FRONTEND.md`; - `docs/FRONTEND.md`;
- `docs/ARCHITECTURE.md`, если меняются роли, авторизация или темпоральная модель. - `docs/ARCHITECTURE.md`.
Для больших этапов лучше добавлять отдельные документы: ## Проверки
- `docs/RBAC.md`; Статически проверены новые и изменённые JS-файлы:
- `docs/TEMPORAL_MODEL.md`;
- `docs/SCHEDULE_EDITING.md`;
- `docs/WORKLOAD_ANALYTICS.md`.
## 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` ```bash
- Прогон Flyway на чистой PostgreSQL БД. 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-файлов. Единая `V1__init.sql` проверена на пустой PostgreSQL 16 внутри Docker: SQL применился успешно.
- `git diff --check`.
- Ручной smoke-test в браузере:
- вход под каждой ролью;
- видимость нужных вкладок;
- запрет доступа к чужим эндпоинтам;
- создание/архивирование/восстановление справочников;
- редактирование расписания учебным отделом;
- просмотр расписания и загруженности по разным фильтрам.
## 17. Риски и вопросы, которые нужно уточнить Локальный `mvn` в окружении отсутствует, поэтому используется Docker.
1. Что именно руководитель имеет в виду под "темпоральными базами данных": только история изменений или полноценная bitemporal-модель с `valid_time` и `transaction_time`. ## Что осталось следующим этапом
2. Нужно ли хранить фактические проведённые занятия отдельно от планового расписания.
3. Какие форматы файлов кафедра должна "грузить": XLSX, CSV, шаблон учебного плана, свободная таблица.
4. Кто именно является пользователем роли `DEPARTMENT`: заведующий кафедрой, оператор кафедры или несколько сотрудников.
5. Нужна ли студентам привязка к конкретной группе. Сейчас студент выбирает группу вручную.
6. Должно ли удаление/архивирование кафедры запрещаться при наличии активных преподавателей, групп и дисциплин или разрешаться с каскадной архивацией.
7. Нужно ли вести журнал всех действий пользователя для юридически значимой истории изменений.
## 18. Рекомендуемый порядок внедрения Остались задачи, которые требуют отдельного цикла проработки:
1. Роли и серверная авторизация. - UI для просмотра истории переводов преподавателя в админке;
2. Мягкое удаление аудиторий и справочников. - UI для списка и редактирования уже созданных `schedule_overrides`;
3. История переводов преподавателей. - более строгая проверка конфликтов перед сохранением переносов;
4. Версионирование расписания и точечные изменения. - полноценный импорт XLSX/CSV для кафедры вместо текстовой загрузки;
5. Проверка конфликтов. - браузерный smoke-test и проверка ролевых вкладок после запуска `localhost:80`.
6. Универсальный `schedule/search`.
7. Загруженность преподавателей, кафедр и пар.
8. Кабинет кафедры.
9. Кабинет учебного отдела.
10. Финальная синхронизация документации и регрессионный smoke-test.
Такой порядок закрывает базовую целостность данных до того, как появятся новые роли и экраны, которые начнут массово создавать и менять расписание.

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.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.magistr.app.config.auth.AuthorizationInterceptor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -123,6 +124,9 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
private TenantRoutingDataSource tenantRoutingDataSource; private TenantRoutingDataSource tenantRoutingDataSource;
@org.springframework.beans.factory.annotation.Autowired
private AuthorizationInterceptor authorizationInterceptor;
@Bean @Bean
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) { public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
TenantInterceptor interceptor = new TenantInterceptor(); TenantInterceptor interceptor = new TenantInterceptor();
@@ -134,6 +138,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB) // Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**"); registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
} }
private List<TenantConfig> loadTenantsFromFile() { private List<TenantConfig> loadTenantsFromFile() {

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*; import com.magistr.app.dto.*;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.*; import com.magistr.app.repository.*;
@@ -13,6 +14,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/admin/calendar") @RequestMapping("/api/admin/calendar")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarAdminController { public class AcademicCalendarAdminController {
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;

View File

@@ -1,5 +1,6 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.*; import com.magistr.app.dto.*;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.*; import com.magistr.app.repository.*;
@@ -13,6 +14,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/admin/academic-calendars") @RequestMapping("/api/admin/academic-calendars")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class AcademicCalendarController { public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository; 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.LoginRequest;
import com.magistr.app.dto.LoginResponse; 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.model.User;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -10,7 +12,6 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@@ -18,16 +19,23 @@ public class AuthController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
private final AuthSessionService sessionService;
private static final Map<String, String> ROLE_REDIRECTS = Map.of( private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/", "ADMIN", "/admin/",
"EDUCATION_OFFICE", "/admin/#schedule-view",
"DEPARTMENT", "/admin/#department-workspace",
"SCHEDULE_VIEWER", "/admin/#schedule-view",
"TEACHER", "/teacher/", "TEACHER", "/teacher/",
"STUDENT", "/student/" "STUDENT", "/student/"
); );
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
AuthSessionService sessionService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.sessionService = sessionService;
} }
@PostMapping("/login") @PostMapping("/login")
@@ -42,11 +50,30 @@ public class AuthController {
} }
User user = userOpt.get(); 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 roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
Long departmentId = user.getDepartmentId(); Long departmentId = user.getDepartmentId();
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId())); 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; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ClassroomRequest; import com.magistr.app.dto.ClassroomRequest;
import com.magistr.app.dto.ClassroomResponse; import com.magistr.app.dto.ClassroomResponse;
import com.magistr.app.model.Classroom; import com.magistr.app.model.Classroom;
import com.magistr.app.model.Equipment; 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.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository; import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -15,6 +18,7 @@ import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/classrooms") @RequestMapping("/api/classrooms")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ClassroomController { public class ClassroomController {
private final ClassroomRepository classroomRepository; private final ClassroomRepository classroomRepository;
@@ -26,8 +30,12 @@ public class ClassroomController {
} }
@GetMapping @GetMapping
public List<ClassroomResponse> getAllClassrooms() { @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
return classroomRepository.findAll().stream() 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) .map(this::mapToResponse)
.toList(); .toList();
} }
@@ -52,7 +60,9 @@ public class ClassroomController {
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true); classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) { 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)); classroom.setEquipments(new java.util.HashSet<>(equipments));
} }
@@ -96,6 +106,9 @@ public class ClassroomController {
if (request.getEquipmentIds() != null) { if (request.getEquipmentIds() != null) {
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds()); List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
equipments = equipments.stream()
.filter(Equipment::isActiveRecord)
.toList();
classroom.setEquipments(new java.util.HashSet<>(equipments)); classroom.setEquipments(new java.util.HashSet<>(equipments));
} }
@@ -105,15 +118,33 @@ public class ClassroomController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) { public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
if (!classroomRepository.existsById(id)) { Optional<Classroom> classroomOpt = classroomRepository.findById(id);
if (classroomOpt.isEmpty()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
classroomRepository.deleteById(id); Classroom classroom = classroomOpt.get();
return ResponseEntity.ok(Map.of("message", "Аудитория удалена")); 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) { private ClassroomResponse mapToResponse(Classroom c) {
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(), return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
c.getStatus(), c.getArchivedAt(), c.getArchiveReason(),
new java.util.ArrayList<>(c.getEquipments())); new java.util.ArrayList<>(c.getEquipments()));
} }

View File

@@ -1,10 +1,12 @@
package com.magistr.app.controller; 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.ConfigMapUpdater;
import com.magistr.app.config.tenant.TenantConfig; import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher; import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource; import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.model.Role;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -24,6 +26,7 @@ import java.util.Map;
*/ */
@RestController @RestController
@RequestMapping("/api/database") @RequestMapping("/api/database")
@RequireRoles({Role.ADMIN})
public class DatabaseController { public class DatabaseController {
private final TenantRoutingDataSource routingDataSource; private final TenantRoutingDataSource routingDataSource;

View File

@@ -1,8 +1,11 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateDepartmentRequest; import com.magistr.app.dto.CreateDepartmentRequest;
import com.magistr.app.dto.DepartmentResponse; import com.magistr.app.dto.DepartmentResponse;
import com.magistr.app.model.Department; 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 com.magistr.app.repository.DepartmentRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -15,6 +18,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/departments") @RequestMapping("/api/departments")
@RequireRoles({Role.ADMIN})
public class DepartmentController { public class DepartmentController {
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class); private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
@@ -26,10 +30,13 @@ public class DepartmentController {
} }
@GetMapping @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("Получен запрос на получение списка кафедр"); logger.info("Получен запрос на получение списка кафедр");
try { try {
List<Department> departments = departmentRepository.findAll(); List<Department> departments = includeArchived
? departmentRepository.findAll()
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Department> response = departments.stream() List<Department> response = departments.stream()
.map( d -> new Department( .map( d -> new Department(
d.getId(), d.getId(),
@@ -150,12 +157,29 @@ public class DepartmentController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) { public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id); logger.info("Получен запрос на удаление кафедры с ID: {}", id);
if (!departmentRepository.existsById(id)) { Department department = departmentRepository.findById(id).orElse(null);
if (department == null) {
logger.info("Кафедра с ID - {} не найдена", id); logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
departmentRepository.deleteById(id); department.archive("Кафедра архивирована");
logger.info("Кафедра с ID - {} успешно удалена", id); departmentRepository.save(department);
return ResponseEntity.ok(Map.of("message", "Кафедра удалена")); 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; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.model.EducationForm; import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.EducationFormRepository; import com.magistr.app.repository.EducationFormRepository;
@@ -13,6 +15,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/education-forms") @RequestMapping("/api/education-forms")
@RequireRoles({Role.ADMIN})
public class EducationFormController { public class EducationFormController {
private final EducationFormRepository educationFormRepository; private final EducationFormRepository educationFormRepository;
@@ -28,6 +31,7 @@ public class EducationFormController {
} }
@GetMapping @GetMapping
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<Map<String, Object>> getAll() { public List<Map<String, Object>> getAll() {
return educationFormRepository.findAllByOrderByNameAsc().stream() return educationFormRepository.findAllByOrderByNameAsc().stream()
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName())) .map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))

View File

@@ -1,5 +1,8 @@
package com.magistr.app.controller; 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.model.Equipment;
import com.magistr.app.repository.EquipmentRepository; import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -10,6 +13,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/equipments") @RequestMapping("/api/equipments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class EquipmentController { public class EquipmentController {
private final EquipmentRepository equipmentRepository; private final EquipmentRepository equipmentRepository;
@@ -19,8 +23,8 @@ public class EquipmentController {
} }
@GetMapping @GetMapping
public List<Equipment> getAllEquipments() { public List<Equipment> getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) {
return equipmentRepository.findAll(); return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
} }
@PostMapping @PostMapping
@@ -42,10 +46,23 @@ public class EquipmentController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteEquipment(@PathVariable Long 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(); return ResponseEntity.notFound().build();
} }
equipmentRepository.deleteById(id); equipment.archive("Оборудование архивировано");
return ResponseEntity.ok(Map.of("message", "Оборудование удалено")); 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; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse; import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear; import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm; 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.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
@@ -25,6 +28,7 @@ import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/groups") @RequestMapping("/api/groups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT})
public class GroupController { public class GroupController {
private static final Logger logger = LoggerFactory.getLogger(GroupController.class); private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
@@ -57,11 +61,13 @@ public class GroupController {
} }
@GetMapping @GetMapping
public List<GroupResponse> getAllGroups() { public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех групп"); logger.info("Получен запрос на получение всех групп");
try { try {
List<StudentGroup> groups = groupRepository.findAll(); List<StudentGroup> groups = includeArchived
? groupRepository.findAll()
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<GroupResponse> response = groups.stream() List<GroupResponse> response = groups.stream()
.map(this::mapToResponse) .map(this::mapToResponse)
@@ -78,7 +84,7 @@ public class GroupController {
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) { public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try { try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId); List<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(groups.isEmpty()) { if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId); logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
@@ -101,6 +107,7 @@ public class GroupController {
} }
@PostMapping @PostMapping
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) { public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}", logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy()); request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
@@ -146,6 +153,7 @@ public class GroupController {
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) { public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на обновление группы с ID - {}", id); logger.info("Получен запрос на обновление группы с ID - {}", id);
try { try {
@@ -195,15 +203,30 @@ public class GroupController {
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@RequireRoles({Role.ADMIN})
public ResponseEntity<?> deleteGroup(@PathVariable Long id) { public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
logger.info("Получен запрос на удаление группы с ID - {}", id); logger.info("Получен запрос на удаление группы с ID - {}", id);
if (!groupRepository.existsById(id)) { StudentGroup group = groupRepository.findById(id).orElse(null);
if (group == null) {
logger.info("Группа с ID - {} не найдена", id); logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
groupRepository.deleteById(id); group.archive("Группа архивирована");
logger.info("Группа с ID - {} успешно удалена", id); groupRepository.save(group);
return ResponseEntity.ok(Map.of("message", "Группа удалена")); 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) { private ResponseEntity<?> validateGroupRequest(CreateGroupRequest request) {
@@ -260,6 +283,7 @@ public class GroupController {
} }
@GetMapping("/{id}/calendar-assignments") @GetMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) { public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
if (!groupRepository.existsById(id)) { if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -270,6 +294,7 @@ public class GroupController {
} }
@PutMapping("/{id}/calendar-assignments") @PutMapping("/{id}/calendar-assignments")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id, public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
@RequestBody GroupCalendarAssignmentDto request) { @RequestBody GroupCalendarAssignmentDto request) {
if (request == null || request.academicYearId() == null || request.calendarId() == null) { if (request == null || request.academicYearId() == null || request.calendarId() == null) {
@@ -309,6 +334,7 @@ public class GroupController {
} }
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}") @DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) { public ResponseEntity<?> deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) {
StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null); StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null);
if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) { if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) {

View File

@@ -1,7 +1,9 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.LessonTypeResponse; import com.magistr.app.dto.LessonTypeResponse;
import com.magistr.app.model.LessonType; import com.magistr.app.model.LessonType;
import com.magistr.app.model.Role;
import com.magistr.app.repository.LessonTypesRepository; import com.magistr.app.repository.LessonTypesRepository;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -12,6 +14,7 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/lesson-types") @RequestMapping("/api/lesson-types")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public class LessonTypeController { public class LessonTypeController {
private final LessonTypesRepository lessonTypesRepository; private final LessonTypesRepository lessonTypesRepository;

View File

@@ -1,7 +1,9 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.RenderedLessonDto; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
@@ -14,14 +16,15 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/schedule") @RequestMapping("/api/schedule")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
public class ScheduleController { public class ScheduleController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class); private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleQueryService scheduleQueryService;
public ScheduleController(ScheduleGeneratorService scheduleGeneratorService) { public ScheduleController(ScheduleQueryService scheduleQueryService) {
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleQueryService = scheduleQueryService;
} }
@GetMapping @GetMapping
@@ -40,9 +43,18 @@ public class ScheduleController {
} }
try { try {
List<RenderedLessonDto> lessons = groupId != null List<RenderedLessonDto> lessons = scheduleQueryService.search(
? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate) groupId,
: scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); teacherId,
null,
null,
null,
null,
null,
null,
startDate,
endDate
);
return ResponseEntity.ok(lessons); return ResponseEntity.ok(lessons);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage()); 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; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto; import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto; import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*; import com.magistr.app.model.*;
@@ -14,6 +15,7 @@ import java.util.*;
@RestController @RestController
@RequestMapping("/api/admin/schedule-rules") @RequestMapping("/api/admin/schedule-rules")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleRuleAdminController { public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT"; private static final String APPLY_MODE_DEFAULT = "DEFAULT";
@@ -112,12 +114,14 @@ public class ScheduleRuleAdminController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Long 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(); return ResponseEntity.notFound().build();
} }
scheduleRuleRepository.deleteById(id); rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Правило расписания удалено")); return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
} }
private String validateRule(ScheduleRuleDto request) { private String validateRule(ScheduleRuleDto request) {
@@ -143,12 +147,18 @@ public class ScheduleRuleAdminController {
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) { private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
Subject subject = subjectRepository.findById(request.subjectId()) Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена")); .orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
}
Semester semester = semesterRepository.findById(request.semesterId()) Semester semester = semesterRepository.findById(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); .orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds()); List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
if (groups.size() != new HashSet<>(request.groupIds()).size()) { if (groups.size() != new HashSet<>(request.groupIds()).size()) {
throw new IllegalArgumentException("Одна или несколько групп не найдены"); throw new IllegalArgumentException("Одна или несколько групп не найдены");
} }
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
}
rule.setSubject(subject); rule.setSubject(subject);
rule.setSemester(semester); rule.setSemester(semester);
@@ -186,8 +196,14 @@ public class ScheduleRuleAdminController {
} }
User teacher = userRepository.findById(slotDto.teacherId()) User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (teacher.isArchivedRecord()) {
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
}
Classroom classroom = classroomRepository.findById(slotDto.classroomId()) Classroom classroom = classroomRepository.findById(slotDto.classroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")); .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
}
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType); 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; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSpecialityRequest; import com.magistr.app.dto.CreateSpecialityRequest;
import com.magistr.app.dto.SpecialityResponse; import com.magistr.app.dto.SpecialityResponse;
import com.magistr.app.dto.SpecialtyProfileDto; 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.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.SpecialtiesRepository; import com.magistr.app.repository.SpecialtiesRepository;
@@ -18,6 +21,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/specialties") @RequestMapping("/api/specialties")
@RequireRoles({Role.ADMIN})
public class SpecialityController { public class SpecialityController {
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class); private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
@@ -32,10 +36,12 @@ public class SpecialityController {
} }
@GetMapping @GetMapping
public List<Speciality> getAllSpecialties() { public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение списка специальностей"); logger.info("Получен запрос на получение списка специальностей");
try { try {
List<Speciality> specialities = specialtiesRepository.findAll(); List<Speciality> specialities = includeArchived
? specialtiesRepository.findAll()
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Speciality> response = specialities.stream() List<Speciality> response = specialities.stream()
.map( s -> new Speciality( .map( s -> new Speciality(
s.getId(), s.getId(),
@@ -161,13 +167,30 @@ public class SpecialityController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) { public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id); logger.info("Получен запрос на удаление специальности с ID: {}", id);
if (!specialtiesRepository.existsById(id)) { Speciality speciality = specialtiesRepository.findById(id).orElse(null);
if (speciality == null) {
logger.info("Специальность с ID - {} не найдена", id); logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
specialtiesRepository.deleteById(id); speciality.archive("Специальность архивирована");
logger.info("Специальность с ID - {} успешно удалена", id); specialtiesRepository.save(speciality);
return ResponseEntity.ok(Map.of("message", "Специальность удалена")); 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") @GetMapping("/{specialtyId}/profiles")

View File

@@ -1,6 +1,8 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.SubgroupDto; import com.magistr.app.dto.SubgroupDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup; import com.magistr.app.model.Subgroup;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
@@ -15,6 +17,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@RestController @RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController { public class SubgroupController {
private final SubgroupRepository subgroupRepository; private final SubgroupRepository subgroupRepository;
@@ -50,6 +53,7 @@ public class SubgroupController {
} }
@PostMapping("/api/groups/{groupId}/subgroups") @PostMapping("/api/groups/{groupId}/subgroups")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) { public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findById(groupId).orElse(null); StudentGroup group = groupRepository.findById(groupId).orElse(null);
if (group == null) { if (group == null) {
@@ -73,6 +77,7 @@ public class SubgroupController {
} }
@PutMapping("/api/groups/{groupId}/subgroups/{id}") @PutMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> update(@PathVariable Long groupId, public ResponseEntity<?> update(@PathVariable Long groupId,
@PathVariable Long id, @PathVariable Long id,
@RequestBody SubgroupDto request) { @RequestBody SubgroupDto request) {
@@ -99,6 +104,7 @@ public class SubgroupController {
} }
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}") @DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) { public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null); Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) { if (subgroup == null) {
@@ -108,9 +114,10 @@ public class SubgroupController {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
} }
try { try {
subgroupRepository.delete(subgroup); subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа удалена")); return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
} catch (DataIntegrityViolationException e) { } catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
} }

View File

@@ -1,7 +1,10 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse; import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject; import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubjectRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -16,6 +19,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/subjects") @RequestMapping("/api/subjects")
@RequireRoles({Role.ADMIN})
public class SubjectController { public class SubjectController {
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class); private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
@@ -27,10 +31,13 @@ public class SubjectController {
} }
@GetMapping @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("Получен запрос на получение всех дисциплин"); logger.info("Получен запрос на получение всех дисциплин");
try { try {
List<Subject> subjects = subjectRepository.findAll(); List<Subject> subjects = includeArchived
? subjectRepository.findAll()
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<Subject> response = subjects.stream() List<Subject> response = subjects.stream()
.map(s -> new Subject( .map(s -> new Subject(
s.getId(), s.getId(),
@@ -48,10 +55,11 @@ public class SubjectController {
} }
@GetMapping("/{departmentId}") @GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) { public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId); logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
try{ try{
List<Subject> subjects = subjectRepository.findByDepartmentId(departmentId); List<Subject> subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
if(subjects.isEmpty()){ if(subjects.isEmpty()){
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId); logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
@@ -121,12 +129,31 @@ public class SubjectController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) { public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
logger.info("Получен запрос на удаление дисциплины с ID: {}", id); logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
if (!subjectRepository.existsById(id)) { Subject subject = subjectRepository.findById(id).orElse(null);
if (subject == null) {
logger.info("Дисциплина с ID - {} не найдена", id); logger.info("Дисциплина с ID - {} не найдена", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
subjectRepository.deleteById(id); subject.archive("Дисциплина архивирована");
logger.info("Дисциплина с ID - {} успешно удалена", id); subjectRepository.save(subject);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена")); 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; 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.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.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId; import com.magistr.app.model.TeacherSubjectId;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository; import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
@@ -14,6 +19,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/teacher-subjects") @RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class TeacherSubjectController { public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository; private final TeacherSubjectRepository teacherSubjectRepository;
@@ -31,6 +37,7 @@ public class TeacherSubjectController {
@GetMapping @GetMapping
public List<TeacherSubjectResponse> getAll() { public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream() return teacherSubjectRepository.findAll().stream()
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
.map(ts -> new TeacherSubjectResponse( .map(ts -> new TeacherSubjectResponse(
ts.getUserId(), ts.getUserId(),
ts.getUser().getUsername(), ts.getUser().getUsername(),
@@ -48,12 +55,17 @@ public class TeacherSubjectController {
if (userId == null || subjectId == null) { if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны")); 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", "Преподаватель не найден")); 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", "Дисциплина не найдена")); 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); TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (teacherSubjectRepository.existsById(id)) { if (teacherSubjectRepository.existsById(id)) {
@@ -76,10 +88,30 @@ public class TeacherSubjectController {
} }
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId); TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (!teacherSubjectRepository.existsById(id)) { TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null);
if (teacherSubject == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) {
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки"));
}
teacherSubjectRepository.deleteById(id); teacherSubjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Привязка удалена")); 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; package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TimeSlotDateAssignmentDto; import com.magistr.app.dto.TimeSlotDateAssignmentDto;
import com.magistr.app.dto.TimeSlotDto; import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.dto.TimeSlotScopeDto; import com.magistr.app.dto.TimeSlotScopeDto;
import com.magistr.app.model.TimeSlot; import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotDateAssignment; import com.magistr.app.model.TimeSlotDateAssignment;
import com.magistr.app.model.TimeSlotScope; import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.model.Role;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository; import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository; import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository; import com.magistr.app.repository.TimeSlotScopeRepository;
@@ -23,6 +25,7 @@ import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/admin/time-slots") @RequestMapping("/api/admin/time-slots")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class TimeSlotAdminController { public class TimeSlotAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT"; private static final String APPLY_MODE_DEFAULT = "DEFAULT";

View File

@@ -1,43 +1,61 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateUserRequest; import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
import com.magistr.app.dto.UserResponse; import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department; import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/users")
@RequireRoles({Role.ADMIN})
public class UserController { public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class); private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository; private final UserRepository userRepository;
private final DepartmentRepository departmentRepository; private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder; 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.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository; this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
} }
@GetMapping @GetMapping
public List<UserResponse> getAllUsers() { public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей"); logger.info("Получен запрос на получение всех пользователей");
try { try {
List<User> users = userRepository.findAll(); List<User> users = includeArchived
? userRepository.findAll()
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> response = users.stream() List<UserResponse> response = users.stream()
.map(u -> { .map(u -> {
@@ -45,13 +63,15 @@ public class UserController {
.map(Department::getDepartmentName) .map(Department::getDepartmentName)
.orElse("Неизвестно"); .orElse("Неизвестно");
return new UserResponse( UserResponse userResponse = new UserResponse(
u.getId(), u.getId(),
u.getUsername(), u.getUsername(),
u.getRole().name(), u.getRole().name(),
u.getFullName(), u.getFullName(),
u.getJobTitle(), u.getJobTitle(),
departmentName); departmentName);
userResponse.setStatus(u.getStatus());
return userResponse;
}) })
.toList(); .toList();
logger.info("Получено {} пользователей", response.size()); logger.info("Получено {} пользователей", response.size());
@@ -64,11 +84,12 @@ public class UserController {
} }
@GetMapping("/teachers") @GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() { public List<UserResponse> getTeachers() {
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'"); logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
try { try {
List<User> users = userRepository.findByRole(Role.TEACHER); List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
List<UserResponse> response = users.stream() List<UserResponse> response = users.stream()
.map(u -> { .map(u -> {
@@ -76,13 +97,15 @@ public class UserController {
.map(Department::getDepartmentName) .map(Department::getDepartmentName)
.orElse("Неизвестно"); .orElse("Неизвестно");
return new UserResponse( UserResponse userResponse = new UserResponse(
u.getId(), u.getId(),
u.getUsername(), u.getUsername(),
u.getRole().name(), u.getRole().name(),
u.getFullName(), u.getFullName(),
u.getJobTitle(), u.getJobTitle(),
departmentName); departmentName);
userResponse.setStatus(u.getStatus());
return userResponse;
}) })
.toList(); .toList();
logger.info("Получено {} преподавателей", response.size()); logger.info("Получено {} преподавателей", response.size());
@@ -94,10 +117,15 @@ public class UserController {
} }
@GetMapping("/teachers/{departmentId}") @GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){ public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
try { try {
List<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId); List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
Role.TEACHER,
departmentId,
LifecycleEntity.STATUS_ARCHIVED
);
if (users.isEmpty()) { if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
@@ -178,6 +206,19 @@ public class UserController {
user.setJobTitle(request.getJobTitle()); user.setJobTitle(request.getJobTitle());
user.setDepartmentId(request.getDepartmentId()); user.setDepartmentId(request.getDepartmentId());
userRepository.save(user); 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()); logger.info("Пользователь успешно создан с ID: {}", user.getId());
@@ -192,12 +233,111 @@ public class UserController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) { public ResponseEntity<?> deleteUser(@PathVariable Long id) {
logger.info("Получен запрос на удаление пользователя с ID: {}", id); logger.info("Получен запрос на удаление пользователя с ID: {}", id);
if (!userRepository.existsById(id)) { User user = userRepository.findById(id).orElse(null);
if (user == null) {
logger.info("Пользователь с ID - {} не найден", id); logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
userRepository.deleteById(id); user.archive("Пользователь архивирован");
logger.info("Пользователь с ID - {} успешно удалён", id); userRepository.save(user);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён")); 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; package com.magistr.app.dto;
import com.magistr.app.model.Equipment; import com.magistr.app.model.Equipment;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
public class ClassroomResponse { public class ClassroomResponse {
@@ -10,6 +11,9 @@ public class ClassroomResponse {
private String building; private String building;
private Integer floor; private Integer floor;
private Boolean isAvailable; private Boolean isAvailable;
private String status;
private LocalDateTime archivedAt;
private String archiveReason;
private List<Equipment> equipments; private List<Equipment> equipments;
public ClassroomResponse() { public ClassroomResponse() {
@@ -17,12 +21,21 @@ public class ClassroomResponse {
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor, public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, List<Equipment> equipments) { 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.id = id;
this.name = name; this.name = name;
this.capacity = capacity; this.capacity = capacity;
this.building = building; this.building = building;
this.floor = floor; this.floor = floor;
this.isAvailable = isAvailable; this.isAvailable = isAvailable;
this.status = status;
this.archivedAt = archivedAt;
this.archiveReason = archiveReason;
this.equipments = equipments; this.equipments = equipments;
} }
@@ -74,6 +87,30 @@ public class ClassroomResponse {
this.isAvailable = isAvailable; 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() { public List<Equipment> getEquipments() {
return equipments; 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 jobTitle;
private String departmentName; private String departmentName;
private Long departmentId; private Long departmentId;
private String status;
public UserResponse() { public UserResponse() {
} }
@@ -69,4 +70,12 @@ public class UserResponse {
public Long getDepartmentId() { public Long getDepartmentId() {
return departmentId; 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 @Entity
@Table(name = "classrooms") @Table(name = "classrooms")
public class Classroom { public class Classroom extends LifecycleEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity @Entity
@Table(name = "equipments") @Table(name = "equipments")
public class Equipment { public class Equipment extends LifecycleEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @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 { public enum Role {
ADMIN, ADMIN,
EDUCATION_OFFICE,
DEPARTMENT,
SCHEDULE_VIEWER,
TEACHER, TEACHER,
STUDENT 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 jakarta.persistence.*;
import java.time.LocalDate;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
@@ -21,6 +22,15 @@ public class ScheduleRule {
@JoinColumn(name = "semester_id", nullable = false) @JoinColumn(name = "semester_id", nullable = false)
private Semester semester; 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) @Column(name = "lecture_academic_hours", nullable = false)
private Integer lectureAcademicHours = 0; private Integer lectureAcademicHours = 0;
@@ -74,6 +84,30 @@ public class ScheduleRule {
this.semester = semester; this.semester = semester;
} }
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDate getValidFrom() {
return validFrom;
}
public void setValidFrom(LocalDate validFrom) {
this.validFrom = validFrom;
}
public LocalDate getValidTo() {
return validTo;
}
public void setValidTo(LocalDate validTo) {
this.validTo = validTo;
}
public Integer getLectureAcademicHours() { public Integer getLectureAcademicHours() {
return lectureAcademicHours; return lectureAcademicHours;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
@Entity @Entity
@Table(name = "subjects") @Table(name = "subjects")
public class Subject { public class Subject extends LifecycleEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @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 @Entity
@Table(name = "users") @Table(name = "users")
public class User { public class User extends LifecycleEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -4,7 +4,10 @@ import com.magistr.app.model.Classroom;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
import java.util.List;
public interface ClassroomRepository extends JpaRepository<Classroom, Long> { public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
Optional<Classroom> findByName(String name); 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 org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
import java.util.List;
public interface DepartmentRepository extends JpaRepository<Department, Long> { public interface DepartmentRepository extends JpaRepository<Department, Long> {
Optional<Department> findByDepartmentName(String departmentName); Optional<Department> findByDepartmentName(String departmentName);
Optional<Department> findByDepartmentCode(Long departmentCode); 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 org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
import java.util.List;
public interface EquipmentRepository extends JpaRepository<Equipment, Long> { public interface EquipmentRepository extends JpaRepository<Equipment, Long> {
Optional<Equipment> findByName(String name); 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> findByEducationFormId(Long educationFormId);
List<StudentGroup> findByDepartmentId(Long departmentId); 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 left join fetch slots.lessonType
where g.id = :groupId where g.id = :groupId
and sem.id = :semesterId and sem.id = :semesterId
and r.status <> 'ARCHIVED'
""") """)
List<ScheduleRule> findByGroupIdAndSemesterId( List<ScheduleRule> findByGroupIdAndSemesterId(
@Param("groupId") Long groupId, @Param("groupId") Long groupId,
@@ -49,6 +50,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId where teacherSlot.teacher.id = :teacherId
and sem.id = :semesterId and sem.id = :semesterId
and r.status <> 'ARCHIVED'
""") """)
List<ScheduleRule> findByTeacherIdAndSemesterId( List<ScheduleRule> findByTeacherIdAndSemesterId(
@Param("teacherId") Long teacherId, @Param("teacherId") Long teacherId,
@@ -69,6 +71,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.teacher left join fetch slots.teacher
left join fetch slots.classroom left join fetch slots.classroom
left join fetch slots.lessonType left join fetch slots.lessonType
where r.status <> 'ARCHIVED'
order by r.id desc order by r.id desc
""") """)
List<ScheduleRule> findAllWithDetails(); List<ScheduleRule> findAllWithDetails();

View File

@@ -4,10 +4,13 @@ import com.magistr.app.model.Speciality;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
import java.util.List;
public interface SpecialtiesRepository extends JpaRepository<Speciality, Long> { public interface SpecialtiesRepository extends JpaRepository<Speciality, Long> {
Optional<Speciality> findBySpecialityName(String specialityName); Optional<Speciality> findBySpecialityName(String specialityName);
Optional<Speciality> findBySpecialityCode(String specialityCode); 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 select subgroup
from Subgroup subgroup from Subgroup subgroup
join fetch subgroup.studentGroup studentGroup join fetch subgroup.studentGroup studentGroup
where subgroup.status <> 'ARCHIVED'
order by studentGroup.name, subgroup.name order by studentGroup.name, subgroup.name
""") """)
List<Subgroup> findAllWithGroupsOrderByGroupNameAndName(); List<Subgroup> findAllWithGroupsOrderByGroupNameAndName();
@@ -23,6 +24,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
from Subgroup subgroup from Subgroup subgroup
join fetch subgroup.studentGroup join fetch subgroup.studentGroup
where subgroup.studentGroup.id = :groupId where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
order by subgroup.name order by subgroup.name
""") """)
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId); List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
@@ -41,6 +43,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
from Subgroup subgroup from Subgroup subgroup
where subgroup.studentGroup.id = :groupId where subgroup.studentGroup.id = :groupId
and lower(subgroup.name) = lower(:name) and lower(subgroup.name) = lower(:name)
and subgroup.status <> 'ARCHIVED'
""") """)
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name); 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); Optional<Subject> findByName(String name);
List<Subject> findByDepartmentId(Long departmentId); 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> findByRole(Role role);
List<User> findByRoleAndDepartmentId(Role role, Long departmentId); 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())) { if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
return; return;
} }
if (!rule.getSubject().isActiveOn(date)) {
return;
}
Semester semester = semesterOpt.get(); Semester semester = semesterOpt.get();
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
@@ -193,6 +196,12 @@ public class ScheduleGeneratorService {
Map<String, Integer> consumedHoursByBucket = new HashMap<>(); Map<String, Integer> consumedHoursByBucket = new HashMap<>();
for (ScheduleRuleSlot slot : slots) { 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()); ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
if (weekNumber < rule.startWeekFor(category)) { if (weekNumber < rule.startWeekFor(category)) {
continue; continue;
@@ -254,10 +263,13 @@ public class ScheduleGeneratorService {
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) { private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
if (targetGroup != null) { 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() return rule.getGroups().stream()
.filter(group -> group.isActiveOn(date))
.filter(group -> academicDateService.isTheoryDay(group, semester, date)) .filter(group -> academicDateService.isTheoryDay(group, semester, date))
.sorted(Comparator.comparing(StudentGroup::getName)) .sorted(Comparator.comparing(StudentGroup::getName))
.toList(); .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 ( CREATE TABLE IF NOT EXISTS departments (
id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL, id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
name VARCHAR(255) 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 INSERT INTO departments (name, code) VALUES
@@ -20,7 +26,13 @@ INSERT INTO departments (name, code) VALUES
CREATE TABLE IF NOT EXISTS specialties ( CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
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 INSERT INTO specialties (name, specialty_code) VALUES
@@ -35,6 +47,12 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
name VARCHAR(500) NOT NULL, name VARCHAR(500) NOT NULL,
description TEXT, 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,
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name) CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
); );
@@ -50,20 +68,62 @@ CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL, username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) 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, full_name VARCHAR(255) NOT NULL,
job_title VARCHAR(255) NOT NULL, job_title VARCHAR(255) NOT NULL,
department_id BIGINT NOT NULL REFERENCES departments(id), department_id BIGINT NOT NULL REFERENCES departments(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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) -- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role, full_name, job_title, department_id) INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1), 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; 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, id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL, name VARCHAR(100) UNIQUE NOT NULL,
description TEXT, 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 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_id BIGINT NOT NULL REFERENCES specialties(id),
specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
year_start_study BIGINT NOT NULL, 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, group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
student_capacity INT, 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) UNIQUE(group_id, name)
); );
@@ -146,7 +224,13 @@ CREATE TABLE IF NOT EXISTS subjects (
code VARCHAR(20), code VARCHAR(20),
department_id BIGINT NOT NULL REFERENCES departments(id), department_id BIGINT NOT NULL REFERENCES departments(id),
description TEXT, 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 INSERT INTO subjects (name, department_id) VALUES
@@ -157,6 +241,17 @@ INSERT INTO subjects (name, department_id) VALUES
('Английский язык', 1) ('Английский язык', 1)
ON CONFLICT (name) DO NOTHING; 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 ( CREATE TABLE IF NOT EXISTS lesson_types (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -176,7 +271,13 @@ CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(50) UNIQUE NOT NULL,
description TEXT, 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 INSERT INTO equipments (name) VALUES
@@ -197,7 +298,13 @@ CREATE TABLE IF NOT EXISTS classrooms (
floor INT, floor INT,
is_available BOOLEAN DEFAULT TRUE, is_available BOOLEAN DEFAULT TRUE,
description TEXT, 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 INSERT INTO classrooms (name, capacity, building, floor) VALUES
@@ -229,6 +336,17 @@ WHERE
OR (c.name = '303 Обычная' AND e.name IN ('Проектор')) OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) DO NOTHING; 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, lecture_start_week INT NOT NULL,
laboratory_start_week INT NOT NULL, laboratory_start_week INT NOT NULL,
practice_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 ( CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK (
lecture_academic_hours >= 0 lecture_academic_hours >= 0
AND laboratory_academic_hours >= 0 AND laboratory_academic_hours >= 0
@@ -495,11 +620,14 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
lecture_start_week > 0 lecture_start_week > 0
AND laboratory_start_week > 0 AND laboratory_start_week > 0
AND practice_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_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_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 ( CREATE TABLE IF NOT EXISTS schedule_rule_groups (
schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE, 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), classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
lesson_format VARCHAR(30) NOT NULL, lesson_format VARCHAR(30) NOT NULL,
time_locked BOOLEAN NOT NULL DEFAULT FALSE,
classroom_locked BOOLEAN NOT NULL DEFAULT FALSE,
teacher_locked BOOLEAN NOT NULL DEFAULT FALSE,
locked_by BIGINT REFERENCES users(id),
locked_at TIMESTAMP,
lock_comment TEXT,
CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7), CONSTRAINT chk_schedule_rule_slots_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_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')),
CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн')) 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 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() CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
DECLARE DECLARE
@@ -762,12 +918,17 @@ COMMENT ON TABLE schedule_rules IS 'Правила динамической ге
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups 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.lecture_academic_hours IS 'Лимит академических часов лекций';
COMMENT ON COLUMN schedule_rules.laboratory_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.practice_academic_hours IS 'Лимит академических часов практик';
COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре'; COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре';
COMMENT ON COLUMN schedule_rules.laboratory_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.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 education_forms IS 'Формы обучения';
COMMENT ON TABLE subgroups 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.username IS 'Логин пользователя';
COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя'; COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя';
COMMENT ON COLUMN users.role 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.created_at IS 'Дата и время создания';
COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления'; COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления';
COMMENT ON COLUMN users.full_name 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.is_available IS 'Доступность аудитории';
COMMENT ON COLUMN classrooms.description IS 'Описание аудитории'; COMMENT ON COLUMN classrooms.description IS 'Описание аудитории';
COMMENT ON COLUMN classrooms.created_at 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.classroom_id IS 'ID аудитории';
COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования'; COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования';

View File

@@ -46,6 +46,32 @@
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>` > После получения токена клиент должен передавать его в заголовке: `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` — обязателен и уникален - `username` — обязателен и уникален
- `password` — минимум 4 символа - `password` — минимум 4 символа
- `role``ADMIN`, `TEACHER` или `STUDENT` - `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
- `fullName` — обязателен - `fullName` — обязателен
- `departmentId` — обязателен - `departmentId` — обязателен
### `DELETE /api/users/{id}` ### `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}` ### `DELETE /api/classrooms/{id}`
Удаление аудитории. Архивирование аудитории. Архивная аудитория остаётся в историческом расписании, но не выбирается в новых назначениях.
### `POST /api/classrooms/{id}/restore`
Восстановление архивной аудитории.
--- ---

View File

@@ -32,7 +32,7 @@ graph TD
- **Порт:** 8080 (внутренний) - **Порт:** 8080 (внутренний)
- **ORM:** Hibernate (JPA), `ddl-auto=none` - **ORM:** Hibernate (JPA), `ddl-auto=none`
- **Миграции:** Flyway (программный запуск при подключении тенанта) - **Миграции:** Flyway (программный запуск при подключении тенанта)
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены - **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей
### PostgreSQL ### PostgreSQL
- **Версия:** `postgres:alpine3.23` - **Версия:** `postgres:alpine3.23`
@@ -131,12 +131,16 @@ sequenceDiagram
## Аутентификация ## Аутентификация
Система использует **простую модель аутентификации** без JWT или Spring Security фильтров: Система использует простую модель аутентификации без JWT и без полноценного Spring Security:
1. Клиент отправляет `POST /api/auth/login` с `username` и `password` 1. Клиент отправляет `POST /api/auth/login` с `username` и `password`
2. Backend проверяет пароль через `BCryptPasswordEncoder` 2. Backend проверяет пароль через `BCryptPasswordEncoder`
3. При успехе возвращается: 3. При успехе возвращается:
- UUID-токен (для заголовка `Authorization: Bearer`) - UUID-токен (для заголовка `Authorization: Bearer`)
- Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`) - Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`)
- Redirect URL (`/admin/`, `/teacher/`, `/student/`) - Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`)
4. Токен хранится в `localStorage` на клиенте 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 | Возможности | | Роль | Enum | Возможности |
|------|------|------------| |------|------|------------|
| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). | | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в недельных матричных таблицах. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
После авторизации пользователь перенаправляется на свой интерфейс: После авторизации пользователь перенаправляется на свой интерфейс:
- `ADMIN``/admin/` - `ADMIN``/admin/`
- `EDUCATION_OFFICE``/admin/#schedule-view`
- `DEPARTMENT``/admin/#department-workspace`
- `SCHEDULE_VIEWER``/admin/#schedule-view`
- `TEACHER``/teacher/` - `TEACHER``/teacher/`
- `STUDENT``/student/` - `STUDENT``/student/`
Bearer-токен проверяется на backend. Frontend-скрытие пунктов меню является только удобством, а не источником прав.
Для роли `DEPARTMENT` backend дополнительно ограничивает изменения рамками кафедры текущего пользователя:
- загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение;
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя.
--- ---
## Управление ресурсами ## Управление ресурсами
@@ -52,6 +66,7 @@
- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность - **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность
- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества - **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества
- **Статус:** Флаг `is_available` для блокирования назначения пар - **Статус:** Флаг `is_available` для блокирования назначения пар
- **Жизненный цикл:** `status=ARCHIVED` означает вывод из эксплуатации. Такая аудитория отображается в прошлом расписании, но запрещена для новых назначений.
### Оборудование (Equipments) ### Оборудование (Equipments)
@@ -64,6 +79,26 @@
- **Поля:** Название (уникальное), код, кафедра, описание - **Поля:** Название (уникальное), код, кафедра, описание
- Привязка преподавателей через `teacher_subjects` (Many-to-Many) - Привязка преподавателей через `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_groups` | Группы правила, включая потоковые лекции |
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | | `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | | `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Генератор `ScheduleGeneratorService` рендерит расписание по запросу: Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона. 1. Определяет семестр для каждой даты диапазона.
@@ -98,6 +134,7 @@
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую. 8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы. 9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
@@ -129,6 +166,18 @@
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы. - **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
### Точечные изменения расписания
Учебный отдел может создать изменение конкретной пары:
- `CANCEL` — отменить пару;
- `MOVE` — перенести пару на другой временной слот или в другую аудиторию;
- `REPLACE` — заменить преподавателя, аудиторию или формат.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного расписания на конкретную дату.
--- ---

View File

@@ -41,6 +41,10 @@ erDiagram
VARCHAR full_name VARCHAR full_name
VARCHAR job_title VARCHAR job_title
BIGINT department_id FK BIGINT department_id FK
VARCHAR status
DATE active_from
DATE active_to
TIMESTAMP archived_at
TIMESTAMP created_at TIMESTAMP created_at
TIMESTAMP updated_at TIMESTAMP updated_at
} }
@@ -101,6 +105,10 @@ erDiagram
VARCHAR building VARCHAR building
INT floor INT floor
BOOLEAN is_available BOOLEAN is_available
VARCHAR status
DATE active_from
DATE active_to
TIMESTAMP archived_at
TEXT description TEXT description
TIMESTAMP created_at TIMESTAMP created_at
} }
@@ -118,6 +126,24 @@ erDiagram
VARCHAR qualification_level VARCHAR qualification_level
INT experience_years 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 { teacher_lesson_types {
BIGINT user_id FK,PK BIGINT user_id FK,PK
@@ -207,6 +233,9 @@ erDiagram
BIGSERIAL id PK BIGSERIAL id PK
BIGINT subject_id FK BIGINT subject_id FK
BIGINT semester_id FK BIGINT semester_id FK
VARCHAR status
DATE valid_from
DATE valid_to
INT lecture_academic_hours INT lecture_academic_hours
INT laboratory_academic_hours INT laboratory_academic_hours
INT practice_academic_hours INT practice_academic_hours
@@ -230,6 +259,21 @@ erDiagram
BIGINT classroom_id FK BIGINT classroom_id FK
BIGINT lesson_type_id FK BIGINT lesson_type_id FK
VARCHAR lesson_format 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 { schedule_rule_slot_subgroups {
@@ -248,8 +292,12 @@ erDiagram
student_groups ||--o{ schedule_rule_groups : "group_id" student_groups ||--o{ schedule_rule_groups : "group_id"
student_groups ||--o{ student_group_calendar_assignments : "group_id" student_groups ||--o{ student_group_calendar_assignments : "group_id"
users ||--o{ teacher_subjects : "user_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{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ subject_comments : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id"
subjects ||--o{ schedule_rules : "subject_id" subjects ||--o{ schedule_rules : "subject_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_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_groups : "schedule_rule_id"
schedule_rules ||--o{ schedule_rule_slots : "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_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_slots : "time_slot_scope_id"
time_slot_scopes ||--o{ time_slot_date_assignments : "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" time_slots ||--o{ schedule_rule_slots : "time_slot_id"
@@ -317,6 +369,11 @@ erDiagram
| `full_name` | VARCHAR(255) | ФИО | | `full_name` | VARCHAR(255) | ФИО |
| `job_title` | VARCHAR(255) | Должность | | `job_title` | VARCHAR(255) | Должность |
| `department_id` | BIGINT FK → departments | Кафедра | | `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 | Дата создания | | `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) | | `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) |
@@ -375,6 +432,11 @@ erDiagram
| `building` | VARCHAR(50) | Корпус | | `building` | VARCHAR(50) | Корпус |
| `floor` | INT | Этаж | | `floor` | INT | Этаж |
| `is_available` | BOOLEAN | Доступна для назначения пар | | `is_available` | BOOLEAN | Доступна для назначения пар |
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях |
| `active_from` | DATE | Дата начала действия записи |
| `active_to` | DATE | Дата окончания действия записи |
| `archived_at` | TIMESTAMP | Когда аудитория выведена из эксплуатации |
| `archive_reason` | TEXT | Причина архивирования |
| `description` | TEXT | Описание | | `description` | TEXT | Описание |
#### `equipments` — Оборудование #### `equipments` — Оборудование
@@ -420,6 +482,30 @@ erDiagram
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | | `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `lesson_type_id` | BIGINT PK, FK → lesson_types (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` — Сетки времени #### `time_slot_scopes` — Сетки времени
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
@@ -524,6 +610,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `lecture_start_week` | INT | Неделя семестра, с которой начинаются лекции | | `lecture_start_week` | INT | Неделя семестра, с которой начинаются лекции |
| `laboratory_start_week` | INT | Неделя семестра, с которой начинаются лабораторные | | `laboratory_start_week` | INT | Неделя семестра, с которой начинаются лабораторные |
| `practice_start_week` | INT | Неделя семестра, с которой начинаются практики | | `practice_start_week` | INT | Неделя семестра, с которой начинаются практики |
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED` |
| `valid_from` | DATE | Начало действия версии правила |
| `valid_to` | DATE | Окончание действия версии правила |
| `version_group_id` | BIGINT | Группа версий одного правила |
| `change_reason` | TEXT | Причина изменения |
#### `schedule_rule_groups` — Группы правила #### `schedule_rule_groups` — Группы правила
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -543,6 +634,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `classroom_id` | BIGINT FK → classrooms | Аудитория | | `classroom_id` | BIGINT FK → classrooms | Аудитория |
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | | `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | | `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
| `time_locked` | BOOLEAN | Время закреплено учебным отделом |
| `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом |
| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом |
| `locked_by` | BIGINT FK → users | Кто выполнил закрепление |
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
| `lock_comment` | TEXT | Комментарий к закреплению |
#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота #### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -552,6 +649,23 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `validate_schedule_rule_slot_subgroups` проверяет тип занятия, принадлежность подгруппы к группам правила и запрет на две подгруппы одной группы в одном слоте; для быстрых выборок есть индекс `idx_schedule_rule_slot_subgroups_subgroup`. Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `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 миграции ## 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) │ │ ├── layout.css # Раскладка (sidebar, topbar, content)
│ │ ├── components.css # Кнопки, таблицы, карточки, формы │ │ ├── components.css # Кнопки, таблицы, карточки, формы
│ │ ├── modals.css # Модальные окна │ │ ├── modals.css # Модальные окна
│ │ ├── auditorium-workload.css # Таблицы загруженности аудиторий │ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
│ │ └── departments-data.css # Стили создания кафедры/специальности │ │ └── departments-data.css # Стили создания кафедры/специальности
│ ├── js/ │ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── main.js # Инициализация, маршрутизация, навигация
@@ -44,9 +44,11 @@ frontend/
│ │ ├── equipments.js # Управление оборудованием │ │ ├── equipments.js # Управление оборудованием
│ │ ├── edu-forms.js # Формы обучения │ │ ├── edu-forms.js # Формы обучения
│ │ ├── profiles.js # Профили обучения специальностей │ │ ├── profiles.js # Профили обучения специальностей
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
│ │ ├── schedule.js # Конструктор правил расписания │ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── academic-calendar.js # Календарные учебные графики
│ │ ├── auditorium-workload.js # Загруженность аудиторий │ │ ├── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ │ ├── database.js # Управление тенантами │ │ ├── database.js # Управление тенантами
│ │ └── departments-data.js # Создание кафедры/специальности │ │ └── departments-data.js # Создание кафедры/специальности
│ ├── views/ # HTML-шаблоны представлений │ ├── views/ # HTML-шаблоны представлений
@@ -57,6 +59,8 @@ frontend/
│ │ ├── equipments.html │ │ ├── equipments.html
│ │ ├── edu-forms.html │ │ ├── edu-forms.html
│ │ ├── profiles.html │ │ ├── profiles.html
│ │ ├── department-workspace.html
│ │ ├── schedule-view.html
│ │ ├── schedule.html │ │ ├── schedule.html
│ │ ├── academic-calendar.html │ │ ├── academic-calendar.html
│ │ ├── auditorium-workload.html │ │ ├── auditorium-workload.html
@@ -78,6 +82,10 @@ frontend/
├── teacher/ # 👩‍🏫 Интерфейс преподавателя ├── teacher/ # 👩‍🏫 Интерфейс преподавателя
│ └── index.html # Недельный просмотр динамического расписания преподавателя │ └── index.html # Недельный просмотр динамического расписания преподавателя
├── department/ # 🏛 Кабинет кафедры
│ └── index.html # Redirect в `/admin/#department-workspace`
├── edu-office/ # 🗓 Кабинет учебного отдела
│ └── index.html # Redirect в `/admin/#schedule-view`
└── student/ # 🎓 Интерфейс студента └── student/ # 🎓 Интерфейс студента
└── index.html # Недельный просмотр динамического расписания группы └── index.html # Недельный просмотр динамического расписания группы
@@ -104,6 +112,19 @@ frontend/
3. Подключает соответствующий JS-модуль из `js/views/{tab}.js` 3. Подключает соответствующий JS-модуль из `js/views/{tab}.js`
4. Обновляет заголовок страницы (`#page-title`) 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 | | Tab | Описание | API |
@@ -115,30 +136,35 @@ frontend/
| `equipments` | Оборудование | `/api/equipments` | | `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` | | `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` | | `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` | | `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` | | `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` | | `database` | Тенанты | `/api/database` |
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | | `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
### Особенности админских вкладок ### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `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` показывает найденные занятия только в табличной матрице: период разбивается по учебным неделям, строки — даты, столбцы — пары, а внутри ячеек используются те же карточки занятий, что и в таблицах загруженности.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
### Страница настроек (`/admin/settings/`) ### Страница настроек (`/admin/settings/`)
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью. Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
- Доступ: через dropdown «Настройки» в footer боковой панели админки - Доступ: через dropdown «Настройки» в footer боковой панели админки для `ADMIN` и `EDUCATION_OFFICE`
- Кнопка «Назад в панель» для возврата в `/admin/` - Кнопка «Назад в панель» для возврата в `/admin/`
- Текущие вкладки: - Текущие вкладки:
- **Общие настройки** — заглушка (в разработке) - **Общие настройки** — заглушка (только `ADMIN`)
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика. - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
--- ---
@@ -191,6 +217,9 @@ export const api = {
- `userId` — ID пользователя для личного расписания преподавателя - `userId` — ID пользователя для личного расписания преподавателя
4. Перенаправляет на соответствующий интерфейс: 4. Перенаправляет на соответствующий интерфейс:
- `ADMIN``/admin/` - `ADMIN``/admin/`
- `EDUCATION_OFFICE``/admin/#schedule-view`
- `DEPARTMENT``/admin/#department-workspace`
- `SCHEDULE_VIEWER``/admin/#schedule-view`
- `TEACHER``/teacher/` - `TEACHER``/teacher/`
- `STUDENT``/student/` - `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/`) ### Преподаватель (`/teacher/`)
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`. Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`.

View File

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

View File

@@ -18,6 +18,11 @@
min-width: 240px; min-width: 240px;
} }
.workload-target-control {
flex: 0 1 220px;
min-width: 200px;
}
.workload-display-select { .workload-display-select {
width: 100%; width: 100%;
min-height: 38px; min-height: 38px;
@@ -564,6 +569,47 @@
background: var(--bg-input); background: var(--bg-input);
} }
.schedule-view-table-stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.schedule-view-week-panel {
display: flex;
flex-direction: column;
gap: 0;
}
.schedule-view-grid-container {
max-height: none;
}
.schedule-view-matrix-table {
min-width: 980px;
}
.schedule-view-matrix-table th,
.schedule-view-matrix-table td {
min-width: 150px;
height: 118px;
}
.schedule-view-matrix-table .schedule-view-date-cell {
width: 150px;
min-width: 150px;
}
.schedule-view-lesson-card .lesson-meta + .lesson-meta {
margin-top: 0.1rem;
}
.schedule-view-state {
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
/* Настройка scrollbar для контейнера сетки */ /* Настройка scrollbar для контейнера сетки */
.workload-grid-container::-webkit-scrollbar { .workload-grid-container::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -581,6 +627,7 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.workload-target-control,
.workload-display-control { .workload-display-control {
min-width: 100%; min-width: 100%;
} }

View File

@@ -104,6 +104,111 @@
opacity: 0.5; 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 { .schedule-section-label {
display: block; display: block;
font-size: 0.78rem; font-size: 0.78rem;
@@ -493,6 +598,12 @@
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.schedule-view-filter-grid,
.schedule-view-metrics,
.department-workspace-grid {
grid-template-columns: 1fr;
}
.schedule-hours-grid { .schedule-hours-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -72,6 +72,11 @@
overflow: hidden; overflow: hidden;
} }
.nav-item[hidden],
.settings-menu-item[hidden] {
display: none !important;
}
.nav-item::before { .nav-item::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -411,4 +416,4 @@
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
} }
} }

View File

@@ -109,6 +109,25 @@
</svg> </svg>
<span>Дисциплины</span> <span>Дисциплины</span>
</a> </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"> <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"> <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> <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="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line> <line x1="9" y1="21" x2="9" y2="9"></line>
</svg> </svg>
<span>Загруженность аудиторий</span> <span>Загруженность</span>
</a> </a>
<a href="#" class="nav-item" data-tab="database"> <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"> <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'; 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') { function getHeaders(contentType = 'application/json') {
const headers = { const headers = {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
@@ -31,9 +41,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
const response = await fetch(endpoint, options); 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; let data;
try { try {
data = await response.json(); data = await response.json();
@@ -48,7 +55,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
return data; return data;
} }
// Shortcut methods
export const api = { export const api = {
get: (url) => apiFetch(url, 'GET'), get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body), 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('./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 { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
// Auth check const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER'];
if (!isAuthenticatedAsAdmin()) { const currentRole = localStorage.getItem('role');
if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) {
window.location.href = '/'; window.location.href = '/';
} }
@@ -30,21 +32,44 @@ import {initDepartmentsData} from "./views/departments-data.js";
import {initAuditoriumWorkload} from "./views/auditorium-workload.js"; import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
import {initProfiles} from "./views/profiles.js"; import {initProfiles} from "./views/profiles.js";
import {initAcademicCalendar} from "./views/academic-calendar.js"; import {initAcademicCalendar} from "./views/academic-calendar.js";
import {initScheduleView} from "./views/schedule-view.js";
import {initDepartmentWorkspace} from "./views/department-workspace.js";
// Configuration // Configuration
const ROUTES = { const ROUTES = {
users: { title: 'Управление пользователями', file: 'views/users.html', init: initUsers }, users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers },
groups: { title: 'Управление группами', file: 'views/groups.html', init: initGroups }, groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups },
'edu-forms': { title: 'Формы обучения', file: 'views/edu-forms.html', init: initEduForms }, 'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms },
equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments }, equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments },
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms }, classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms },
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects }, subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects },
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule }, 'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace },
'academic-calendar': { title: 'Календарный учебный график', file: 'views/academic-calendar.html', init: initAcademicCalendar }, 'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView },
'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload }, schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule },
database: { title: 'База данных', file: 'views/database.html', init: initDatabase }, 'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar },
'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData }, 'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload },
profiles: { title: 'Профили обучения', file: 'views/profiles.html', init: initProfiles }, 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; let currentTab = null;
@@ -59,15 +84,13 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout'); const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main'); const main = document.querySelector('.main');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
// Initial auth check const allowedTabs = new Set(roleNavigation.tabs);
if (!isAuthenticatedAsAdmin()) {
window.location.href = '/';
}
// Setup Global Effects // Setup Global Effects
applyRippleEffect(); applyRippleEffect();
closeAllDropdownsOnOutsideClick(); closeAllDropdownsOnOutsideClick();
configureRoleInterface();
// Init sidebar state from localStorage on load // Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -136,7 +159,7 @@ navItems.forEach(item => {
}); });
async function switchTab(tab) { async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab]) return; if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
// UI Update // UI Update
navItems.forEach(n => n.classList.remove('active')); navItems.forEach(n => n.classList.remove('active'));
@@ -147,7 +170,7 @@ async function switchTab(tab) {
try { try {
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>'; appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file); 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(); const html = await response.text();
appContent.innerHTML = html; appContent.innerHTML = html;
@@ -168,5 +191,24 @@ async function switchTab(tab) {
sidebarOverlay.classList.remove('open'); sidebarOverlay.classList.remove('open');
} }
// Load default tab function configureRoleInterface() {
switchTab('users'); 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 NO_BUILDING = '__no_building__';
const DISPLAY_ALL = 'all'; const DISPLAY_ALL = 'all';
const TARGET_CLASSROOMS = 'classrooms';
const TARGET_TEACHERS = 'teachers';
const TARGET_DEPARTMENTS = 'departments';
const ROOM_PREFIX = 'room:'; const ROOM_PREFIX = 'room:';
const TEACHER_PREFIX = 'teacher:';
const DEPARTMENT_PREFIX = 'department:';
const MS_PER_DAY = 24 * 60 * 60 * 1000; 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 = { const DAY_SHORT_LABELS = {
1: 'Пн', 1: 'Пн',
2: 'Вт', 2: 'Вт',
@@ -43,6 +78,7 @@ const CAPACITY_BUCKETS = [
export async function initAuditoriumWorkload() { export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date'); const dateInput = document.getElementById('workload-date');
const refreshButton = document.getElementById('workload-refresh'); const refreshButton = document.getElementById('workload-refresh');
const targetSelect = document.getElementById('workload-target-select');
const displaySelect = document.getElementById('workload-display-select'); const displaySelect = document.getElementById('workload-display-select');
const headerRow = document.getElementById('workload-header-row'); const headerRow = document.getElementById('workload-header-row');
const tbody = document.getElementById('workload-tbody'); const tbody = document.getElementById('workload-tbody');
@@ -55,14 +91,18 @@ export async function initAuditoriumWorkload() {
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label'); const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
let classrooms = []; let classrooms = [];
let teachers = [];
let departments = [];
let timeSlots = []; let timeSlots = [];
let equipments = []; let equipments = [];
let groups = []; let groups = [];
let semesters = []; let semesters = [];
let lessonsByRoomSlot = new Map(); let lessonsByEntitySlot = new Map();
let failedGroupLoads = 0; let failedGroupLoads = 0;
let currentTarget = TARGET_CLASSROOMS;
let currentView = DISPLAY_ALL; let currentView = DISPLAY_ALL;
let selectedRoomId = null; let selectedRoomId = null;
let selectedEntityId = null;
setDefaultDate(); setDefaultDate();
initFilters(); initFilters();
@@ -84,6 +124,16 @@ export async function initAuditoriumWorkload() {
function bindEvents() { function bindEvents() {
refreshButton?.addEventListener('click', loadCurrentView); refreshButton?.addEventListener('click', loadCurrentView);
dateInput?.addEventListener('change', 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', () => { displaySelect?.addEventListener('change', () => {
applyDisplaySelection(); applyDisplaySelection();
syncDisplayMode(); syncDisplayMode();
@@ -106,25 +156,30 @@ export async function initAuditoriumWorkload() {
} }
async function loadInitialData() { async function loadInitialData() {
setTableLoading('Загрузка аудиторий и расписания...'); setTableLoading('Загрузка справочников и расписания...');
try { 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/classrooms'),
api.get('/api/equipments'), api.get('/api/equipments'),
api.get('/api/groups'), 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)); 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; equipments = loadedEquipments;
groups = loadedGroups; groups = loadedGroups;
semesters = (academicYears || []).flatMap(year => year.semesters || []); semesters = (academicYears || []).flatMap(year => year.semesters || []);
currentTarget = targetSelect?.value || TARGET_CLASSROOMS;
populateFilters(); populateFilters();
populateDisplayOptions(); populateDisplayOptions();
applyDisplaySelection(); applyDisplaySelection();
syncDisplayMode(); syncDisplayMode();
await loadCurrentView(); await loadCurrentView();
} catch (error) { } catch (error) {
const message = error.message || 'Ошибка загрузки данных загруженности аудиторий'; const message = error.message || 'Ошибка загрузки данных загруженности';
setTableError(message); setTableError(message);
setRoomError(message); setRoomError(message);
} }
@@ -137,10 +192,10 @@ export async function initAuditoriumWorkload() {
if (currentView === DISPLAY_ALL) { if (currentView === DISPLAY_ALL) {
await loadScheduleForSelectedDate(); await loadScheduleForSelectedDate();
} else { } else {
await loadRoomWorkload(); await loadSelectedEntityWorkload();
} }
} catch (error) { } catch (error) {
const message = error.message || 'Ошибка загрузки загруженности аудиторий'; const message = error.message || 'Ошибка загрузки загруженности';
if (currentView === DISPLAY_ALL) { if (currentView === DISPLAY_ALL) {
setTableError(message); setTableError(message);
} else { } else {
@@ -162,9 +217,9 @@ export async function initAuditoriumWorkload() {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`); timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
if (!groups.length) { if (!groups.length) {
lessonsByRoomSlot = new Map(); lessonsByEntitySlot = new Map();
renderGrid(); renderGrid();
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error'); showAlert('workload-alert', 'Группы не созданы, загруженность пуста', 'error');
return; return;
} }
@@ -176,7 +231,7 @@ export async function initAuditoriumWorkload() {
.filter(response => response.status === 'fulfilled') .filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []); .flatMap(response => response.value || []);
lessonsByRoomSlot = groupLessonsByRoomSlot(lessons); lessonsByEntitySlot = groupLessonsByEntitySlot(lessons);
renderGrid(); renderGrid();
if (failedGroupLoads > 0) { if (failedGroupLoads > 0) {
@@ -184,30 +239,31 @@ export async function initAuditoriumWorkload() {
} }
} }
async function loadRoomWorkload() { async function loadSelectedEntityWorkload() {
const date = dateInput?.value; const date = dateInput?.value;
const room = selectedRoom(); const entity = selectedEntity();
const target = targetConfig();
if (!date) { if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error'); showAlert('workload-alert', 'Выберите дату', 'error');
setRoomError('Выберите дату для расчёта двухнедельного периода'); setRoomError('Выберите дату для расчёта двухнедельного периода');
return; return;
} }
if (!room) { if (!entity) {
showAlert('workload-alert', 'Выберите аудиторию', 'error'); showAlert('workload-alert', target.selectError, 'error');
setRoomError('Выберите аудиторию в поле «Отображение»'); setRoomError(`${target.selectError} в поле «Отображение»`);
return; return;
} }
hideAlert('workload-alert'); hideAlert('workload-alert');
setRoomLoading('Загрузка расписания аудитории на две недели...'); setRoomLoading('Загрузка расписания на две недели...');
const range = twoWeekRange(date); const range = twoWeekRange(date);
const dates = datesBetween(range.startDate, range.endDate); const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([ const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates), 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, failedSlotLoads: slotLoad.failedLoads,
failedGroupLoads: lessonLoad.failedLoads, failedGroupLoads: lessonLoad.failedLoads,
parityLessons: lessonLoad.parityLessons parityLessons: lessonLoad.parityLessons
@@ -236,7 +292,7 @@ export async function initAuditoriumWorkload() {
}; };
} }
async function loadRoomLessons(range, roomId) { async function loadEntityLessons(range, entityId) {
if (!groups.length) { if (!groups.length) {
return { return {
lessons: [], lessons: [],
@@ -252,9 +308,8 @@ export async function initAuditoriumWorkload() {
const parityLessons = responses const parityLessons = responses
.filter(response => response.status === 'fulfilled') .filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []); .flatMap(response => response.value || []);
const lessons = uniqueLessons( const lessons = uniqueLessons(parityLessons)
parityLessons.filter(lesson => sameId(lesson.classroomId, roomId)) .filter(lesson => entityIdsForLesson(lesson).some(id => sameId(id, entityId)));
);
const lessonsByDateSlot = new Map(); const lessonsByDateSlot = new Map();
lessons.forEach(lesson => { lessons.forEach(lesson => {
const slotKey = lessonSlotKey(lesson); const slotKey = lessonSlotKey(lesson);
@@ -311,12 +366,11 @@ export async function initAuditoriumWorkload() {
function populateDisplayOptions() { function populateDisplayOptions() {
if (!displaySelect) return; if (!displaySelect) return;
const selectedValue = displaySelect.value; const selectedValue = displaySelect.value;
const target = targetConfig();
displaySelect.innerHTML = ''; displaySelect.innerHTML = '';
displaySelect.append(new Option('Все аудитории', DISPLAY_ALL)); displaySelect.append(new Option(target.allLabel, DISPLAY_ALL));
classrooms.forEach(room => { allEntities().forEach(entity => {
const details = [room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(', '); displaySelect.append(new Option(entityOptionLabel(entity), `${target.prefix}${entity.id}`));
const suffix = details ? ` · ${details}` : '';
displaySelect.append(new Option(`${room.name}${suffix}`, `${ROOM_PREFIX}${room.id}`));
}); });
if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) { if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) {
displaySelect.value = selectedValue; displaySelect.value = selectedValue;
@@ -325,19 +379,24 @@ export async function initAuditoriumWorkload() {
function applyDisplaySelection() { function applyDisplaySelection() {
const value = displaySelect?.value || DISPLAY_ALL; const value = displaySelect?.value || DISPLAY_ALL;
if (value.startsWith(ROOM_PREFIX)) { selectedRoomId = null;
currentView = 'room'; selectedEntityId = null;
selectedRoomId = value.slice(ROOM_PREFIX.length); const target = targetConfig();
if (value.startsWith(target.prefix)) {
currentView = 'detail';
selectedEntityId = value.slice(target.prefix.length);
if (currentTarget === TARGET_CLASSROOMS) {
selectedRoomId = selectedEntityId;
}
return; return;
} }
currentView = DISPLAY_ALL; currentView = DISPLAY_ALL;
selectedRoomId = null;
} }
function syncDisplayMode() { function syncDisplayMode() {
const roomMode = currentView !== DISPLAY_ALL; const roomMode = currentView !== DISPLAY_ALL;
overviewFilters.forEach(filter => { overviewFilters.forEach(filter => {
filter.hidden = roomMode; filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS;
}); });
if (overviewBlock) overviewBlock.hidden = roomMode; if (overviewBlock) overviewBlock.hidden = roomMode;
if (roomBlock) roomBlock.hidden = !roomMode; if (roomBlock) roomBlock.hidden = !roomMode;
@@ -346,9 +405,39 @@ export async function initAuditoriumWorkload() {
} }
} }
function selectedRoom() { function selectedEntity() {
if (!selectedRoomId) return null; const id = selectedEntityId || selectedRoomId;
return classrooms.find(room => sameId(room.id, selectedRoomId)) || null; 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() { function buildingOptions() {
@@ -394,7 +483,7 @@ export async function initAuditoriumWorkload() {
function uniqueLessons(lessons) { function uniqueLessons(lessons) {
const unique = new Map(); const unique = new Map();
lessons.forEach(lesson => { lessons.forEach(lesson => {
if (!lesson.classroomId || !lesson.timeSlotId) return; if (!lesson.timeSlotId) return;
const key = [ const key = [
lesson.date, lesson.date,
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
@@ -422,12 +511,14 @@ export async function initAuditoriumWorkload() {
return Array.from(unique.values()); return Array.from(unique.values());
} }
function groupLessonsByRoomSlot(lessons) { function groupLessonsByEntitySlot(lessons) {
const grouped = new Map(); const grouped = new Map();
uniqueLessons(lessons).forEach(lesson => { uniqueLessons(lessons).forEach(lesson => {
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId); entityIdsForLesson(lesson).forEach(entityId => {
if (!grouped.has(key)) grouped.set(key, []); const key = entitySlotKey(entityId, lesson.timeSlotId);
grouped.get(key).push(lesson); if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
}); });
grouped.forEach(items => { grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
@@ -435,31 +526,43 @@ export async function initAuditoriumWorkload() {
return grouped; 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() { function renderGrid() {
const visibleClassrooms = filteredClassrooms(); const rows = visibleEntities();
const target = targetConfig();
renderHeader(); renderHeader();
tbody.innerHTML = ''; tbody.innerHTML = '';
if (!visibleClassrooms.length) { if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`; tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">${escapeHtml(target.emptyLabel)}</td></tr>`;
renderSummary(0, 0, 0); renderSummary(0, 0, 0);
return; return;
} }
visibleClassrooms.forEach(room => { rows.forEach(entity => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const roomCell = document.createElement('td'); const entityCell = document.createElement('td');
roomCell.className = `axis-cell${room.isAvailable === false ? ' room-unavailable' : ''}`; entityCell.className = `axis-cell${currentTarget === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`;
roomCell.innerHTML = ` entityCell.innerHTML = entityAxisHtml(entity);
<strong>${escapeHtml(room.name)}</strong> tr.appendChild(entityCell);
<span>${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'}</span>
${room.building ? `<small>${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}</small>` : ''}
`;
tr.appendChild(roomCell);
timeSlots.forEach(slot => { timeSlots.forEach(slot => {
const td = document.createElement('td'); 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.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell';
td.innerHTML = lessons.length td.innerHTML = lessons.length
? lessons.map(renderLessonCard).join('') ? lessons.map(renderLessonCard).join('')
@@ -470,15 +573,16 @@ export async function initAuditoriumWorkload() {
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
const occupiedCells = countOccupiedCells(visibleClassrooms); const occupiedCells = countOccupiedCells(rows);
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length); renderSummary(rows.length, occupiedCells, rows.length * timeSlots.length);
} }
function renderHeader() { function renderHeader() {
const target = targetConfig();
headerRow.innerHTML = ` headerRow.innerHTML = `
<th class="top-left-cell"> <th class="top-left-cell">
<span class="top-label">Время</span> <span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span> <span class="bottom-label">${escapeHtml(target.rowLabel)}</span>
</th> </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 slotRows = buildSlotRows(dates, slotsByDate, lessons);
const dateGrid = buildDateGridByDay(dates, failures.parityLessons || 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) { if (!slotRows.length) {
roomTables.innerHTML = '<div class="loading-row room-workload-state">За выбранный период нет временных слотов</div>'; 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); 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; if (!roomSummary) return;
const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot); const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot);
const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot); const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot);
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const roomMeta = [ const details = entitySummaryDetails(entity);
room.capacity ? `${room.capacity} мест` : null,
room.building || null,
room.floor ? `${room.floor} этаж` : null
].filter(Boolean).join(', ');
const warnings = []; const warnings = [];
if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`); if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`);
if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`); if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`);
@@ -523,8 +664,8 @@ export async function initAuditoriumWorkload() {
roomSummary.innerHTML = ` roomSummary.innerHTML = `
<div class="room-summary-main"> <div class="room-summary-main">
<strong>${escapeHtml(room.name)}</strong> <strong>${escapeHtml(entityName(entity))}</strong>
${roomMeta ? `<span>${escapeHtml(roomMeta)}</span>` : ''} ${details ? `<span>${escapeHtml(details)}</span>` : ''}
</div> </div>
<span class="room-summary-chip">${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))}</span> <span class="room-summary-chip">${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))}</span>
<span class="room-summary-chip">Занято: ${occupiedCells} из ${totalCells} (${percent}%)</span> <span class="room-summary-chip">Занято: ${occupiedCells} из ${totalCells} (${percent}%)</span>
@@ -555,7 +696,7 @@ export async function initAuditoriumWorkload() {
return ` return `
<section class="room-week-panel"> <section class="room-week-panel">
<div class="room-week-heading"> <div class="room-week-heading">
<h3>Нагрузка аудитории по неделям</h3> <h3>${escapeHtml(targetConfig().periodTitle)}</h3>
<span>${escapeHtml(roomWeekCaption(datesByDay))}</span> <span>${escapeHtml(roomWeekCaption(datesByDay))}</span>
</div> </div>
<div class="workload-grid-container room-grid-container"> <div class="workload-grid-container room-grid-container">
@@ -852,18 +993,19 @@ export async function initAuditoriumWorkload() {
showAlert('workload-alert', message, 'error'); showAlert('workload-alert', message, 'error');
} }
function renderSummary(roomCount, occupiedCells, totalCells) { function renderSummary(entityCount, occupiedCells, totalCells) {
if (!summary) return; if (!summary) return;
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const date = dateInput?.value ? formatDate(dateInput.value) : '-'; const date = dateInput?.value ? formatDate(dateInput.value) : '-';
const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-'; const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-';
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : ''; 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) { function countOccupiedCells(entities) {
return rooms.reduce((count, room) => count + timeSlots.filter(slot => return entities.reduce((count, entity) => count + timeSlots.filter(slot =>
(lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0 (lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0
).length, 0); ).length, 0);
} }
@@ -883,8 +1025,8 @@ export async function initAuditoriumWorkload() {
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length; return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
} }
function roomSlotKey(roomId, timeSlotId) { function entitySlotKey(entityId, timeSlotId) {
return `${roomId}:${timeSlotId}`; return `${entityId}:${timeSlotId}`;
} }
function dateSlotKey(date, slotKey) { function dateSlotKey(date, slotKey) {
@@ -905,6 +1047,22 @@ export async function initAuditoriumWorkload() {
return room.building ? String(room.building) : NO_BUILDING; 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 = []) { function mergeNames(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b)); 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() { async function loadClassrooms() {
try { try {
const classrooms = await api.get('/api/classrooms'); const classrooms = await api.get('/api/classrooms?includeArchived=true');
renderClassrooms(classrooms); renderClassrooms(classrooms);
} catch (e) { } catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>'; classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
@@ -57,7 +57,7 @@ export async function initClassrooms() {
<td> <td>
<div class="status-cell"> <div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}"> <span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.isAvailable ? 'Доступна' : 'Не доступна'} ${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span> </span>
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус"> <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> <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>
<td style="text-align: right;"> <td style="text-align: right;">
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button> <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> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -100,11 +102,19 @@ export async function initClassrooms() {
const btnEdit = e.target.closest('.btn-edit-classroom'); const btnEdit = e.target.closest('.btn-edit-classroom');
if (btnDelete) { if (btnDelete) {
if (!confirm('Удалить аудиторию?')) return; if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
try { try {
await api.delete('/api/classrooms/' + btnDelete.dataset.id); await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms(); loadClassrooms();
} catch (err) { alert('Ошибка удаления'); } } catch (err) { 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) { if (btnToggleStatus) {
@@ -124,7 +134,7 @@ export async function initClassrooms() {
async function openEditClassroomModal(id) { async function openEditClassroomModal(id) {
try { try {
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data // 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); editingClassroomData = classrooms.find(c => c.id == id);
if (!editingClassroomData) return; 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} дисциплин`;
}

View File

@@ -0,0 +1,355 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
const PARITY_LABELS = {
BOTH: 'каждая',
ODD: 'нечётная',
EVEN: 'чётная'
};
export async function initScheduleView() {
const role = localStorage.getItem('role');
const userDepartmentId = localStorage.getItem('departmentId');
const startInput = document.getElementById('schedule-view-start');
const endInput = document.getElementById('schedule-view-end');
const loadButton = document.getElementById('schedule-view-load');
const departmentField = document.getElementById('schedule-view-department-field');
const departmentSelect = document.getElementById('schedule-view-department');
const tables = document.getElementById('schedule-view-tables');
const countLabel = document.getElementById('schedule-view-count');
setDefaultDates(startInput, endInput);
loadButton?.addEventListener('click', loadData);
try {
await loadDictionaries(role, userDepartmentId);
if (role === ROLE_DEPARTMENT) {
departmentField.hidden = true;
departmentSelect.value = userDepartmentId || '';
}
await loadData();
} catch (error) {
setTableState(error.message || 'Ошибка загрузки справочников');
}
async function loadData() {
hideAlert('schedule-view-alert');
setTableState('Загрузка...');
countLabel.textContent = 'Загрузка...';
const params = new URLSearchParams({
startDate: startInput.value,
endDate: endInput.value
});
addOptional(params, 'groupId', valueOf('schedule-view-group'));
addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
addOptional(params, 'classroomId', valueOf('schedule-view-classroom'));
addOptional(params, 'departmentId', role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value);
addOptional(params, 'subjectId', valueOf('schedule-view-subject'));
addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type'));
addOptional(params, 'parity', valueOf('schedule-view-parity'));
try {
const lessons = await api.get(`/api/schedule/search?${params}`);
renderLessons(lessons);
} catch (error) {
const message = error.message || 'Ошибка загрузки расписания';
showAlert('schedule-view-alert', message, 'error');
setTableState(message);
countLabel.textContent = '0 занятий';
}
}
function renderLessons(lessons) {
countLabel.textContent = lessonCountLabel(lessons.length);
if (!lessons.length) {
setTableState('Занятия не найдены');
return;
}
const slots = collectSlots(lessons);
const lessonsByDateSlot = groupLessonsByDateSlot(lessons);
const weeks = groupDatesByWeek(lessons.map(lesson => lesson.date));
tables.innerHTML = weeks
.map(week => renderWeekTable(week, slots, lessonsByDateSlot))
.join('');
}
function renderWeekTable(week, slots, lessonsByDateSlot) {
const rows = week.dates.map(date => `
<tr>
<td class="axis-cell schedule-view-date-cell">
<strong>${escapeHtml(dayTitle(date, lessonsByDateSlot))}</strong>
<span>${escapeHtml(formatDate(date))}</span>
</td>
${slots.map(slot => renderSlotCell(date, slot, lessonsByDateSlot)).join('')}
</tr>
`).join('');
return `
<section class="schedule-view-week-panel">
<div class="room-week-heading">
<h3>${escapeHtml(week.label)}</h3>
<span>${escapeHtml(weekCaption(week.dates))}</span>
</div>
<div class="workload-grid-container schedule-view-grid-container">
<table class="workload-table schedule-view-matrix-table">
<thead>
<tr>
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Дата</span>
</th>
${slots.map(renderSlotHeader).join('')}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</section>
`;
}
function renderSlotHeader(slot) {
return `
<th>
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
</th>
`;
}
function renderSlotCell(date, slot, lessonsByDateSlot) {
const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || [];
return `
<td class="${lessons.length ? 'workload-busy-cell' : 'workload-free-cell'}">
${lessons.length ? lessons.map(renderLessonCard).join('') : '<span class="free-slot">Свободно</span>'}
</td>
`;
}
function renderLessonCard(lesson) {
const subgroupText = (lesson.subgroupNames || []).join(', ');
const meta = [
lesson.lessonTypeName,
lesson.lessonFormat,
PARITY_LABELS[lesson.parity] || lesson.parity
].filter(Boolean).join(' · ');
return `
<div class="lesson-card schedule-view-lesson-card">
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</div>
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
<div class="lesson-meta">${escapeHtml(lesson.classroomName || 'Аудитория не указана')}</div>
${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''}
${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''}
</div>
`;
}
function setTableState(message) {
if (!tables) return;
tables.innerHTML = `<div class="loading-row schedule-view-state">${escapeHtml(message)}</div>`;
}
}
async function loadDictionaries(role, userDepartmentId) {
const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/departments'),
api.get('/api/subjects'),
api.get('/api/lesson-types')
]);
fillSelect('schedule-view-group', groups, item => item.id, item => item.name, 'Все группы');
fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели');
fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории');
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
if (role === ROLE_DEPARTMENT && userDepartmentId) {
document.getElementById('schedule-view-department').value = userDepartmentId;
}
}
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 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 addOptional(params, key, value) {
if (value) params.set(key, value);
}
function valueOf(id) {
return document.getElementById(id)?.value || '';
}
function roomLabel(room) {
return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · ');
}
function formatSlot(lesson) {
const order = lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара';
const time = lesson.startTime && lesson.endTime
? ` (${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)})`
: '';
return `${order}${time}`;
}
function collectSlots(lessons) {
const slots = new Map();
lessons.forEach(lesson => {
const key = lessonSlotKey(lesson);
const existing = slots.get(key);
const next = {
key,
id: lesson.timeSlotId,
orderNumber: lesson.timeSlotOrder,
startTime: lesson.startTime,
endTime: lesson.endTime,
orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара',
timeLabel: lesson.startTime || lesson.endTime
? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}`
: 'Время не указано'
};
if (!existing || existing.timeLabel === 'Время не указано') {
slots.set(key, next);
}
});
return Array.from(slots.values()).sort(compareSlots);
}
function groupLessonsByDateSlot(lessons) {
const grouped = new Map();
lessons.forEach(lesson => {
if (!lesson.date) return;
const key = dateSlotKey(lesson.date, lessonSlotKey(lesson));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)
|| naturalCompare(a.teacherName, b.teacherName)
|| naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(',')));
});
return grouped;
}
function groupDatesByWeek(dates) {
const groups = new Map();
Array.from(new Set(dates.filter(Boolean)))
.sort((a, b) => naturalCompare(a, b))
.forEach(date => {
const key = weekStart(date);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(date);
});
return Array.from(groups.entries()).map(([startDate, weekDates]) => ({
startDate,
dates: weekDates,
label: `Неделя ${formatDate(startDate)} - ${formatDate(addDaysToIso(startDate, 6))}`
}));
}
function dayTitle(date, lessonsByDateSlot) {
for (const [key, lessons] of lessonsByDateSlot.entries()) {
if (key.startsWith(`${date}:`) && lessons[0]?.dayName) {
return lessons[0].dayName;
}
}
return dayName(date);
}
function weekCaption(dates) {
const first = dates[0];
const last = dates[dates.length - 1];
return `${formatDate(first)} - ${formatDate(last)}`;
}
function lessonSlotKey(lesson) {
const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`;
return value ? String(value) : 'unknown';
}
function dateSlotKey(date, slotKey) {
return `${date}:${slotKey}`;
}
function weekStart(value) {
const date = parseIsoDate(value);
const day = date.getDay() || 7;
date.setDate(date.getDate() + 1 - day);
return toIsoDate(date);
}
function addDaysToIso(value, days) {
const date = parseIsoDate(value);
date.setDate(date.getDate() + days);
return toIsoDate(date);
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function dayName(value) {
return parseIsoDate(value).toLocaleDateString('ru-RU', { weekday: 'long' });
}
function formatDate(value) {
if (!value) return '-';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
function lessonCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} занятий`;
if (count % 10 === 1) return `${count} занятие`;
if ([2, 3, 4].includes(count % 10)) return `${count} занятия`;
return `${count} занятий`;
}
function compareSlots(a, b) {
const first = Number(a.orderNumber || a.key);
const second = Number(b.orderNumber || b.key);
if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
return first - second;
}
return naturalCompare(a.startTime, b.startTime)
|| naturalCompare(a.key, b.key);
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}

View File

@@ -1,8 +1,22 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' }; const ROLE_LABELS = {
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' }; 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() { export async function initUsers() {
const usersTbody = document.getElementById('users-tbody'); const usersTbody = document.getElementById('users-tbody');
@@ -10,7 +24,7 @@ export async function initUsers() {
async function loadUsers() { async function loadUsers() {
try { try {
const users = await api.get('/api/users'); const users = await api.get('/api/users?includeArchived=true');
renderUsers(users); renderUsers(users);
} catch (e) { } catch (e) {
usersTbody.innerHTML = usersTbody.innerHTML =
@@ -34,7 +48,9 @@ export async function initUsers() {
<td>${escapeHtml(u.departmentName || '-')}</td> <td>${escapeHtml(u.departmentName || '-')}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td> <td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<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> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -77,12 +93,24 @@ export async function initUsers() {
const deleteBtn = e.target.closest('.btn-delete'); const deleteBtn = e.target.closest('.btn-delete');
if (!deleteBtn) return; if (!deleteBtn) return;
if (!confirm('Удалить пользователя?')) return; if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
try { try {
await api.delete('/api/users/' + deleteBtn.dataset.id); await api.delete('/api/users/' + deleteBtn.dataset.id);
loadUsers(); loadUsers();
} catch (err) { } catch (err) {
alert(err.message || 'Ошибка удаления'); 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; overflow: hidden;
} }
.nav-item[hidden] {
display: none !important;
}
.nav-item::before { .nav-item::before {
content: ''; content: '';
position: absolute; position: absolute;

View File

@@ -4,7 +4,7 @@ import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/drop
// Проверка авторизации // Проверка авторизации
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
if (!token || role !== 'ADMIN') { if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
window.location.href = '/'; 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; let currentTab = null;
// DOM-элементы // DOM-элементы
@@ -76,6 +83,7 @@ sidebarOverlay.addEventListener('click', () => {
// Навигация // Навигация
navItems.forEach(item => { navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const tab = item.dataset.tab; const tab = item.dataset.tab;
@@ -84,7 +92,7 @@ navItems.forEach(item => {
}); });
async function switchTab(tab) { 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')); navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('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">
<div class="card-header-row" style="margin-bottom: 1.5rem;"> <div class="card-header-row" style="margin-bottom: 1.5rem;">
<h2>Загруженность аудиторий</h2> <h2>Загруженность</h2>
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button> <button type="button" class="btn-primary" id="workload-refresh">Обновить</button>
</div> </div>
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;"> <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"> <div class="form-group workload-display-control">
<label for="workload-display-select">Отображение</label> <label for="workload-display-select">Отображение</label>
<select id="workload-display-select" class="workload-display-select" data-native-select="true"> <select id="workload-display-select" class="workload-display-select" data-native-select="true">
@@ -74,7 +83,7 @@
<tr id="workload-header-row"> <tr id="workload-header-row">
<th class="top-left-cell"> <th class="top-left-cell">
<span class="top-label">Время</span> <span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span> <span class="bottom-label">Сущность</span>
</th> </th>
</tr> </tr>
</thead> </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,74 @@
<div class="card">
<div class="card-header-row">
<h2>Фильтры расписания</h2>
<button type="button" class="btn-primary" id="schedule-view-load">Показать</button>
</div>
<div class="schedule-view-filter-grid">
<div class="form-group">
<label for="schedule-view-start">Начало</label>
<input type="date" id="schedule-view-start">
</div>
<div class="form-group">
<label for="schedule-view-end">Окончание</label>
<input type="date" id="schedule-view-end">
</div>
<div class="form-group">
<label for="schedule-view-group">Группа</label>
<select id="schedule-view-group" data-native-select="true">
<option value="">Все группы</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-teacher">Преподаватель</label>
<select id="schedule-view-teacher" data-native-select="true">
<option value="">Все преподаватели</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-classroom">Аудитория</label>
<select id="schedule-view-classroom" data-native-select="true">
<option value="">Все аудитории</option>
</select>
</div>
<div class="form-group" id="schedule-view-department-field">
<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-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>
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Таблицы расписания</h2>
<span class="muted" id="schedule-view-count">0 занятий</span>
</div>
<div class="schedule-view-table-stack" id="schedule-view-tables">
<div class="loading-row schedule-view-state">Загрузка...</div>
</div>
</div>

View File

@@ -13,11 +13,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-role">Роль</label> <label for="new-role">Роль</label>
<select id="new-role"> <select id="new-role">
<option value="STUDENT">Студент</option> <option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option> <option value="TEACHER">Преподаватель</option>
<option value="ADMIN">Администратор</option> <option value="DEPARTMENT">Кафедра</option>
</select> <option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="ADMIN">Администратор</option>
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-fullname">ФИО пользователя</label> <label for="new-fullname">ФИО пользователя</label>

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>