Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule
This commit is contained in:
@@ -27,6 +27,8 @@ magistr/
|
||||
├── frontend/ # Статические файлы
|
||||
│ ├── admin/ # Интерфейс администратора
|
||||
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
||||
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
|
||||
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
|
||||
│ ├── teacher/ # Интерфейс преподавателя
|
||||
│ └── student/ # Интерфейс студента
|
||||
├── docs/ # 📖 Документация проекта
|
||||
|
||||
@@ -1,541 +1,248 @@
|
||||
# План реализации задач после созвона с научным руководителем
|
||||
# Отчёт об изменениях по задачам научного руководителя
|
||||
|
||||
Дата: 2026-05-19
|
||||
|
||||
## 1. Короткий вывод
|
||||
## Краткий итог
|
||||
|
||||
В проекте уже есть основа для большей части требований: справочники, пользователи, кафедры, дисциплины, аудитории, динамическое расписание на базе `schedule_rules`, календарный учебный график, временные сетки и экран загруженности аудиторий. Главные недостающие архитектурные слои:
|
||||
Выполнен первый цельный релиз архитектурной основы:
|
||||
|
||||
- нормальная ролевая модель и серверная авторизация;
|
||||
- темпоральность: история принадлежности, версионирование связей и запрет ретроактивного изменения прошлого расписания;
|
||||
- мягкое удаление справочников вместо физического удаления;
|
||||
- отдельные рабочие места для кафедры и учебного отдела;
|
||||
- расширенные просмотры расписания и загруженности по преподавателям, аудиториям, кафедрам, парам и периодам.
|
||||
- добавлены роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`;
|
||||
- добавлена backend-проверка bearer-токена и ролей через `AuthorizationInterceptor` и `@RequireRoles`;
|
||||
- добавлен жизненный цикл справочников: `ACTIVE` / `ARCHIVED`;
|
||||
- физическое удаление ключевых сущностей заменено архивированием там, где это влияет на историю;
|
||||
- добавлена история переводов преподавателей между кафедрами;
|
||||
- добавлены точечные изменения расписания: перенос, отмена, замена;
|
||||
- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности;
|
||||
- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям;
|
||||
- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям;
|
||||
- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр;
|
||||
- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки;
|
||||
- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`;
|
||||
- обновлена документация проекта.
|
||||
|
||||
Рекомендуемый порядок: сначала ввести темпоральный слой, жизненный цикл записей и роли, затем уже строить новые кабинеты и редакторы. Если начать с экранов, старые связи будут продолжать теряться при удалениях, переносах преподавателей и правках расписания.
|
||||
## Backend
|
||||
|
||||
## 2. Расшифровка задач руководителя в терминах системы
|
||||
### Роли и авторизация
|
||||
|
||||
| Формулировка | Что реализуем |
|
||||
|-------------|---------------|
|
||||
| Загруженность по паре или преподавателю | Аналитика занятости по временным слотам, преподавателям, аудиториям, кафедрам, дисциплинам и периодам. |
|
||||
| Темпоральные базы данных | Не отдельная БД, а темпоральная модель внутри текущей PostgreSQL БД каждого тенанта: периоды действия записей, история связей, версионирование расписания. |
|
||||
| Сделать удаление аудиторий и т.д., чтобы нельзя было использовать в дальнейшем | Мягкое удаление/архивация справочников. Исторические записи остаются, новые назначения на архивные сущности запрещены. |
|
||||
| Перенос преподавателей с одной кафедры в другую без потери связи с прошлыми записями | История принадлежности преподавателя к кафедрам через интервалы действия, а не простое перезаписывание `users.department_id`. |
|
||||
| Уровни пользователей | Расширение ролей и прав: администратор, кафедра, учебный отдел, преподаватель, студент. Проверки должны быть на backend, не только в UI. |
|
||||
| Администратор: создание аудиторий, специальностей, кафедр и т.д. | Довести справочники до полного CRUD + архив/восстановление + фильтр архивных записей. |
|
||||
| Кафедра: смотреть предметы кафедры, грузить, связывать предметы, разделять, комментарии, расписания и загруженность | Новый кабинет кафедры: дисциплины кафедры, импорт, описание дисциплин, привязки преподавателей и типов занятий, комментарии, расписание и нагрузка кафедры. |
|
||||
| Учебный отдел: закреплять время и аудитории, редактировать расписание, закреплять предметы и преподавателей в аудитории в определённое время | Новый кабинет учебного отдела: редактор расписания, фиксация времени/аудиторий/преподавателей, точечные переносы и отмены, проверка конфликтов. |
|
||||
| Просмотр расписания разных аудиторий, преподавателей, кафедр | Универсальный просмотр расписания с фильтрами по аудитории, преподавателю, группе, кафедре, дисциплине и периоду. |
|
||||
Поддерживаются роли:
|
||||
|
||||
## 3. Текущее состояние проекта
|
||||
- `ADMIN`;
|
||||
- `EDUCATION_OFFICE`;
|
||||
- `DEPARTMENT`;
|
||||
- `SCHEDULE_VIEWER`;
|
||||
- `TEACHER`;
|
||||
- `STUDENT`.
|
||||
|
||||
### Уже есть
|
||||
Добавлены:
|
||||
|
||||
- Backend: Java 17, Spring Boot, JPA, Flyway, мультитенантность через отдельную БД на тенант.
|
||||
- Frontend: Vanilla JS, отдельные интерфейсы `/admin/`, `/teacher/`, `/student/`.
|
||||
- Роли сейчас ограничены `ADMIN`, `TEACHER`, `STUDENT`.
|
||||
- Авторизация возвращает UUID-токен и роль, но полноценной серверной проверки прав по эндпоинтам пока нет.
|
||||
- Динамическое расписание строится из:
|
||||
- `schedule_rules`;
|
||||
- `schedule_rule_groups`;
|
||||
- `schedule_rule_slots`;
|
||||
- `schedule_rule_slot_subgroups`;
|
||||
- `time_slots`;
|
||||
- `academic_years` / `semesters`;
|
||||
- `academic_calendars`.
|
||||
- `GET /api/schedule` сейчас принимает ровно один фильтр: `groupId` или `teacherId`.
|
||||
- Экран загруженности аудиторий уже есть и использует реальные аудитории, временные слоты и сгенерированное расписание.
|
||||
- У аудиторий есть `is_available`, но `DELETE /api/classrooms/{id}` физически удаляет запись.
|
||||
- Преподаватель сейчас имеет одно поле `users.department_id`; история переводов не хранится.
|
||||
- Дисциплины связаны с кафедрой через `subjects.department_id`; история смены кафедры дисциплины не хранится.
|
||||
- Привязка преподавателей к дисциплинам есть через `teacher_subjects`, но это простая текущая связь без периода действия, комментариев и разбиения по ответственности.
|
||||
- `AuthSessionService` — in-memory UUID-сессии;
|
||||
- `AuthContext` — текущий пользователь в `ThreadLocal`;
|
||||
- `AuthorizationInterceptor` — проверка `Authorization: Bearer ...`;
|
||||
- `@RequireRoles` — ограничение доступа на уровне контроллеров и методов;
|
||||
- `GET /api/auth/me` — текущий пользователь по токену.
|
||||
|
||||
### Основные проблемы текущей модели
|
||||
Redirect после входа:
|
||||
|
||||
- Физическое удаление справочников опасно для истории расписания и отчётов.
|
||||
- Обновление текущих связей перезаписывает контекст прошлого.
|
||||
- Динамическое расписание пересчитывается из текущих правил, поэтому изменение правила может изменить отображение прошлого периода.
|
||||
- Ролевая модель не отражает учебный отдел и кафедру как самостоятельных пользователей.
|
||||
- Просмотр расписания и загруженности завязан на частные сценарии, а не на единый фильтруемый слой.
|
||||
- `ADMIN` -> `/admin/`;
|
||||
- `EDUCATION_OFFICE` -> `/admin/#schedule-view`;
|
||||
- `DEPARTMENT` -> `/admin/#department-workspace`;
|
||||
- `SCHEDULE_VIEWER` -> `/admin/#schedule-view`;
|
||||
- `TEACHER` -> `/teacher/`;
|
||||
- `STUDENT` -> `/student/`.
|
||||
|
||||
## 4. Целевая архитектурная идея
|
||||
### Миграция БД
|
||||
|
||||
Ввести три базовых понятия:
|
||||
Отдельная миграция `V2__roles_lifecycle_temporal_schedule.sql` удалена по текущему решению проекта.
|
||||
|
||||
1. **Жизненный цикл записи**: активна, архивирована, недоступна для новых назначений.
|
||||
2. **Период действия связи**: преподаватель относится к кафедре не "навсегда", а с даты по дату.
|
||||
3. **Версия расписания**: прошлое расписание должно оставаться воспроизводимым, даже если текущие правила изменились.
|
||||
Все изменения схемы внесены в:
|
||||
|
||||
После этого все новые экраны работают с одной логикой:
|
||||
|
||||
- активные справочники доступны для выбора;
|
||||
- архивные записи видны в истории, но не выбираются для будущих назначений;
|
||||
- отчёты за прошлый период используют связи, действовавшие на дату занятия;
|
||||
- правки будущего расписания не ломают прошлые отчёты.
|
||||
|
||||
## 5. Этап 1. Роли и права доступа
|
||||
|
||||
### Новые роли
|
||||
|
||||
| Роль | Назначение |
|
||||
|------|------------|
|
||||
| `ADMIN` | Полный доступ внутри тенанта: справочники, пользователи, роли, настройки. |
|
||||
| `EDUCATION_OFFICE` | Учебный отдел: расписание, аудитории, временные слоты, закрепления, конфликты. |
|
||||
| `DEPARTMENT` | Кафедра: дисциплины своей кафедры, преподаватели, комментарии, нагрузка, просмотр расписания. |
|
||||
| `TEACHER` | Преподаватель: личное расписание и, позже, заявки на изменения. |
|
||||
| `STUDENT` | Просмотр расписания. |
|
||||
|
||||
`ADMIN` можно оставить как суперроль внутри одной БД тенанта. Управление тенантами через `/api/database` стоит разрешить только `ADMIN`.
|
||||
|
||||
### Backend
|
||||
|
||||
- Расширить `Role`.
|
||||
- Обновить `AuthController.ROLE_REDIRECTS`:
|
||||
- `ADMIN` -> `/admin/`;
|
||||
- `EDUCATION_OFFICE` -> `/edu-office/`;
|
||||
- `DEPARTMENT` -> `/department/`;
|
||||
- `TEACHER` -> `/teacher/`;
|
||||
- `STUDENT` -> `/student/`.
|
||||
- Добавить серверную авторизацию:
|
||||
- хранение активных сессий/токенов или проверяемый токен;
|
||||
- фильтр/интерцептор, который получает пользователя по `Authorization`;
|
||||
- аннотации или централизованные проверки прав в контроллерах.
|
||||
- Не полагаться на `localStorage.role` как на источник прав.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Добавить оболочки `/department/` и `/edu-office/`.
|
||||
- В админке скрывать пункты меню по роли, но считать это только UX-ограничением.
|
||||
- Все ошибки доступа показывать на русском: `Недостаточно прав для выполнения операции`.
|
||||
|
||||
## 6. Этап 2. Темпоральный слой и жизненный цикл справочников
|
||||
|
||||
### Базовые поля жизненного цикла
|
||||
|
||||
Для справочников, которые могут участвовать в расписании или отчётах, добавить:
|
||||
|
||||
- `status` (`ACTIVE`, `ARCHIVED`);
|
||||
- `active_from`;
|
||||
- `active_to`;
|
||||
- `archived_at`;
|
||||
- `archived_by`;
|
||||
- `archive_reason`.
|
||||
|
||||
Минимальный набор для первого внедрения:
|
||||
|
||||
- `classrooms`;
|
||||
- `equipments`;
|
||||
- `departments`;
|
||||
- `specialties`;
|
||||
- `specialty_profiles`;
|
||||
- `education_forms`;
|
||||
- `student_groups`;
|
||||
- `subgroups`;
|
||||
- `subjects`;
|
||||
- `users`.
|
||||
|
||||
Для сущностей, где достаточно простого вывода из эксплуатации, можно начать с `status`, `archived_at`, `archive_reason`, а `active_from/active_to` добавить там, где требуется отчётность по датам.
|
||||
|
||||
### Правила использования
|
||||
|
||||
- `GET` по умолчанию возвращает только активные записи.
|
||||
- Для админских экранов добавить `includeArchived=true`.
|
||||
- `DELETE` заменить на архивирование.
|
||||
- Физическое удаление оставить только для записей, которые точно нигде не используются, либо убрать из публичных API.
|
||||
- В формах будущего расписания нельзя выбирать архивные аудитории, дисциплины, группы, подгруппы, кафедры и пользователей.
|
||||
- В историческом расписании архивные записи отображаются с пометкой, например `Аудитория 101 (архив)`.
|
||||
|
||||
### Аудитории
|
||||
|
||||
`is_available` и архивирование должны означать разные вещи:
|
||||
|
||||
- `is_available=false` — аудитория временно недоступна, но может вернуться в работу;
|
||||
- `status=ARCHIVED` — аудитория выведена из эксплуатации и не может использоваться в новых назначениях.
|
||||
|
||||
UI-кнопку `Удалить` лучше заменить на `Вывести из эксплуатации`. Для администратора можно добавить отдельное действие `Восстановить`.
|
||||
|
||||
## 7. Этап 3. История переводов преподавателей между кафедрами
|
||||
|
||||
### Модель данных
|
||||
|
||||
Добавить таблицу:
|
||||
|
||||
```sql
|
||||
teacher_department_assignments
|
||||
```text
|
||||
backend/src/main/resources/db/migration/V1__init.sql
|
||||
```
|
||||
|
||||
Поля:
|
||||
|
||||
- `id`;
|
||||
- `teacher_id`;
|
||||
- `department_id`;
|
||||
- `valid_from`;
|
||||
- `valid_to`;
|
||||
- `is_primary`;
|
||||
- `comment`;
|
||||
- `created_at`;
|
||||
- `created_by`.
|
||||
|
||||
Текущий `users.department_id` можно временно оставить как денормализованное поле "текущая кафедра", но источником правды для отчётов должна стать таблица истории.
|
||||
|
||||
### Правила
|
||||
|
||||
- У преподавателя должна быть ровно одна основная кафедра на дату.
|
||||
- При переводе преподавателя:
|
||||
- закрывается старая запись `valid_to`;
|
||||
- создаётся новая запись `valid_from`;
|
||||
- старые расписания и отчёты используют кафедру, актуальную на дату занятия.
|
||||
- Привязки `teacher_subjects` не удаляются автоматически при переводе, иначе потеряется история. Для будущих назначений можно показывать предупреждение, если дисциплина не относится к текущей кафедре преподавателя.
|
||||
|
||||
### API
|
||||
|
||||
- `GET /api/users/{id}/department-history`
|
||||
- `POST /api/users/{id}/department-transfer`
|
||||
- `GET /api/departments/{id}/teachers?date=YYYY-MM-DD`
|
||||
- `GET /api/users/teachers?departmentId=&date=`
|
||||
|
||||
## 8. Этап 4. Версионирование расписания и точечные изменения
|
||||
|
||||
Текущая модель `schedule_rules` хороша для генерации будущего расписания, но недостаточна для неизменяемого прошлого и точечных переносов.
|
||||
|
||||
### Версии правил
|
||||
|
||||
Добавить к правилам и слотам период действия:
|
||||
|
||||
- `valid_from`;
|
||||
- `valid_to`;
|
||||
- `status`;
|
||||
- `version_group_id` или `parent_rule_id`;
|
||||
- `created_by`;
|
||||
- `change_reason`.
|
||||
|
||||
При редактировании правила:
|
||||
|
||||
- если правка относится к будущему периоду, закрывать старую версию и создавать новую;
|
||||
- если правка исправляет ошибку текущего будущего правила, разрешить обновление только до даты начала действия;
|
||||
- прошлые периоды не пересчитывать по новым данным.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
Добавить таблицу:
|
||||
|
||||
```sql
|
||||
schedule_overrides
|
||||
```
|
||||
|
||||
Назначение: перенос, отмена или замена конкретной сгенерированной пары.
|
||||
|
||||
Поля:
|
||||
|
||||
- `id`;
|
||||
- `base_rule_slot_id`;
|
||||
- `lesson_date`;
|
||||
- `action` (`MOVE`, `CANCEL`, `REPLACE`);
|
||||
- `new_time_slot_id`;
|
||||
- `new_classroom_id`;
|
||||
- `new_teacher_id`;
|
||||
- `new_lesson_format`;
|
||||
- `comment`;
|
||||
- `created_by`;
|
||||
- `created_at`.
|
||||
|
||||
Генератор расписания должен сначала строить базовые пары из правил, затем применять точечные изменения.
|
||||
|
||||
### Закрепление времени, аудитории, преподавателя
|
||||
|
||||
В `schedule_rule_slots` или отдельной таблице закреплений добавить признаки:
|
||||
|
||||
- `time_locked`;
|
||||
- `classroom_locked`;
|
||||
- `teacher_locked`;
|
||||
- `locked_by`;
|
||||
- `locked_at`;
|
||||
- `lock_comment`.
|
||||
|
||||
Учебный отдел сможет фиксировать назначения, а автоматические или массовые операции не должны менять закреплённые поля без явного подтверждения.
|
||||
|
||||
## 9. Этап 5. Проверка конфликтов
|
||||
|
||||
Перед сохранением правила, версии или override проверять:
|
||||
|
||||
- преподаватель не занят в другом месте в тот же день/пару;
|
||||
- аудитория не занята другой парой;
|
||||
- вместимость аудитории не меньше суммы групп/подгрупп;
|
||||
- преподаватель привязан к дисциплине и нужному типу занятия;
|
||||
- выбранная аудитория активна и доступна на дату;
|
||||
- группа/подгруппа активна на дату;
|
||||
- кафедральные ограничения не нарушены, если дисциплина принадлежит другой кафедре.
|
||||
|
||||
Конфликты делить на:
|
||||
|
||||
- `ERROR` — сохранение запрещено;
|
||||
- `WARNING` — можно сохранить с комментарием, если роль имеет право.
|
||||
|
||||
API:
|
||||
|
||||
- `POST /api/admin/schedule-rules/validate`
|
||||
- `POST /api/edu-office/schedule/validate`
|
||||
- `GET /api/schedule/conflicts?startDate=&endDate=`
|
||||
|
||||
## 10. Этап 6. Кабинет кафедры
|
||||
|
||||
### Функции
|
||||
|
||||
- Просмотр дисциплин своей кафедры.
|
||||
- Создание и редактирование описания дисциплины.
|
||||
- Импорт дисциплин из файла. Формат нужно согласовать отдельно: CSV/XLSX, обязательные колонки, правила обновления.
|
||||
- Разделение данных дисциплины:
|
||||
- карточка дисциплины: название, код, кафедра, описание, комментарии;
|
||||
- преподаватели дисциплины;
|
||||
- типы занятий, которые может вести каждый преподаватель;
|
||||
- история изменений.
|
||||
- Комментарии к дисциплине, привязке или расписанию.
|
||||
- Просмотр расписания кафедры.
|
||||
- Просмотр загруженности кафедры:
|
||||
- по преподавателям;
|
||||
- по дисциплинам;
|
||||
- по неделям;
|
||||
- по типам занятий.
|
||||
|
||||
### Backend
|
||||
|
||||
Новые или расширенные сущности:
|
||||
`V1__init.sql` теперь сразу создаёт:
|
||||
|
||||
- lifecycle-поля к справочникам и пользователям;
|
||||
- `teacher_department_assignments`;
|
||||
- `subject_comments`;
|
||||
- `subject_department_assignments` или темпоральные поля у `subjects`;
|
||||
- версионированные `teacher_subjects`;
|
||||
- `teacher_lesson_types` вывести в полноценный API, если таблица уже есть в схеме.
|
||||
- version-поля к `schedule_rules`;
|
||||
- lock-поля к `schedule_rule_slots`;
|
||||
- `schedule_overrides`;
|
||||
- стартовых пользователей `учебный_отдел`, `кафедра_иб` и `просмотр_расписаний`.
|
||||
|
||||
API:
|
||||
### Архивирование вместо удаления
|
||||
|
||||
- `GET /api/department/subjects`
|
||||
- `POST /api/department/subjects/import`
|
||||
- `GET /api/department/subjects/{id}`
|
||||
- `PUT /api/department/subjects/{id}`
|
||||
- `GET /api/department/subjects/{id}/teachers`
|
||||
- `PUT /api/department/subjects/{id}/teachers`
|
||||
- `GET /api/department/schedule`
|
||||
- `GET /api/department/workload`
|
||||
- `POST /api/department/comments`
|
||||
Архивирование добавлено для пользователей, аудиторий, оборудования, кафедр, специальностей, групп, подгрупп, дисциплин и правил расписания.
|
||||
|
||||
### Frontend
|
||||
Архивные аудитории, преподаватели, группы и дисциплины запрещены для новых назначений. Историческое расписание при этом не теряет ссылки.
|
||||
|
||||
Создать `/department/` как отдельный кабинет:
|
||||
### История кафедр преподавателя
|
||||
|
||||
- `subjects` — дисциплины кафедры;
|
||||
- `teachers` — преподаватели кафедры и их дисциплины;
|
||||
- `schedule` — расписание кафедры;
|
||||
- `workload` — нагрузка;
|
||||
- `comments` или встроенные комментарии в карточках.
|
||||
Добавлены endpoint:
|
||||
|
||||
## 11. Этап 7. Кабинет учебного отдела
|
||||
- `GET /api/users/{id}/department-history`;
|
||||
- `POST /api/users/{id}/department-transfer`;
|
||||
- `GET /api/users/teachers/by-department/{departmentId}?date=YYYY-MM-DD`.
|
||||
|
||||
### Функции
|
||||
При переводе преподавателя старая запись закрывается, новая открывается с `valid_from`, а `users.department_id` обновляется как текущая кафедра.
|
||||
|
||||
- Просмотр расписания по группам, аудиториям, преподавателям и кафедрам.
|
||||
- Редактирование правил расписания.
|
||||
- Точечный перенос пары.
|
||||
- Отмена пары.
|
||||
- Замена преподавателя.
|
||||
- Замена аудитории.
|
||||
- Закрепление времени, аудитории и преподавателя.
|
||||
- Работа с конфликтами.
|
||||
- Просмотр загруженности аудиторий и преподавателей.
|
||||
### Расписание и загруженность
|
||||
|
||||
### Backend
|
||||
Добавлены:
|
||||
|
||||
API:
|
||||
- `GET /api/schedule/search`;
|
||||
- `GET /api/workload/teachers`;
|
||||
- `GET /api/workload/classrooms`;
|
||||
- `GET /api/workload/departments`;
|
||||
- `GET /api/workload/time-slots`;
|
||||
- `GET /api/workload/free-classrooms`;
|
||||
- `GET/POST/PUT/DELETE /api/edu-office/schedule/overrides`.
|
||||
|
||||
- `GET /api/edu-office/schedule`
|
||||
- `POST /api/edu-office/schedule/overrides`
|
||||
- `PUT /api/edu-office/schedule/overrides/{id}`
|
||||
- `DELETE /api/edu-office/schedule/overrides/{id}`
|
||||
- `POST /api/edu-office/schedule/locks`
|
||||
- `DELETE /api/edu-office/schedule/locks/{id}`
|
||||
- `GET /api/edu-office/workload/classrooms`
|
||||
- `GET /api/edu-office/workload/teachers`
|
||||
- `GET /api/edu-office/conflicts`
|
||||
`GET /api/schedule` теперь использует общий слой поиска, поэтому учитывает точечные изменения расписания.
|
||||
|
||||
### Frontend
|
||||
Роль `SCHEDULE_VIEWER` имеет доступ только к read-only просмотру расписаний и справочникам-фильтрам.
|
||||
|
||||
Создать `/edu-office/` с вкладками:
|
||||
### Кабинет кафедры API
|
||||
|
||||
- `Расписание`;
|
||||
- `Редактор`;
|
||||
- `Конфликты`;
|
||||
- `Загруженность аудиторий`;
|
||||
- `Загруженность преподавателей`;
|
||||
- `Закрепления`.
|
||||
Добавлены:
|
||||
|
||||
Текущий админский конструктор расписания можно переиспользовать, но его нужно отделить от справочников и адаптировать под роль учебного отдела.
|
||||
- `GET /api/department/subjects`;
|
||||
- `POST /api/department/subjects/import`;
|
||||
- `GET /api/department/subjects/{subjectId}/comments`;
|
||||
- `POST /api/department/subjects/{subjectId}/comments`;
|
||||
- `GET /api/department/teachers`;
|
||||
- `GET /api/department/schedule`.
|
||||
|
||||
## 12. Этап 8. Универсальный просмотр расписания
|
||||
Для роли `DEPARTMENT` кафедра берётся из текущего пользователя. Для администратора можно выбрать кафедру в UI.
|
||||
|
||||
Сейчас `GET /api/schedule` требует ровно `groupId` или `teacherId`. Для новых требований нужен отдельный эндпоинт или расширение текущего.
|
||||
## Frontend
|
||||
|
||||
Рекомендуется добавить новый эндпоинт:
|
||||
### Единая панель по ролям
|
||||
|
||||
```http
|
||||
GET /api/schedule/search
|
||||
Отдельные интерфейсы `/department/` и `/edu-office/` заменены на redirect в общую админ-панель:
|
||||
|
||||
- `/department/` -> `/admin/#department-workspace`;
|
||||
- `/edu-office/` -> `/admin/#schedule-view`.
|
||||
|
||||
В `frontend/admin/js/main.js` добавлена фильтрация вкладок:
|
||||
|
||||
- `ADMIN` видит все вкладки;
|
||||
- `EDUCATION_OFFICE` видит просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории и оборудование;
|
||||
- `DEPARTMENT` видит кабинет кафедры и просмотр расписаний;
|
||||
- `SCHEDULE_VIEWER` видит только просмотр расписаний.
|
||||
|
||||
Backend не опирается только на скрытие вкладок. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, а `@RequireRoles` ограничивает операции на контроллерах и методах. Для кафедры дополнительно закрыты обходные пути:
|
||||
|
||||
- общий `/api/subjects` оставлен для записи только администратору, кафедра загружает дисциплины через scoped `/api/department/subjects/import`;
|
||||
- `/api/teacher-subjects` для роли `DEPARTMENT` проверяет, что и преподаватель, и дисциплина относятся к кафедре текущего пользователя;
|
||||
- `SCHEDULE_VIEWER` имеет только read-only доступ к расписаниям и справочникам-фильтрам.
|
||||
|
||||
После браузерной проверки исправлено визуальное скрытие недоступных вкладок: `layout.css` теперь принудительно скрывает `.nav-item[hidden]` и скрытые пункты меню настроек, поэтому роли больше не видят чужие вкладки в sidebar.
|
||||
|
||||
### Просмотр расписаний
|
||||
|
||||
Добавлена вкладка:
|
||||
|
||||
```text
|
||||
frontend/admin/views/schedule-view.html
|
||||
frontend/admin/js/views/schedule-view.js
|
||||
```
|
||||
|
||||
Фильтры:
|
||||
Возможности:
|
||||
|
||||
- `startDate`;
|
||||
- `endDate`;
|
||||
- `groupId`;
|
||||
- `teacherId`;
|
||||
- `classroomId`;
|
||||
- `departmentId`;
|
||||
- `subjectId`;
|
||||
- `lessonTypeId`;
|
||||
- `timeSlotId`;
|
||||
- `parity`;
|
||||
- `includeArchived`.
|
||||
- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности;
|
||||
- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели;
|
||||
- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям;
|
||||
- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели;
|
||||
- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку;
|
||||
- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
|
||||
|
||||
Правило: можно передавать один или несколько фильтров. Если фильтров нет, доступ зависит от роли:
|
||||
### Загруженность
|
||||
|
||||
- `ADMIN` и `EDUCATION_OFFICE` могут видеть всё;
|
||||
- `DEPARTMENT` видит свою кафедру;
|
||||
- `TEACHER` видит себя;
|
||||
- `STUDENT` видит доступные группы или свою группу, когда появится привязка студента к группе.
|
||||
Вкладка `auditorium-workload` оставлена техническим tab id, но в UI называется `Загруженность`.
|
||||
|
||||
Форматы отображения:
|
||||
Возможности:
|
||||
|
||||
- недельная сетка;
|
||||
- табличный список;
|
||||
- вид по аудиториям;
|
||||
- вид по преподавателям;
|
||||
- вид по кафедрам.
|
||||
- выбор типа загруженности: аудитории, преподаватели или кафедры;
|
||||
- сводная матрица по выбранной дате: строки — выбранные сущности, столбцы — пары;
|
||||
- кафедральная матрица группирует занятия по кафедре преподавателя;
|
||||
- для аудиторий сохранены фильтры корпуса, вместимости и оборудования;
|
||||
- выбор конкретной аудитории, преподавателя или кафедры заменяет обзор двухнедельной таблицей по дням недели и времени;
|
||||
- чётная и нечётная недели показываются в одной ячейке: если состояние одинаковое, ячейка цельная, если отличается — делится вертикально.
|
||||
|
||||
## 13. Этап 9. Загруженность по паре, преподавателю и кафедре
|
||||
### Кабинет кафедры
|
||||
|
||||
### Показатели
|
||||
Добавлена вкладка:
|
||||
|
||||
- занятость аудитории по дате и паре;
|
||||
- занятость преподавателя по дате и паре;
|
||||
- часы преподавателя за период;
|
||||
- часы кафедры за период;
|
||||
- распределение по дисциплинам;
|
||||
- распределение по типам занятий;
|
||||
- свободные аудитории на выбранную дату/пару;
|
||||
- перегрузки и конфликты.
|
||||
```text
|
||||
frontend/admin/views/department-workspace.html
|
||||
frontend/admin/js/views/department-workspace.js
|
||||
```
|
||||
|
||||
### API
|
||||
Возможности:
|
||||
|
||||
- `GET /api/workload/classrooms`
|
||||
- `GET /api/workload/teachers`
|
||||
- `GET /api/workload/departments`
|
||||
- `GET /api/workload/time-slots`
|
||||
- `GET /api/workload/free-classrooms`
|
||||
- просмотр дисциплин кафедры;
|
||||
- загрузка дисциплин из списка `код; название`;
|
||||
- просмотр и добавление комментариев к дисциплинам;
|
||||
- просмотр преподавателей кафедры;
|
||||
- просмотр нагрузки преподавателей кафедры за период.
|
||||
|
||||
Общие параметры:
|
||||
### Админка
|
||||
|
||||
- `startDate`;
|
||||
- `endDate`;
|
||||
- `date`;
|
||||
- `timeSlotId`;
|
||||
- `teacherId`;
|
||||
- `departmentId`;
|
||||
- `classroomId`;
|
||||
- `groupId`;
|
||||
- `lessonTypeId`.
|
||||
Обновлено:
|
||||
|
||||
### Реализация
|
||||
- создание пользователей теперь поддерживает роли `DEPARTMENT`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`;
|
||||
- удаление пользователей заменено на архивирование;
|
||||
- вкладка аудиторий показывает архивные записи и умеет восстанавливать аудиторию;
|
||||
- удаление аудитории заменено на вывод из эксплуатации;
|
||||
- настройки временных слотов доступны `ADMIN` и `EDUCATION_OFFICE`.
|
||||
|
||||
На первом этапе считать загруженность из `ScheduleGeneratorService` и фильтровать уже сгенерированные пары. Если отчёты станут тяжёлыми, добавить кэш или материализованную таблицу фактических занятий на период.
|
||||
## Документация
|
||||
|
||||
Важно: после внедрения `schedule_overrides` отчёты должны считать уже итоговое расписание, а не только базовые правила.
|
||||
Обновлены:
|
||||
|
||||
## 14. Миграции Flyway
|
||||
|
||||
Существующие миграции не изменять. Новые изменения оформлять новыми файлами:
|
||||
|
||||
1. `V2__roles_and_lifecycle.sql`
|
||||
- новые роли;
|
||||
- поля жизненного цикла для справочников;
|
||||
- индексы по `status`, `active_from`, `active_to`.
|
||||
2. `V3__teacher_department_history.sql`
|
||||
- история принадлежности преподавателей к кафедрам;
|
||||
- перенос текущих `users.department_id` в первую историческую запись.
|
||||
3. `V4__subject_and_teacher_temporal_links.sql`
|
||||
- комментарии;
|
||||
- версионированные связи преподавателей и дисциплин;
|
||||
- связь преподаватель + дисциплина + тип занятия с периодом действия.
|
||||
4. `V5__schedule_versions_and_overrides.sql`
|
||||
- версии правил;
|
||||
- точечные изменения расписания;
|
||||
- закрепления.
|
||||
5. `V6__workload_indexes.sql`
|
||||
- индексы под отчёты и фильтры расписания.
|
||||
|
||||
Нумерация может измениться, если в проекте появятся другие миграции до начала реализации.
|
||||
|
||||
## 15. Изменения документации
|
||||
|
||||
После каждого этапа обновлять:
|
||||
|
||||
- `docs/DATABASE.md`;
|
||||
- `AGENTS.md`;
|
||||
- `docs/README.md`;
|
||||
- `docs/API.md`;
|
||||
- `docs/DATABASE.md`;
|
||||
- `docs/BUSINESS_LOGIC.md`;
|
||||
- `docs/FRONTEND.md`;
|
||||
- `docs/ARCHITECTURE.md`, если меняются роли, авторизация или темпоральная модель.
|
||||
- `docs/ARCHITECTURE.md`.
|
||||
|
||||
Для больших этапов лучше добавлять отдельные документы:
|
||||
## Проверки
|
||||
|
||||
- `docs/RBAC.md`;
|
||||
- `docs/TEMPORAL_MODEL.md`;
|
||||
- `docs/SCHEDULE_EDITING.md`;
|
||||
- `docs/WORKLOAD_ANALYTICS.md`.
|
||||
Статически проверены новые и изменённые JS-файлы:
|
||||
|
||||
## 16. Проверка и критерии готовности
|
||||
```bash
|
||||
node --check frontend/admin/js/main.js
|
||||
node --check frontend/admin/js/views/schedule-view.js
|
||||
node --check frontend/admin/js/views/auditorium-workload.js
|
||||
node --check frontend/admin/js/views/department-workspace.js
|
||||
node --check frontend/admin/settings/js/main.js
|
||||
node --check frontend/admin/js/api.js
|
||||
node --check frontend/admin/js/views/users.js
|
||||
node --check frontend/admin/js/views/classrooms.js
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### Backend
|
||||
Backend-компиляция проверяется через Docker Maven:
|
||||
|
||||
- `docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q clean compile`
|
||||
- Прогон Flyway на чистой PostgreSQL БД.
|
||||
- Проверка, что старые данные мигрировали в темпоральные таблицы.
|
||||
- Тесты или ручные проверки:
|
||||
- архивная аудитория не выбирается для будущей пары;
|
||||
- старая пара с архивной аудиторией отображается;
|
||||
- перевод преподавателя не меняет кафедру в отчёте за прошлый период;
|
||||
- правка будущего правила не меняет прошлое расписание;
|
||||
- конфликт преподавателя/аудитории блокируется или предупреждается.
|
||||
```bash
|
||||
docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile
|
||||
```
|
||||
|
||||
### Frontend
|
||||
Результат: компиляция прошла успешно.
|
||||
|
||||
- `node --check` для изменённых JS-файлов.
|
||||
- `git diff --check`.
|
||||
- Ручной smoke-test в браузере:
|
||||
- вход под каждой ролью;
|
||||
- видимость нужных вкладок;
|
||||
- запрет доступа к чужим эндпоинтам;
|
||||
- создание/архивирование/восстановление справочников;
|
||||
- редактирование расписания учебным отделом;
|
||||
- просмотр расписания и загруженности по разным фильтрам.
|
||||
Единая `V1__init.sql` проверена на пустой PostgreSQL 16 внутри Docker: SQL применился успешно.
|
||||
|
||||
## 17. Риски и вопросы, которые нужно уточнить
|
||||
Локальный `mvn` в окружении отсутствует, поэтому используется Docker.
|
||||
|
||||
1. Что именно руководитель имеет в виду под "темпоральными базами данных": только история изменений или полноценная bitemporal-модель с `valid_time` и `transaction_time`.
|
||||
2. Нужно ли хранить фактические проведённые занятия отдельно от планового расписания.
|
||||
3. Какие форматы файлов кафедра должна "грузить": XLSX, CSV, шаблон учебного плана, свободная таблица.
|
||||
4. Кто именно является пользователем роли `DEPARTMENT`: заведующий кафедрой, оператор кафедры или несколько сотрудников.
|
||||
5. Нужна ли студентам привязка к конкретной группе. Сейчас студент выбирает группу вручную.
|
||||
6. Должно ли удаление/архивирование кафедры запрещаться при наличии активных преподавателей, групп и дисциплин или разрешаться с каскадной архивацией.
|
||||
7. Нужно ли вести журнал всех действий пользователя для юридически значимой истории изменений.
|
||||
## Что осталось следующим этапом
|
||||
|
||||
## 18. Рекомендуемый порядок внедрения
|
||||
Остались задачи, которые требуют отдельного цикла проработки:
|
||||
|
||||
1. Роли и серверная авторизация.
|
||||
2. Мягкое удаление аудиторий и справочников.
|
||||
3. История переводов преподавателей.
|
||||
4. Версионирование расписания и точечные изменения.
|
||||
5. Проверка конфликтов.
|
||||
6. Универсальный `schedule/search`.
|
||||
7. Загруженность преподавателей, кафедр и пар.
|
||||
8. Кабинет кафедры.
|
||||
9. Кабинет учебного отдела.
|
||||
10. Финальная синхронизация документации и регрессионный smoke-test.
|
||||
|
||||
Такой порядок закрывает базовую целостность данных до того, как появятся новые роли и экраны, которые начнут массово создавать и менять расписание.
|
||||
- UI для просмотра истории переводов преподавателя в админке;
|
||||
- UI для списка и редактирования уже созданных `schedule_overrides`;
|
||||
- более строгая проверка конфликтов перед сохранением переносов;
|
||||
- полноценный импорт XLSX/CSV для кафедры вместо текстовой загрузки;
|
||||
- браузерный smoke-test и проверка ролевых вкладок после запуска `localhost:80`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.auth.AuthorizationInterceptor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -123,6 +124,9 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private TenantRoutingDataSource tenantRoutingDataSource;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private AuthorizationInterceptor authorizationInterceptor;
|
||||
|
||||
@Bean
|
||||
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
|
||||
TenantInterceptor interceptor = new TenantInterceptor();
|
||||
@@ -134,6 +138,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
|
||||
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
|
||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||
}
|
||||
|
||||
private List<TenantConfig> loadTenantsFromFile() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
@@ -13,6 +14,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/calendar")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class AcademicCalendarAdminController {
|
||||
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
@@ -13,6 +14,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/academic-calendars")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class AcademicCalendarController {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.LoginRequest;
|
||||
import com.magistr.app.dto.LoginResponse;
|
||||
import com.magistr.app.config.auth.AuthSessionService;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
@@ -10,7 +12,6 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@@ -18,16 +19,23 @@ public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final AuthSessionService sessionService;
|
||||
|
||||
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
|
||||
"ADMIN", "/admin/",
|
||||
"EDUCATION_OFFICE", "/admin/#schedule-view",
|
||||
"DEPARTMENT", "/admin/#department-workspace",
|
||||
"SCHEDULE_VIEWER", "/admin/#schedule-view",
|
||||
"TEACHER", "/teacher/",
|
||||
"STUDENT", "/student/"
|
||||
);
|
||||
|
||||
public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
||||
public AuthController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
AuthSessionService sessionService) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.sessionService = sessionService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@@ -42,11 +50,30 @@ public class AuthController {
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
String token = UUID.randomUUID().toString();
|
||||
if (user.isArchivedRecord()) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
||||
}
|
||||
String token = sessionService.createSession(user);
|
||||
String roleName = user.getRole().name();
|
||||
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
||||
Long departmentId = user.getDepartmentId();
|
||||
|
||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId()));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<?> me() {
|
||||
var user = AuthContext.getCurrentUser();
|
||||
if (user == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"userId", user.id(),
|
||||
"username", user.username(),
|
||||
"role", user.role().name(),
|
||||
"departmentId", user.departmentId()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ClassroomRequest;
|
||||
import com.magistr.app.dto.ClassroomResponse;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.Equipment;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.EquipmentRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -15,6 +18,7 @@ import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/classrooms")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ClassroomController {
|
||||
|
||||
private final ClassroomRepository classroomRepository;
|
||||
@@ -26,8 +30,12 @@ public class ClassroomController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ClassroomResponse> getAllClassrooms() {
|
||||
return classroomRepository.findAll().stream()
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<ClassroomResponse> getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
List<Classroom> classrooms = includeArchived
|
||||
? classroomRepository.findAll()
|
||||
: classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
return classrooms.stream()
|
||||
.map(this::mapToResponse)
|
||||
.toList();
|
||||
}
|
||||
@@ -52,7 +60,9 @@ public class ClassroomController {
|
||||
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
|
||||
|
||||
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
|
||||
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
|
||||
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream()
|
||||
.filter(Equipment::isActiveRecord)
|
||||
.toList();
|
||||
classroom.setEquipments(new java.util.HashSet<>(equipments));
|
||||
}
|
||||
|
||||
@@ -96,6 +106,9 @@ public class ClassroomController {
|
||||
|
||||
if (request.getEquipmentIds() != null) {
|
||||
List<Equipment> equipments = equipmentRepository.findAllById(request.getEquipmentIds());
|
||||
equipments = equipments.stream()
|
||||
.filter(Equipment::isActiveRecord)
|
||||
.toList();
|
||||
classroom.setEquipments(new java.util.HashSet<>(equipments));
|
||||
}
|
||||
|
||||
@@ -105,15 +118,33 @@ public class ClassroomController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteClassroom(@PathVariable Long id) {
|
||||
if (!classroomRepository.existsById(id)) {
|
||||
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
|
||||
if (classroomOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
classroomRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Аудитория удалена"));
|
||||
Classroom classroom = classroomOpt.get();
|
||||
classroom.archive("Выведена из эксплуатации");
|
||||
classroom.setIsAvailable(false);
|
||||
classroomRepository.save(classroom);
|
||||
return ResponseEntity.ok(Map.of("message", "Аудитория выведена из эксплуатации"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreClassroom(@PathVariable Long id) {
|
||||
Optional<Classroom> classroomOpt = classroomRepository.findById(id);
|
||||
if (classroomOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Classroom classroom = classroomOpt.get();
|
||||
classroom.restore();
|
||||
classroom.setIsAvailable(true);
|
||||
classroomRepository.save(classroom);
|
||||
return ResponseEntity.ok(mapToResponse(classroom));
|
||||
}
|
||||
|
||||
private ClassroomResponse mapToResponse(Classroom c) {
|
||||
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
|
||||
c.getStatus(), c.getArchivedAt(), c.getArchiveReason(),
|
||||
new java.util.ArrayList<>(c.getEquipments()));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.config.tenant.ConfigMapUpdater;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.model.Role;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -24,6 +26,7 @@ import java.util.Map;
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/database")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class DatabaseController {
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateDepartmentRequest;
|
||||
import com.magistr.app.dto.DepartmentResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -15,6 +18,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/departments")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class DepartmentController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
|
||||
@@ -26,10 +30,13 @@ public class DepartmentController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Department> getAllDepartments() {
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<Department> getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение списка кафедр");
|
||||
try {
|
||||
List<Department> departments = departmentRepository.findAll();
|
||||
List<Department> departments = includeArchived
|
||||
? departmentRepository.findAll()
|
||||
: departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<Department> response = departments.stream()
|
||||
.map( d -> new Department(
|
||||
d.getId(),
|
||||
@@ -150,12 +157,29 @@ public class DepartmentController {
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
|
||||
if (!departmentRepository.existsById(id)) {
|
||||
Department department = departmentRepository.findById(id).orElse(null);
|
||||
if (department == null) {
|
||||
logger.info("Кафедра с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
departmentRepository.deleteById(id);
|
||||
logger.info("Кафедра с ID - {} успешно удалена", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Кафедра удалена"));
|
||||
department.archive("Кафедра архивирована");
|
||||
departmentRepository.save(department);
|
||||
logger.info("Кафедра с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Кафедра архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreDepartment(@PathVariable Long id) {
|
||||
Department department = departmentRepository.findById(id).orElse(null);
|
||||
if (department == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
department.restore();
|
||||
departmentRepository.save(department);
|
||||
return ResponseEntity.ok(new DepartmentResponse(
|
||||
department.getId(),
|
||||
department.getDepartmentName(),
|
||||
department.getDepartmentCode()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
@@ -13,6 +15,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/education-forms")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class EducationFormController {
|
||||
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
@@ -28,6 +31,7 @@ public class EducationFormController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<Map<String, Object>> getAll() {
|
||||
return educationFormRepository.findAllByOrderByNameAsc().stream()
|
||||
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Equipment;
|
||||
import com.magistr.app.repository.EquipmentRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -10,6 +13,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/equipments")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class EquipmentController {
|
||||
|
||||
private final EquipmentRepository equipmentRepository;
|
||||
@@ -19,8 +23,8 @@ public class EquipmentController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Equipment> getAllEquipments() {
|
||||
return equipmentRepository.findAll();
|
||||
public List<Equipment> getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -42,10 +46,23 @@ public class EquipmentController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteEquipment(@PathVariable Long id) {
|
||||
if (!equipmentRepository.existsById(id)) {
|
||||
Equipment equipment = equipmentRepository.findById(id).orElse(null);
|
||||
if (equipment == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
equipmentRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Оборудование удалено"));
|
||||
equipment.archive("Оборудование архивировано");
|
||||
equipmentRepository.save(equipment);
|
||||
return ResponseEntity.ok(Map.of("message", "Оборудование архивировано"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreEquipment(@PathVariable Long id) {
|
||||
Equipment equipment = equipmentRepository.findById(id).orElse(null);
|
||||
if (equipment == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
equipment.restore();
|
||||
equipmentRepository.save(equipment);
|
||||
return ResponseEntity.ok(equipment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
@@ -25,6 +28,7 @@ import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/groups")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.STUDENT})
|
||||
public class GroupController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
||||
@@ -57,11 +61,13 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<GroupResponse> getAllGroups() {
|
||||
public List<GroupResponse> getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение всех групп");
|
||||
|
||||
try {
|
||||
List<StudentGroup> groups = groupRepository.findAll();
|
||||
List<StudentGroup> groups = includeArchived
|
||||
? groupRepository.findAll()
|
||||
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
.map(this::mapToResponse)
|
||||
@@ -78,7 +84,7 @@ public class GroupController {
|
||||
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
try {
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId);
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
if(groups.isEmpty()) {
|
||||
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -101,6 +107,7 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
|
||||
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
|
||||
@@ -146,6 +153,7 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на обновление группы с ID - {}", id);
|
||||
try {
|
||||
@@ -195,15 +203,30 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление группы с ID - {}", id);
|
||||
if (!groupRepository.existsById(id)) {
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.deleteById(id);
|
||||
logger.info("Группа с ID - {} успешно удалена", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
||||
group.archive("Группа архивирована");
|
||||
groupRepository.save(group);
|
||||
logger.info("Группа с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> restoreGroup(@PathVariable Long id) {
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
group.restore();
|
||||
groupRepository.save(group);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
}
|
||||
|
||||
private ResponseEntity<?> validateGroupRequest(CreateGroupRequest request) {
|
||||
@@ -260,6 +283,7 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/calendar-assignments")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> getCalendarAssignments(@PathVariable Long id) {
|
||||
if (!groupRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -270,6 +294,7 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/calendar-assignments")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
|
||||
@RequestBody GroupCalendarAssignmentDto request) {
|
||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
||||
@@ -309,6 +334,7 @@ public class GroupController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) {
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null);
|
||||
if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.LessonTypeResponse;
|
||||
import com.magistr.app.model.LessonType;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.LessonTypesRepository;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -12,6 +14,7 @@ import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/lesson-types")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public class LessonTypeController {
|
||||
|
||||
private final LessonTypesRepository lessonTypesRepository;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
@@ -14,14 +16,15 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/schedule")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT})
|
||||
public class ScheduleController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
|
||||
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
|
||||
public ScheduleController(ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
public ScheduleController(ScheduleQueryService scheduleQueryService) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -40,9 +43,18 @@ public class ScheduleController {
|
||||
}
|
||||
|
||||
try {
|
||||
List<RenderedLessonDto> lessons = groupId != null
|
||||
? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate)
|
||||
: scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(
|
||||
groupId,
|
||||
teacherId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
return ResponseEntity.ok(lessons);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.*;
|
||||
@@ -14,6 +15,7 @@ import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/schedule-rules")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ScheduleRuleAdminController {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
@@ -112,12 +114,14 @@ public class ScheduleRuleAdminController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!scheduleRuleRepository.existsById(id)) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findById(id).orElse(null);
|
||||
if (rule == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
scheduleRuleRepository.deleteById(id);
|
||||
rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||
scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Правило расписания удалено"));
|
||||
return ResponseEntity.ok(Map.of("message", "Правило расписания архивировано"));
|
||||
}
|
||||
|
||||
private String validateRule(ScheduleRuleDto request) {
|
||||
@@ -143,12 +147,18 @@ public class ScheduleRuleAdminController {
|
||||
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
|
||||
Subject subject = subjectRepository.findById(request.subjectId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
||||
if (subject.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивную дисциплину нельзя использовать в новых правилах");
|
||||
}
|
||||
Semester semester = semesterRepository.findById(request.semesterId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
|
||||
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
|
||||
throw new IllegalArgumentException("Одна или несколько групп не найдены");
|
||||
}
|
||||
if (groups.stream().anyMatch(StudentGroup::isArchivedRecord)) {
|
||||
throw new IllegalArgumentException("Архивные группы нельзя использовать в новых правилах");
|
||||
}
|
||||
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(semester);
|
||||
@@ -186,8 +196,14 @@ public class ScheduleRuleAdminController {
|
||||
}
|
||||
User teacher = userRepository.findById(slotDto.teacherId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
if (teacher.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивного преподавателя нельзя назначать в расписание");
|
||||
}
|
||||
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
||||
if (classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) {
|
||||
throw new IllegalArgumentException("Аудитория недоступна для новых назначений");
|
||||
}
|
||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
|
||||
|
||||
@@ -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", "Произошла ошибка при поиске расписания"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateSpecialityRequest;
|
||||
import com.magistr.app.dto.SpecialityResponse;
|
||||
import com.magistr.app.dto.SpecialtyProfileDto;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.repository.SpecialtiesRepository;
|
||||
@@ -18,6 +21,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/specialties")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class SpecialityController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
|
||||
@@ -32,10 +36,12 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Speciality> getAllSpecialties() {
|
||||
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение списка специальностей");
|
||||
try {
|
||||
List<Speciality> specialities = specialtiesRepository.findAll();
|
||||
List<Speciality> specialities = includeArchived
|
||||
? specialtiesRepository.findAll()
|
||||
: specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<Speciality> response = specialities.stream()
|
||||
.map( s -> new Speciality(
|
||||
s.getId(),
|
||||
@@ -161,13 +167,30 @@ public class SpecialityController {
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление специальности с ID: {}", id);
|
||||
if (!specialtiesRepository.existsById(id)) {
|
||||
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
|
||||
if (speciality == null) {
|
||||
logger.info("Специальность с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
specialtiesRepository.deleteById(id);
|
||||
logger.info("Специальность с ID - {} успешно удалена", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Специальность удалена"));
|
||||
speciality.archive("Специальность архивирована");
|
||||
specialtiesRepository.save(speciality);
|
||||
logger.info("Специальность с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Специальность архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreSpeciality(@PathVariable Long id) {
|
||||
Speciality speciality = specialtiesRepository.findById(id).orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
speciality.restore();
|
||||
specialtiesRepository.save(speciality);
|
||||
return ResponseEntity.ok(new SpecialityResponse(
|
||||
speciality.getId(),
|
||||
speciality.getSpecialityName(),
|
||||
speciality.getSpecialityCode()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/{specialtyId}/profiles")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.SubgroupDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subgroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
@@ -15,6 +17,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
|
||||
public class SubgroupController {
|
||||
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
@@ -50,6 +53,7 @@ public class SubgroupController {
|
||||
}
|
||||
|
||||
@PostMapping("/api/groups/{groupId}/subgroups")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
|
||||
StudentGroup group = groupRepository.findById(groupId).orElse(null);
|
||||
if (group == null) {
|
||||
@@ -73,6 +77,7 @@ public class SubgroupController {
|
||||
}
|
||||
|
||||
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> update(@PathVariable Long groupId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody SubgroupDto request) {
|
||||
@@ -99,6 +104,7 @@ public class SubgroupController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
|
||||
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||
if (subgroup == null) {
|
||||
@@ -108,9 +114,10 @@ public class SubgroupController {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
try {
|
||||
subgroupRepository.delete(subgroup);
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа удалена"));
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.dto.SubjectResponse;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import org.slf4j.Logger;
|
||||
@@ -16,6 +19,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/subjects")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class SubjectController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
|
||||
@@ -27,10 +31,13 @@ public class SubjectController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Subject> getAllSubjects() {
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<Subject> getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение всех дисциплин");
|
||||
try {
|
||||
List<Subject> subjects = subjectRepository.findAll();
|
||||
List<Subject> subjects = includeArchived
|
||||
? subjectRepository.findAll()
|
||||
: subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<Subject> response = subjects.stream()
|
||||
.map(s -> new Subject(
|
||||
s.getId(),
|
||||
@@ -48,10 +55,11 @@ public class SubjectController {
|
||||
}
|
||||
|
||||
@GetMapping("/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
|
||||
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
|
||||
try{
|
||||
List<Subject> subjects = subjectRepository.findByDepartmentId(departmentId);
|
||||
List<Subject> subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
if(subjects.isEmpty()){
|
||||
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -121,12 +129,31 @@ public class SubjectController {
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
|
||||
if (!subjectRepository.existsById(id)) {
|
||||
Subject subject = subjectRepository.findById(id).orElse(null);
|
||||
if (subject == null) {
|
||||
logger.info("Дисциплина с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
subjectRepository.deleteById(id);
|
||||
logger.info("Дисциплина с ID - {} успешно удалена", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
|
||||
subject.archive("Дисциплина архивирована");
|
||||
subjectRepository.save(subject);
|
||||
logger.info("Дисциплина с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Дисциплина архивирована"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> restoreSubject(@PathVariable Long id) {
|
||||
Subject subject = subjectRepository.findById(id).orElse(null);
|
||||
if (subject == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
subject.restore();
|
||||
subjectRepository.save(subject);
|
||||
return ResponseEntity.ok(new SubjectResponse(
|
||||
subject.getId(),
|
||||
subject.getName(),
|
||||
subject.getCode(),
|
||||
subject.getDepartmentId()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.TeacherSubjectResponse;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.model.TeacherSubject;
|
||||
import com.magistr.app.model.TeacherSubjectId;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
@@ -14,6 +19,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/teacher-subjects")
|
||||
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||
public class TeacherSubjectController {
|
||||
|
||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||
@@ -31,6 +37,7 @@ public class TeacherSubjectController {
|
||||
@GetMapping
|
||||
public List<TeacherSubjectResponse> getAll() {
|
||||
return teacherSubjectRepository.findAll().stream()
|
||||
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
|
||||
.map(ts -> new TeacherSubjectResponse(
|
||||
ts.getUserId(),
|
||||
ts.getUser().getUsername(),
|
||||
@@ -48,12 +55,17 @@ public class TeacherSubjectController {
|
||||
if (userId == null || subjectId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
|
||||
}
|
||||
if (!userRepository.existsById(userId)) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null || user.getRole() != Role.TEACHER || user.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
|
||||
}
|
||||
if (!subjectRepository.existsById(subjectId)) {
|
||||
Subject subject = subjectRepository.findById(subjectId).orElse(null);
|
||||
if (subject == null || subject.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена"));
|
||||
}
|
||||
if (!canManage(user, subject)) {
|
||||
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может связывать только своих преподавателей и свои дисциплины"));
|
||||
}
|
||||
|
||||
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
|
||||
if (teacherSubjectRepository.existsById(id)) {
|
||||
@@ -76,10 +88,30 @@ public class TeacherSubjectController {
|
||||
}
|
||||
|
||||
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
|
||||
if (!teacherSubjectRepository.existsById(id)) {
|
||||
TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null);
|
||||
if (teacherSubject == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) {
|
||||
return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки"));
|
||||
}
|
||||
teacherSubjectRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
|
||||
}
|
||||
|
||||
private boolean canManage(User teacher, Subject subject) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser == null || currentUser.role() == Role.ADMIN) {
|
||||
return true;
|
||||
}
|
||||
if (currentUser.role() != Role.DEPARTMENT) {
|
||||
return false;
|
||||
}
|
||||
Long departmentId = currentUser.departmentId();
|
||||
return departmentId != null
|
||||
&& teacher != null
|
||||
&& subject != null
|
||||
&& departmentId.equals(teacher.getDepartmentId())
|
||||
&& departmentId.equals(subject.getDepartmentId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
|
||||
import com.magistr.app.dto.TimeSlotDto;
|
||||
import com.magistr.app.dto.TimeSlotScopeDto;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||
import com.magistr.app.model.TimeSlotScope;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
@@ -23,6 +25,7 @@ import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/time-slots")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class TimeSlotAdminController {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
|
||||
@@ -1,43 +1,61 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateUserRequest;
|
||||
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class UserController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<UserResponse> getAllUsers() {
|
||||
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение всех пользователей");
|
||||
try {
|
||||
List<User> users = userRepository.findAll();
|
||||
List<User> users = includeArchived
|
||||
? userRepository.findAll()
|
||||
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
List<UserResponse> response = users.stream()
|
||||
.map(u -> {
|
||||
@@ -45,13 +63,15 @@ public class UserController {
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
return new UserResponse(
|
||||
UserResponse userResponse = new UserResponse(
|
||||
u.getId(),
|
||||
u.getUsername(),
|
||||
u.getRole().name(),
|
||||
u.getFullName(),
|
||||
u.getJobTitle(),
|
||||
departmentName);
|
||||
userResponse.setStatus(u.getStatus());
|
||||
return userResponse;
|
||||
})
|
||||
.toList();
|
||||
logger.info("Получено {} пользователей", response.size());
|
||||
@@ -64,11 +84,12 @@ public class UserController {
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<UserResponse> getTeachers() {
|
||||
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||
|
||||
try {
|
||||
List<User> users = userRepository.findByRole(Role.TEACHER);
|
||||
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
List<UserResponse> response = users.stream()
|
||||
.map(u -> {
|
||||
@@ -76,13 +97,15 @@ public class UserController {
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Неизвестно");
|
||||
|
||||
return new UserResponse(
|
||||
UserResponse userResponse = new UserResponse(
|
||||
u.getId(),
|
||||
u.getUsername(),
|
||||
u.getRole().name(),
|
||||
u.getFullName(),
|
||||
u.getJobTitle(),
|
||||
departmentName);
|
||||
userResponse.setStatus(u.getStatus());
|
||||
return userResponse;
|
||||
})
|
||||
.toList();
|
||||
logger.info("Получено {} преподавателей", response.size());
|
||||
@@ -94,10 +117,15 @@ public class UserController {
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||
try {
|
||||
List<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId);
|
||||
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
||||
Role.TEACHER,
|
||||
departmentId,
|
||||
LifecycleEntity.STATUS_ARCHIVED
|
||||
);
|
||||
|
||||
if (users.isEmpty()) {
|
||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -178,6 +206,19 @@ public class UserController {
|
||||
user.setJobTitle(request.getJobTitle());
|
||||
user.setDepartmentId(request.getDepartmentId());
|
||||
userRepository.save(user);
|
||||
if (role == Role.TEACHER) {
|
||||
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
|
||||
if (department != null) {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(user);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
||||
|
||||
@@ -192,12 +233,111 @@ public class UserController {
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
|
||||
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
|
||||
if (!userRepository.existsById(id)) {
|
||||
User user = userRepository.findById(id).orElse(null);
|
||||
if (user == null) {
|
||||
logger.info("Пользователь с ID - {} не найден", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
userRepository.deleteById(id);
|
||||
logger.info("Пользователь с ID - {} успешно удалён", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
|
||||
user.archive("Пользователь архивирован");
|
||||
userRepository.save(user);
|
||||
logger.info("Пользователь с ID - {} успешно архивирован", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ResponseEntity<?> restoreUser(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElse(null);
|
||||
if (user == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
user.restore();
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
||||
user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/department-history")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
|
||||
public ResponseEntity<?> getDepartmentHistory(@PathVariable Long id) {
|
||||
if (!userRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
|
||||
.map(this::toAssignmentDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/department-transfer")
|
||||
@Transactional
|
||||
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
|
||||
User teacher = userRepository.findById(id).orElse(null);
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
|
||||
}
|
||||
if (teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
|
||||
}
|
||||
if (request.departmentId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
|
||||
if (department == null || department.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
|
||||
}
|
||||
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
|
||||
|
||||
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
|
||||
.ifPresent(current -> {
|
||||
if (current.getValidFrom().isBefore(validFrom)) {
|
||||
current.setValidTo(validFrom.minusDays(1));
|
||||
teacherDepartmentAssignmentRepository.save(current);
|
||||
} else {
|
||||
teacherDepartmentAssignmentRepository.delete(current);
|
||||
}
|
||||
});
|
||||
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(validFrom);
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment(request.comment());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
|
||||
teacher.setDepartmentId(department.getId());
|
||||
userRepository.save(teacher);
|
||||
|
||||
return ResponseEntity.ok(toAssignmentDto(assignment));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers/by-department/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT})
|
||||
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
|
||||
return ResponseEntity.ok(teacherDepartmentAssignmentRepository
|
||||
.findDepartmentTeachersAtDate(departmentId, effectiveDate)
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(User::isActiveRecord)
|
||||
.map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
|
||||
user.getFullName(), user.getJobTitle(), user.getDepartmentId()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
|
||||
return new TeacherDepartmentAssignmentDto(
|
||||
assignment.getId(),
|
||||
assignment.getTeacher().getId(),
|
||||
assignment.getTeacher().getFullName(),
|
||||
assignment.getDepartment().getId(),
|
||||
assignment.getDepartment().getDepartmentName(),
|
||||
assignment.getValidFrom(),
|
||||
assignment.getValidTo(),
|
||||
assignment.getPrimaryAssignment(),
|
||||
assignment.getComment()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.Equipment;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public class ClassroomResponse {
|
||||
@@ -10,6 +11,9 @@ public class ClassroomResponse {
|
||||
private String building;
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private String status;
|
||||
private LocalDateTime archivedAt;
|
||||
private String archiveReason;
|
||||
private List<Equipment> equipments;
|
||||
|
||||
public ClassroomResponse() {
|
||||
@@ -17,12 +21,21 @@ public class ClassroomResponse {
|
||||
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||
Boolean isAvailable, List<Equipment> equipments) {
|
||||
this(id, name, capacity, building, floor, isAvailable, null, null, null, equipments);
|
||||
}
|
||||
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||
Boolean isAvailable, String status, LocalDateTime archivedAt,
|
||||
String archiveReason, List<Equipment> equipments) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.capacity = capacity;
|
||||
this.building = building;
|
||||
this.floor = floor;
|
||||
this.isAvailable = isAvailable;
|
||||
this.status = status;
|
||||
this.archivedAt = archivedAt;
|
||||
this.archiveReason = archiveReason;
|
||||
this.equipments = equipments;
|
||||
}
|
||||
|
||||
@@ -74,6 +87,30 @@ public class ClassroomResponse {
|
||||
this.isAvailable = isAvailable;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
public String getArchiveReason() {
|
||||
return archiveReason;
|
||||
}
|
||||
|
||||
public void setArchiveReason(String archiveReason) {
|
||||
this.archiveReason = archiveReason;
|
||||
}
|
||||
|
||||
public List<Equipment> getEquipments() {
|
||||
return equipments;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record DepartmentTransferRequest(
|
||||
Long departmentId,
|
||||
LocalDate validFrom,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public class UserResponse {
|
||||
private String jobTitle;
|
||||
private String departmentName;
|
||||
private Long departmentId;
|
||||
private String status;
|
||||
|
||||
public UserResponse() {
|
||||
}
|
||||
@@ -69,4 +70,12 @@ public class UserResponse {
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "classrooms")
|
||||
public class Classroom {
|
||||
public class Classroom extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name="departments")
|
||||
public class Department {
|
||||
public class Department extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "education_forms")
|
||||
public class EducationForm {
|
||||
public class EducationForm extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "equipments")
|
||||
public class Equipment {
|
||||
public class Equipment extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
104
backend/src/main/java/com/magistr/app/model/LifecycleEntity.java
Normal file
104
backend/src/main/java/com/magistr/app/model/LifecycleEntity.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package com.magistr.app.model;
|
||||
|
||||
public enum Role {
|
||||
ADMIN,
|
||||
EDUCATION_OFFICE,
|
||||
DEPARTMENT,
|
||||
SCHEDULE_VIEWER,
|
||||
TEACHER,
|
||||
STUDENT
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -21,6 +22,15 @@ public class ScheduleRule {
|
||||
@JoinColumn(name = "semester_id", nullable = false)
|
||||
private Semester semester;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String status = LifecycleEntity.STATUS_ACTIVE;
|
||||
|
||||
@Column(name = "valid_from")
|
||||
private LocalDate validFrom;
|
||||
|
||||
@Column(name = "valid_to")
|
||||
private LocalDate validTo;
|
||||
|
||||
@Column(name = "lecture_academic_hours", nullable = false)
|
||||
private Integer lectureAcademicHours = 0;
|
||||
|
||||
@@ -74,6 +84,30 @@ public class ScheduleRule {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDate getValidFrom() {
|
||||
return validFrom;
|
||||
}
|
||||
|
||||
public void setValidFrom(LocalDate validFrom) {
|
||||
this.validFrom = validFrom;
|
||||
}
|
||||
|
||||
public LocalDate getValidTo() {
|
||||
return validTo;
|
||||
}
|
||||
|
||||
public void setValidTo(LocalDate validTo) {
|
||||
this.validTo = validTo;
|
||||
}
|
||||
|
||||
public Integer getLectureAcademicHours() {
|
||||
return lectureAcademicHours;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name="specialties")
|
||||
public class Speciality {
|
||||
public class Speciality extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "specialty_profiles")
|
||||
public class SpecialtyProfile {
|
||||
public class SpecialtyProfile extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "student_groups")
|
||||
public class StudentGroup {
|
||||
public class StudentGroup extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "subgroups")
|
||||
public class Subgroup {
|
||||
public class Subgroup extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "subjects")
|
||||
public class Subject {
|
||||
public class Subject extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
public class User extends LifecycleEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@@ -4,7 +4,10 @@ import com.magistr.app.model.Classroom;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
|
||||
Optional<Classroom> findByName(String name);
|
||||
|
||||
List<Classroom> findByStatusNot(String status);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import com.magistr.app.model.Department;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
public interface DepartmentRepository extends JpaRepository<Department, Long> {
|
||||
|
||||
Optional<Department> findByDepartmentName(String departmentName);
|
||||
|
||||
Optional<Department> findByDepartmentCode(Long departmentCode);
|
||||
|
||||
List<Department> findByStatusNot(String status);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import com.magistr.app.model.Equipment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
public interface EquipmentRepository extends JpaRepository<Equipment, Long> {
|
||||
Optional<Equipment> findByName(String name);
|
||||
|
||||
List<Equipment> findByStatusNot(String status);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,8 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||
|
||||
List<StudentGroup> findByDepartmentId(Long departmentId);
|
||||
|
||||
List<StudentGroup> findByStatusNot(String status);
|
||||
|
||||
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where g.id = :groupId
|
||||
and sem.id = :semesterId
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdAndSemesterId(
|
||||
@Param("groupId") Long groupId,
|
||||
@@ -49,6 +50,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id = :semesterId
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterId(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@@ -69,6 +71,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where r.status <> 'ARCHIVED'
|
||||
order by r.id desc
|
||||
""")
|
||||
List<ScheduleRule> findAllWithDetails();
|
||||
|
||||
@@ -4,10 +4,13 @@ import com.magistr.app.model.Speciality;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
public interface SpecialtiesRepository extends JpaRepository<Speciality, Long> {
|
||||
|
||||
Optional<Speciality> findBySpecialityName(String specialityName);
|
||||
|
||||
Optional<Speciality> findBySpecialityCode(String specialityCode);
|
||||
|
||||
List<Speciality> findByStatusNot(String status);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
join fetch subgroup.studentGroup studentGroup
|
||||
where subgroup.status <> 'ARCHIVED'
|
||||
order by studentGroup.name, subgroup.name
|
||||
""")
|
||||
List<Subgroup> findAllWithGroupsOrderByGroupNameAndName();
|
||||
@@ -23,6 +24,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
from Subgroup subgroup
|
||||
join fetch subgroup.studentGroup
|
||||
where subgroup.studentGroup.id = :groupId
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
order by subgroup.name
|
||||
""")
|
||||
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
|
||||
@@ -41,6 +43,7 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
from Subgroup subgroup
|
||||
where subgroup.studentGroup.id = :groupId
|
||||
and lower(subgroup.name) = lower(:name)
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
""")
|
||||
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,4 +10,8 @@ public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||
Optional<Subject> findByName(String name);
|
||||
|
||||
List<Subject> findByDepartmentId(Long departmentId);
|
||||
|
||||
List<Subject> findByStatusNot(String status);
|
||||
|
||||
List<Subject> findByDepartmentIdAndStatusNot(Long departmentId, String status);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -14,4 +14,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
List<User> findByRole(Role role);
|
||||
|
||||
List<User> findByRoleAndDepartmentId(Role role, Long departmentId);
|
||||
|
||||
List<User> findByStatusNot(String status);
|
||||
|
||||
List<User> findByRoleAndStatusNot(Role role, String status);
|
||||
|
||||
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ public class ScheduleGeneratorService {
|
||||
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
|
||||
return;
|
||||
}
|
||||
if (!rule.getSubject().isActiveOn(date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||
@@ -193,6 +196,12 @@ public class ScheduleGeneratorService {
|
||||
Map<String, Integer> consumedHoursByBucket = new HashMap<>();
|
||||
|
||||
for (ScheduleRuleSlot slot : slots) {
|
||||
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
||||
continue;
|
||||
}
|
||||
if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
|
||||
continue;
|
||||
}
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
||||
if (weekNumber < rule.startWeekFor(category)) {
|
||||
continue;
|
||||
@@ -254,10 +263,13 @@ public class ScheduleGeneratorService {
|
||||
|
||||
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
|
||||
if (targetGroup != null) {
|
||||
return academicDateService.isTheoryDay(targetGroup, semester, date) ? List.of(targetGroup) : List.of();
|
||||
return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date)
|
||||
? List.of(targetGroup)
|
||||
: List.of();
|
||||
}
|
||||
|
||||
return rule.getGroups().stream()
|
||||
.filter(group -> group.isActiveOn(date))
|
||||
.filter(group -> academicDateService.isTheoryDay(group, semester, date))
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS departments (
|
||||
id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
code BIGINT UNIQUE NOT NULL
|
||||
code BIGINT UNIQUE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO departments (name, code) VALUES
|
||||
@@ -20,7 +26,13 @@ INSERT INTO departments (name, code) VALUES
|
||||
CREATE TABLE IF NOT EXISTS specialties (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
specialty_code VARCHAR(255) UNIQUE NOT NULL
|
||||
specialty_code VARCHAR(255) UNIQUE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO specialties (name, specialty_code) VALUES
|
||||
@@ -35,6 +47,12 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||||
name VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT,
|
||||
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
||||
);
|
||||
|
||||
@@ -50,20 +68,62 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
|
||||
role VARCHAR(30) NOT NULL DEFAULT 'STUDENT',
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
job_title VARCHAR(255) NOT NULL,
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
||||
INSERT INTO users (username, password, role, full_name, job_title, department_id)
|
||||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
|
||||
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2)
|
||||
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
|
||||
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
|
||||
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
|
||||
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teacher_department_assignments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
comment TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
CONSTRAINT chk_teacher_department_dates CHECK (valid_to IS NULL OR valid_to >= valid_from)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teacher_department_teacher_dates
|
||||
ON teacher_department_assignments(teacher_id, valid_from, valid_to);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teacher_department_department_dates
|
||||
ON teacher_department_assignments(department_id, valid_from, valid_to);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
|
||||
ON teacher_department_assignments(teacher_id)
|
||||
WHERE is_primary = TRUE AND valid_to IS NULL;
|
||||
|
||||
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
|
||||
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
|
||||
FROM users u
|
||||
WHERE u.role = 'TEACHER'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM teacher_department_assignments existing
|
||||
WHERE existing.teacher_id = u.id
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Образовательные формы
|
||||
-- ==========================================
|
||||
@@ -71,7 +131,13 @@ CREATE TABLE IF NOT EXISTS education_forms (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO education_forms (name) VALUES
|
||||
@@ -92,7 +158,13 @@ CREATE TABLE IF NOT EXISTS student_groups (
|
||||
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
|
||||
specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
|
||||
year_start_study BIGINT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
-- Тестовая базовая группа для работы
|
||||
@@ -116,6 +188,12 @@ CREATE TABLE IF NOT EXISTS subgroups (
|
||||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
student_capacity INT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT,
|
||||
UNIQUE(group_id, name)
|
||||
);
|
||||
|
||||
@@ -146,7 +224,13 @@ CREATE TABLE IF NOT EXISTS subjects (
|
||||
code VARCHAR(20),
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO subjects (name, department_id) VALUES
|
||||
@@ -157,6 +241,17 @@ INSERT INTO subjects (name, department_id) VALUES
|
||||
('Английский язык', 1)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subject_comments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||
author_id BIGINT REFERENCES users(id),
|
||||
comment TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subject_comments_subject
|
||||
ON subject_comments(subject_id, created_at DESC);
|
||||
|
||||
-- Типы занятий
|
||||
CREATE TABLE IF NOT EXISTS lesson_types (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -176,7 +271,13 @@ CREATE TABLE IF NOT EXISTS equipments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
inventory_number VARCHAR(50)
|
||||
inventory_number VARCHAR(50),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO equipments (name) VALUES
|
||||
@@ -197,7 +298,13 @@ CREATE TABLE IF NOT EXISTS classrooms (
|
||||
floor INT,
|
||||
is_available BOOLEAN DEFAULT TRUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
active_from DATE,
|
||||
active_to DATE,
|
||||
archived_at TIMESTAMP,
|
||||
archived_by BIGINT,
|
||||
archive_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
||||
@@ -229,6 +336,17 @@ WHERE
|
||||
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
||||
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_education_forms_status ON education_forms(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_student_groups_status ON student_groups(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_subgroups_status ON subgroups(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_subjects_status ON subjects(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipments_status ON equipments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_classrooms_status ON classrooms(status);
|
||||
|
||||
-- ==========================================
|
||||
-- Связи для преподавателей
|
||||
-- ==========================================
|
||||
@@ -483,6 +601,13 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
lecture_start_week INT NOT NULL,
|
||||
laboratory_start_week INT NOT NULL,
|
||||
practice_start_week INT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
valid_from DATE,
|
||||
valid_to DATE,
|
||||
version_group_id BIGINT,
|
||||
change_reason TEXT,
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK (
|
||||
lecture_academic_hours >= 0
|
||||
AND laboratory_academic_hours >= 0
|
||||
@@ -495,11 +620,14 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
lecture_start_week > 0
|
||||
AND laboratory_start_week > 0
|
||||
AND practice_start_week > 0
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_schedule_rules_dates CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_subject ON schedule_rules(subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_status_dates
|
||||
ON schedule_rules(status, valid_from, valid_to);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_rule_groups (
|
||||
schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
|
||||
@@ -519,6 +647,12 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
|
||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
|
||||
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
|
||||
lesson_format VARCHAR(30) NOT NULL,
|
||||
time_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
classroom_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
teacher_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
locked_by BIGINT REFERENCES users(id),
|
||||
locked_at TIMESTAMP,
|
||||
lock_comment TEXT,
|
||||
CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')),
|
||||
CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн'))
|
||||
@@ -537,6 +671,28 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_overrides (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
new_time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
new_classroom_id BIGINT REFERENCES classrooms(id),
|
||||
new_teacher_id BIGINT REFERENCES users(id),
|
||||
new_lesson_format VARCHAR(30),
|
||||
comment TEXT,
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_schedule_overrides_action CHECK (action IN ('MOVE', 'CANCEL', 'REPLACE')),
|
||||
CONSTRAINT uq_schedule_overrides_slot_date UNIQUE (base_rule_slot_id, lesson_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date
|
||||
ON schedule_overrides(lesson_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher
|
||||
ON schedule_overrides(new_teacher_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
@@ -762,12 +918,17 @@ COMMENT ON TABLE schedule_rules IS 'Правила динамической ге
|
||||
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
|
||||
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
|
||||
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
|
||||
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
|
||||
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
|
||||
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
|
||||
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
|
||||
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
|
||||
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
|
||||
COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре';
|
||||
COMMENT ON COLUMN schedule_rules.laboratory_start_week IS 'Неделя начала лабораторных работ в семестре';
|
||||
COMMENT ON COLUMN schedule_rules.practice_start_week IS 'Неделя начала практик в семестре';
|
||||
COMMENT ON COLUMN schedule_rules.valid_from IS 'Дата начала действия версии правила расписания';
|
||||
COMMENT ON COLUMN schedule_rules.valid_to IS 'Дата окончания действия версии правила расписания';
|
||||
|
||||
COMMENT ON TABLE education_forms IS 'Формы обучения';
|
||||
COMMENT ON TABLE subgroups IS 'Подгруппы';
|
||||
@@ -782,6 +943,7 @@ COMMENT ON COLUMN users.id IS 'ID пользователя';
|
||||
COMMENT ON COLUMN users.username IS 'Логин пользователя';
|
||||
COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя';
|
||||
COMMENT ON COLUMN users.role IS 'Роль пользователя';
|
||||
COMMENT ON COLUMN users.status IS 'Жизненный цикл пользователя: ACTIVE или ARCHIVED';
|
||||
COMMENT ON COLUMN users.created_at IS 'Дата и время создания';
|
||||
COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления';
|
||||
COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
|
||||
@@ -833,6 +995,7 @@ COMMENT ON COLUMN classrooms.floor IS 'Этаж';
|
||||
COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории';
|
||||
COMMENT ON COLUMN classrooms.description IS 'Описание аудитории';
|
||||
COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания';
|
||||
COMMENT ON COLUMN classrooms.status IS 'Жизненный цикл аудитории: ACTIVE или ARCHIVED';
|
||||
|
||||
COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории';
|
||||
COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования';
|
||||
|
||||
164
docs/API.md
164
docs/API.md
@@ -46,6 +46,32 @@
|
||||
|
||||
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>`
|
||||
|
||||
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
|
||||
|
||||
Redirect по ролям:
|
||||
|
||||
| Роль | Redirect |
|
||||
|------|----------|
|
||||
| `ADMIN` | `/admin/` |
|
||||
| `EDUCATION_OFFICE` | `/admin/#schedule-view` |
|
||||
| `DEPARTMENT` | `/admin/#department-workspace` |
|
||||
| `SCHEDULE_VIEWER` | `/admin/#schedule-view` |
|
||||
| `TEACHER` | `/teacher/` |
|
||||
| `STUDENT` | `/student/` |
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Возвращает текущего пользователя по bearer-токену.
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 1,
|
||||
"username": "admin",
|
||||
"role": "ADMIN",
|
||||
"departmentId": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Пользователи
|
||||
@@ -89,13 +115,50 @@
|
||||
**Валидация:**
|
||||
- `username` — обязателен и уникален
|
||||
- `password` — минимум 4 символа
|
||||
- `role` — `ADMIN`, `TEACHER` или `STUDENT`
|
||||
- `role` — `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
|
||||
- `fullName` — обязателен
|
||||
- `departmentId` — обязателен
|
||||
|
||||
### `DELETE /api/users/{id}`
|
||||
|
||||
Удаление пользователя.
|
||||
Архивирование пользователя. Исторические связи и расписание остаются в БД, но пользователь больше не может войти.
|
||||
|
||||
### `POST /api/users/{id}/restore`
|
||||
|
||||
Восстановление архивного пользователя.
|
||||
|
||||
### `GET /api/users/{id}/department-history`
|
||||
|
||||
История переводов преподавателя между кафедрами.
|
||||
|
||||
### `POST /api/users/{id}/department-transfer`
|
||||
|
||||
Перевод преподавателя на другую кафедру без потери прошлых связей.
|
||||
|
||||
```json
|
||||
{
|
||||
"departmentId": 2,
|
||||
"validFrom": "2026-06-01",
|
||||
"comment": "Перевод на кафедру ВТ"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
|
||||
|
||||
Список преподавателей кафедры на конкретную дату по таблице истории.
|
||||
|
||||
---
|
||||
|
||||
## Права ролей на API
|
||||
|
||||
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`.
|
||||
|
||||
Важные ограничения:
|
||||
|
||||
- `DEPARTMENT` не может создавать аудитории, кафедры, специальности, группы, пользователей или правила расписания через API;
|
||||
- `DEPARTMENT` создаёт и комментирует дисциплины через `/api/department/*`, где кафедра берётся из текущего пользователя;
|
||||
- `/api/teacher-subjects` для `DEPARTMENT` разрешает связывать только преподавателей и дисциплины своей кафедры;
|
||||
- `SCHEDULE_VIEWER` имеет read-only доступ к просмотру расписаний, справочникам-фильтрам и загруженности.
|
||||
|
||||
---
|
||||
|
||||
@@ -334,6 +397,97 @@ CRUD доступен по:
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/schedule/search`
|
||||
|
||||
Расширенный поиск расписания. В отличие от `GET /api/schedule`, принимает несколько фильтров одновременно.
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `startDate` / `endDate` | Обязательный период |
|
||||
| `groupId` | Учебная группа |
|
||||
| `teacherId` | Преподаватель |
|
||||
| `classroomId` | Аудитория |
|
||||
| `departmentId` | Кафедра |
|
||||
| `subjectId` | Дисциплина |
|
||||
| `lessonTypeId` | Тип занятия |
|
||||
| `timeSlotId` | Временной слот |
|
||||
| `parity` | `BOTH`, `ODD`, `EVEN` |
|
||||
|
||||
Пример:
|
||||
|
||||
```http
|
||||
GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
|
||||
```
|
||||
|
||||
Ответ совпадает со структурой `RenderedLessonDto` из `GET /api/schedule`.
|
||||
|
||||
### Точечные изменения расписания учебного отдела
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/edu-office/schedule/overrides` | Список точечных изменений |
|
||||
| `POST` | `/api/edu-office/schedule/overrides` | Создать перенос, отмену или замену |
|
||||
| `PUT` | `/api/edu-office/schedule/overrides/{id}` | Обновить изменение |
|
||||
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение |
|
||||
|
||||
```json
|
||||
{
|
||||
"baseRuleSlotId": 31,
|
||||
"lessonDate": "2026-05-21",
|
||||
"action": "REPLACE",
|
||||
"newClassroomId": 2,
|
||||
"newTeacherId": 5,
|
||||
"comment": "Замена аудитории и преподавателя"
|
||||
}
|
||||
```
|
||||
|
||||
`action=CANCEL` отменяет конкретную пару. `MOVE` и `REPLACE` могут менять аудиторию, преподавателя, формат и временной слот.
|
||||
|
||||
## Загруженность
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/workload/teachers` | Загруженность преподавателей |
|
||||
| `GET` | `/api/workload/classrooms` | Загруженность аудиторий |
|
||||
| `GET` | `/api/workload/departments` | Загруженность кафедр |
|
||||
| `GET` | `/api/workload/time-slots` | Загруженность по парам |
|
||||
| `GET` | `/api/workload/free-classrooms` | Свободные аудитории на дату и пару |
|
||||
|
||||
Общие параметры для отчётов: `startDate`, `endDate`, опционально `departmentId`.
|
||||
|
||||
Пример:
|
||||
|
||||
```http
|
||||
GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-01
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Петров Препод Петрович",
|
||||
"departmentId": 1,
|
||||
"departmentName": "Кафедра ИБ",
|
||||
"lessonCount": 8,
|
||||
"academicHours": 16,
|
||||
"occupiedSlotCount": 8
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Кабинет кафедры
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/department/subjects` | Дисциплины текущей кафедры |
|
||||
| `POST` | `/api/department/subjects/import` | Загрузка списка дисциплин |
|
||||
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
|
||||
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
|
||||
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
|
||||
| `GET` | `/api/department/schedule` | Расписание кафедры |
|
||||
|
||||
---
|
||||
|
||||
## Кафедры и специальности
|
||||
@@ -557,7 +711,11 @@ CRUD доступен по:
|
||||
|
||||
### `DELETE /api/classrooms/{id}`
|
||||
|
||||
Удаление аудитории.
|
||||
Архивирование аудитории. Архивная аудитория остаётся в историческом расписании, но не выбирается в новых назначениях.
|
||||
|
||||
### `POST /api/classrooms/{id}/restore`
|
||||
|
||||
Восстановление архивной аудитории.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ graph TD
|
||||
- **Порт:** 8080 (внутренний)
|
||||
- **ORM:** Hibernate (JPA), `ddl-auto=none`
|
||||
- **Миграции:** Flyway (программный запуск при подключении тенанта)
|
||||
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены
|
||||
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей
|
||||
|
||||
### PostgreSQL
|
||||
- **Версия:** `postgres:alpine3.23`
|
||||
@@ -131,12 +131,16 @@ sequenceDiagram
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Система использует **простую модель аутентификации** без JWT или Spring Security фильтров:
|
||||
Система использует простую модель аутентификации без JWT и без полноценного Spring Security:
|
||||
|
||||
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`
|
||||
2. Backend проверяет пароль через `BCryptPasswordEncoder`
|
||||
3. При успехе возвращается:
|
||||
- UUID-токен (для заголовка `Authorization: Bearer`)
|
||||
- Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`)
|
||||
- Redirect URL (`/admin/`, `/teacher/`, `/student/`)
|
||||
- Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`)
|
||||
- Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`)
|
||||
4. Токен хранится в `localStorage` на клиенте
|
||||
|
||||
Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно.
|
||||
|
||||
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution.
|
||||
|
||||
@@ -2,19 +2,33 @@
|
||||
|
||||
## Ролевая модель
|
||||
|
||||
Система поддерживает три роли пользователей:
|
||||
Система поддерживает шесть ролей пользователей:
|
||||
|
||||
| Роль | Enum | Возможности |
|
||||
|------|------|------------|
|
||||
| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). |
|
||||
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
|
||||
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
|
||||
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
|
||||
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
|
||||
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
|
||||
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
|
||||
|
||||
После авторизации пользователь перенаправляется на свой интерфейс:
|
||||
- `ADMIN` → `/admin/`
|
||||
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
|
||||
- `DEPARTMENT` → `/admin/#department-workspace`
|
||||
- `SCHEDULE_VIEWER` → `/admin/#schedule-view`
|
||||
- `TEACHER` → `/teacher/`
|
||||
- `STUDENT` → `/student/`
|
||||
|
||||
Bearer-токен проверяется на backend. Frontend-скрытие пунктов меню является только удобством, а не источником прав.
|
||||
|
||||
Для роли `DEPARTMENT` backend дополнительно ограничивает изменения рамками кафедры текущего пользователя:
|
||||
|
||||
- загрузка и комментарии дисциплин идут через `/api/department/*`;
|
||||
- общий `/api/subjects` доступен кафедре только на чтение;
|
||||
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя.
|
||||
|
||||
---
|
||||
|
||||
## Управление ресурсами
|
||||
@@ -52,6 +66,7 @@
|
||||
- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность
|
||||
- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества
|
||||
- **Статус:** Флаг `is_available` для блокирования назначения пар
|
||||
- **Жизненный цикл:** `status=ARCHIVED` означает вывод из эксплуатации. Такая аудитория отображается в прошлом расписании, но запрещена для новых назначений.
|
||||
|
||||
### Оборудование (Equipments)
|
||||
|
||||
@@ -64,6 +79,26 @@
|
||||
|
||||
- **Поля:** Название (уникальное), код, кафедра, описание
|
||||
- Привязка преподавателей через `teacher_subjects` (Many-to-Many)
|
||||
- Кафедра может добавлять комментарии к дисциплине и загружать список дисциплин через кабинет кафедры
|
||||
|
||||
### Жизненный цикл справочников
|
||||
|
||||
Справочники, которые участвуют в расписании и отчётах, не удаляются физически. Для них используется архивирование:
|
||||
|
||||
- `ACTIVE` — запись доступна для выбора;
|
||||
- `ARCHIVED` — запись остаётся в истории, но не используется в будущих назначениях.
|
||||
|
||||
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
|
||||
|
||||
### Перевод преподавателей между кафедрами
|
||||
|
||||
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
|
||||
|
||||
Правила:
|
||||
|
||||
- у преподавателя должна быть одна открытая основная кафедра;
|
||||
- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
|
||||
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +121,7 @@
|
||||
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
||||
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
|
||||
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
|
||||
|
||||
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
||||
1. Определяет семестр для каждой даты диапазона.
|
||||
@@ -98,6 +134,7 @@
|
||||
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
|
||||
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
|
||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
||||
|
||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||
|
||||
@@ -129,6 +166,18 @@
|
||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
||||
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
Учебный отдел может создать изменение конкретной пары:
|
||||
|
||||
- `CANCEL` — отменить пару;
|
||||
- `MOVE` — перенести пару на другой временной слот или в другую аудиторию;
|
||||
- `REPLACE` — заменить преподавателя, аудиторию или формат.
|
||||
|
||||
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного расписания на конкретную дату.
|
||||
|
||||
---
|
||||
|
||||
|
||||
116
docs/DATABASE.md
116
docs/DATABASE.md
@@ -41,6 +41,10 @@ erDiagram
|
||||
VARCHAR full_name
|
||||
VARCHAR job_title
|
||||
BIGINT department_id FK
|
||||
VARCHAR status
|
||||
DATE active_from
|
||||
DATE active_to
|
||||
TIMESTAMP archived_at
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMP updated_at
|
||||
}
|
||||
@@ -101,6 +105,10 @@ erDiagram
|
||||
VARCHAR building
|
||||
INT floor
|
||||
BOOLEAN is_available
|
||||
VARCHAR status
|
||||
DATE active_from
|
||||
DATE active_to
|
||||
TIMESTAMP archived_at
|
||||
TEXT description
|
||||
TIMESTAMP created_at
|
||||
}
|
||||
@@ -119,6 +127,24 @@ erDiagram
|
||||
INT experience_years
|
||||
}
|
||||
|
||||
teacher_department_assignments {
|
||||
BIGSERIAL id PK
|
||||
BIGINT teacher_id FK
|
||||
BIGINT department_id FK
|
||||
DATE valid_from
|
||||
DATE valid_to
|
||||
BOOLEAN is_primary
|
||||
TEXT comment
|
||||
}
|
||||
|
||||
subject_comments {
|
||||
BIGSERIAL id PK
|
||||
BIGINT subject_id FK
|
||||
BIGINT author_id FK
|
||||
TEXT comment
|
||||
TIMESTAMP created_at
|
||||
}
|
||||
|
||||
teacher_lesson_types {
|
||||
BIGINT user_id FK,PK
|
||||
BIGINT subject_id FK,PK
|
||||
@@ -207,6 +233,9 @@ erDiagram
|
||||
BIGSERIAL id PK
|
||||
BIGINT subject_id FK
|
||||
BIGINT semester_id FK
|
||||
VARCHAR status
|
||||
DATE valid_from
|
||||
DATE valid_to
|
||||
INT lecture_academic_hours
|
||||
INT laboratory_academic_hours
|
||||
INT practice_academic_hours
|
||||
@@ -230,6 +259,21 @@ erDiagram
|
||||
BIGINT classroom_id FK
|
||||
BIGINT lesson_type_id FK
|
||||
VARCHAR lesson_format
|
||||
BOOLEAN time_locked
|
||||
BOOLEAN classroom_locked
|
||||
BOOLEAN teacher_locked
|
||||
}
|
||||
|
||||
schedule_overrides {
|
||||
BIGSERIAL id PK
|
||||
BIGINT base_rule_slot_id FK
|
||||
DATE lesson_date
|
||||
VARCHAR action
|
||||
BIGINT new_time_slot_id FK
|
||||
BIGINT new_classroom_id FK
|
||||
BIGINT new_teacher_id FK
|
||||
VARCHAR new_lesson_format
|
||||
TEXT comment
|
||||
}
|
||||
|
||||
schedule_rule_slot_subgroups {
|
||||
@@ -248,8 +292,12 @@ erDiagram
|
||||
student_groups ||--o{ schedule_rule_groups : "group_id"
|
||||
student_groups ||--o{ student_group_calendar_assignments : "group_id"
|
||||
users ||--o{ teacher_subjects : "user_id"
|
||||
users ||--o{ teacher_department_assignments : "teacher_id"
|
||||
departments ||--o{ teacher_department_assignments : "department_id"
|
||||
users ||--o{ teacher_lesson_types : "user_id"
|
||||
users ||--o{ subject_comments : "author_id"
|
||||
subjects ||--o{ teacher_subjects : "subject_id"
|
||||
subjects ||--o{ subject_comments : "subject_id"
|
||||
subjects ||--o{ teacher_lesson_types : "subject_id"
|
||||
subjects ||--o{ schedule_rules : "subject_id"
|
||||
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
|
||||
@@ -270,6 +318,10 @@ erDiagram
|
||||
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
||||
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
||||
schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id"
|
||||
schedule_rule_slots ||--o{ schedule_overrides : "base_rule_slot_id"
|
||||
time_slots ||--o{ schedule_overrides : "new_time_slot_id"
|
||||
classrooms ||--o{ schedule_overrides : "new_classroom_id"
|
||||
users ||--o{ schedule_overrides : "new_teacher_id"
|
||||
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
|
||||
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
|
||||
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
||||
@@ -317,6 +369,11 @@ erDiagram
|
||||
| `full_name` | VARCHAR(255) | ФИО |
|
||||
| `job_title` | VARCHAR(255) | Должность |
|
||||
| `department_id` | BIGINT FK → departments | Кафедра |
|
||||
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти |
|
||||
| `active_from` | DATE | Дата начала действия записи |
|
||||
| `active_to` | DATE | Дата окончания действия записи |
|
||||
| `archived_at` | TIMESTAMP | Когда пользователь архивирован |
|
||||
| `archive_reason` | TEXT | Причина архивирования |
|
||||
| `created_at` | TIMESTAMP | Дата создания |
|
||||
| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) |
|
||||
|
||||
@@ -375,6 +432,11 @@ erDiagram
|
||||
| `building` | VARCHAR(50) | Корпус |
|
||||
| `floor` | INT | Этаж |
|
||||
| `is_available` | BOOLEAN | Доступна для назначения пар |
|
||||
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях |
|
||||
| `active_from` | DATE | Дата начала действия записи |
|
||||
| `active_to` | DATE | Дата окончания действия записи |
|
||||
| `archived_at` | TIMESTAMP | Когда аудитория выведена из эксплуатации |
|
||||
| `archive_reason` | TEXT | Причина архивирования |
|
||||
| `description` | TEXT | Описание |
|
||||
|
||||
#### `equipments` — Оборудование
|
||||
@@ -420,6 +482,30 @@ erDiagram
|
||||
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
|
||||
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
|
||||
|
||||
#### `teacher_department_assignments` — История кафедр преподавателя
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID исторической записи |
|
||||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||||
| `department_id` | BIGINT FK → departments | Кафедра |
|
||||
| `valid_from` | DATE | Дата начала принадлежности |
|
||||
| `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры |
|
||||
| `is_primary` | BOOLEAN | Основная кафедра преподавателя |
|
||||
| `comment` | TEXT | Комментарий к переводу |
|
||||
| `created_at` | TIMESTAMP | Дата создания записи |
|
||||
| `created_by` | BIGINT FK → users | Кто оформил перевод |
|
||||
|
||||
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя.
|
||||
|
||||
#### `subject_comments` — Комментарии к дисциплинам
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID комментария |
|
||||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||
| `author_id` | BIGINT FK → users | Автор комментария |
|
||||
| `comment` | TEXT | Текст комментария |
|
||||
| `created_at` | TIMESTAMP | Дата создания |
|
||||
|
||||
#### `time_slot_scopes` — Сетки времени
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
@@ -524,6 +610,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
| `lecture_start_week` | INT | Неделя семестра, с которой начинаются лекции |
|
||||
| `laboratory_start_week` | INT | Неделя семестра, с которой начинаются лабораторные |
|
||||
| `practice_start_week` | INT | Неделя семестра, с которой начинаются практики |
|
||||
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED` |
|
||||
| `valid_from` | DATE | Начало действия версии правила |
|
||||
| `valid_to` | DATE | Окончание действия версии правила |
|
||||
| `version_group_id` | BIGINT | Группа версий одного правила |
|
||||
| `change_reason` | TEXT | Причина изменения |
|
||||
|
||||
#### `schedule_rule_groups` — Группы правила
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -543,6 +634,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
||||
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
|
||||
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
|
||||
| `time_locked` | BOOLEAN | Время закреплено учебным отделом |
|
||||
| `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом |
|
||||
| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом |
|
||||
| `locked_by` | BIGINT FK → users | Кто выполнил закрепление |
|
||||
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
|
||||
| `lock_comment` | TEXT | Комментарий к закреплению |
|
||||
|
||||
#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -552,6 +649,23 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
|
||||
Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `validate_schedule_rule_slot_subgroups` проверяет тип занятия, принадлежность подгруппы к группам правила и запрет на две подгруппы одной группы в одном слоте; для быстрых выборок есть индекс `idx_schedule_rule_slot_subgroups_subgroup`.
|
||||
|
||||
#### `schedule_overrides` — Точечные изменения пар
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID изменения |
|
||||
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот правила |
|
||||
| `lesson_date` | DATE | Дата конкретной пары |
|
||||
| `action` | VARCHAR(20) | `MOVE`, `CANCEL`, `REPLACE` |
|
||||
| `new_time_slot_id` | BIGINT FK → time_slots | Новый временной слот |
|
||||
| `new_classroom_id` | BIGINT FK → classrooms | Новая аудитория |
|
||||
| `new_teacher_id` | BIGINT FK → users | Новый преподаватель |
|
||||
| `new_lesson_format` | VARCHAR(30) | Новый формат занятия |
|
||||
| `comment` | TEXT | Причина изменения |
|
||||
| `created_by` | BIGINT FK → users | Автор изменения |
|
||||
| `created_at` | TIMESTAMP | Дата создания |
|
||||
|
||||
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для одной и той же пары.
|
||||
|
||||
---
|
||||
|
||||
## Flyway миграции
|
||||
@@ -570,7 +684,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание с раздельными часами и стартовыми неделями по типам занятий, тестовые правила, триггеры, комментарии |
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ frontend/
|
||||
│ │ ├── layout.css # Раскладка (sidebar, topbar, content)
|
||||
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
|
||||
│ │ ├── modals.css # Модальные окна
|
||||
│ │ ├── auditorium-workload.css # Таблицы загруженности аудиторий
|
||||
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
|
||||
│ │ └── departments-data.css # Стили создания кафедры/специальности
|
||||
│ ├── js/
|
||||
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
||||
@@ -44,9 +44,11 @@ frontend/
|
||||
│ │ ├── equipments.js # Управление оборудованием
|
||||
│ │ ├── edu-forms.js # Формы обучения
|
||||
│ │ ├── profiles.js # Профили обучения специальностей
|
||||
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
|
||||
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
|
||||
│ │ ├── schedule.js # Конструктор правил расписания
|
||||
│ │ ├── academic-calendar.js # Календарные учебные графики
|
||||
│ │ ├── auditorium-workload.js # Загруженность аудиторий
|
||||
│ │ ├── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
|
||||
│ │ ├── database.js # Управление тенантами
|
||||
│ │ └── departments-data.js # Создание кафедры/специальности
|
||||
│ ├── views/ # HTML-шаблоны представлений
|
||||
@@ -57,6 +59,8 @@ frontend/
|
||||
│ │ ├── equipments.html
|
||||
│ │ ├── edu-forms.html
|
||||
│ │ ├── profiles.html
|
||||
│ │ ├── department-workspace.html
|
||||
│ │ ├── schedule-view.html
|
||||
│ │ ├── schedule.html
|
||||
│ │ ├── academic-calendar.html
|
||||
│ │ ├── auditorium-workload.html
|
||||
@@ -78,6 +82,10 @@ frontend/
|
||||
│
|
||||
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
||||
│ └── index.html # Недельный просмотр динамического расписания преподавателя
|
||||
├── department/ # 🏛 Кабинет кафедры
|
||||
│ └── index.html # Redirect в `/admin/#department-workspace`
|
||||
├── edu-office/ # 🗓 Кабинет учебного отдела
|
||||
│ └── index.html # Redirect в `/admin/#schedule-view`
|
||||
│
|
||||
└── student/ # 🎓 Интерфейс студента
|
||||
└── index.html # Недельный просмотр динамического расписания группы
|
||||
@@ -104,6 +112,19 @@ frontend/
|
||||
3. Подключает соответствующий JS-модуль из `js/views/{tab}.js`
|
||||
4. Обновляет заголовок страницы (`#page-title`)
|
||||
|
||||
`main.js` также фильтрует вкладки по роли:
|
||||
|
||||
| Роль | Доступные вкладки |
|
||||
|------|-------------------|
|
||||
| `ADMIN` | Все вкладки |
|
||||
| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории, оборудование |
|
||||
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний |
|
||||
| `SCHEDULE_VIEWER` | Только просмотр расписаний |
|
||||
|
||||
Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели.
|
||||
|
||||
Пункты меню, недоступные роли, не отображаются в sidebar: `main.js` выставляет `hidden`, а `layout.css` явно скрывает такие элементы, чтобы базовый `display: flex` у `.nav-item` не возвращал их на экран.
|
||||
|
||||
### Разделы админ-панели
|
||||
|
||||
| Tab | Описание | API |
|
||||
@@ -115,30 +136,35 @@ frontend/
|
||||
| `equipments` | Оборудование | `/api/equipments` |
|
||||
| `classrooms` | Аудитории | `/api/classrooms` |
|
||||
| `subjects` | Дисциплины | `/api/subjects` |
|
||||
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
|
||||
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
|
||||
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
|
||||
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
|
||||
| `auditorium-workload` | Динамическая загруженность аудиторий: сводная матрица по дате или совмещённая таблица выбранной аудитории по чётной/нечётной неделе | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
|
||||
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
|
||||
| `database` | Тенанты | `/api/database` |
|
||||
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. В поле «Отображение» можно выбрать конкретную аудиторию; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной аудитории растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
|
||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
||||
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса идут вертикально друг под другом, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; выбранная ячейка и её код дополнительно подсвечиваются в общей сводке.
|
||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
||||
|
||||
### Страница настроек (`/admin/settings/`)
|
||||
|
||||
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
|
||||
|
||||
- Доступ: через dropdown «Настройки» в footer боковой панели админки
|
||||
- Доступ: через dropdown «Настройки» в footer боковой панели админки для `ADMIN` и `EDUCATION_OFFICE`
|
||||
- Кнопка «Назад в панель» для возврата в `/admin/`
|
||||
- Текущие вкладки:
|
||||
- **Общие настройки** — заглушка (в разработке)
|
||||
- **Общие настройки** — заглушка (только `ADMIN`)
|
||||
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
|
||||
|
||||
---
|
||||
@@ -191,6 +217,9 @@ export const api = {
|
||||
- `userId` — ID пользователя для личного расписания преподавателя
|
||||
4. Перенаправляет на соответствующий интерфейс:
|
||||
- `ADMIN` → `/admin/`
|
||||
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
|
||||
- `DEPARTMENT` → `/admin/#department-workspace`
|
||||
- `SCHEDULE_VIEWER` → `/admin/#schedule-view`
|
||||
- `TEACHER` → `/teacher/`
|
||||
- `STUDENT` → `/student/`
|
||||
|
||||
@@ -213,6 +242,30 @@ export function isAuthenticatedAsAdmin() {
|
||||
|
||||
## Кабинеты расписания
|
||||
|
||||
### Учебный отдел (`/admin/#schedule-view`)
|
||||
|
||||
Учебный отдел работает в общей админ-панели с ограниченным набором вкладок:
|
||||
|
||||
- расширенный поиск `/api/schedule/search` по дате в периоде, преподавателю, аудитории и кафедре;
|
||||
- конструктор расписания через `/api/admin/schedule-rules`;
|
||||
- создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API;
|
||||
- матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`;
|
||||
- аудитории, оборудование, календарный график и загруженность.
|
||||
|
||||
### Кафедра (`/admin/#department-workspace`)
|
||||
|
||||
Кабинет кафедры встроен в общую панель и работает в контексте `departmentId` текущего пользователя:
|
||||
|
||||
- список дисциплин кафедры через `/api/department/subjects`;
|
||||
- загрузка дисциплин из текстового списка в формате `код; название`;
|
||||
- комментарии к дисциплинам;
|
||||
- просмотр расписания кафедры через вкладку `schedule-view`;
|
||||
- загруженность преподавателей кафедры.
|
||||
|
||||
### Просмотр расписаний (`/admin/#schedule-view`)
|
||||
|
||||
Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам.
|
||||
|
||||
### Преподаватель (`/teacher/`)
|
||||
|
||||
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`.
|
||||
|
||||
@@ -90,6 +90,8 @@ magistr/
|
||||
│ ├── admin/ # Админ-панель (деканат)
|
||||
│ │ ├── js/views/ # Модули представлений
|
||||
│ │ └── css/ # Стили
|
||||
│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA
|
||||
│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA
|
||||
│ ├── teacher/ # Интерфейс преподавателя
|
||||
│ └── student/ # Интерфейс студента
|
||||
├── docs/ # 📖 Документация (вы здесь)
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.workload-target-control {
|
||||
flex: 0 1 220px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.workload-display-select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
@@ -564,6 +569,297 @@
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.schedule-view-shell {
|
||||
min-height: calc(100vh - 8.5rem);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(420px, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.schedule-view-control-card,
|
||||
.schedule-view-results-card,
|
||||
.schedule-view-table-card {
|
||||
animation-duration: 0.28s;
|
||||
}
|
||||
|
||||
.schedule-view-control-header,
|
||||
.schedule-view-results-header,
|
||||
.schedule-view-table-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.schedule-view-control-header h2,
|
||||
.schedule-view-results-header h2,
|
||||
.schedule-view-table-header h2 {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.schedule-view-control-grid,
|
||||
.schedule-view-extra-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.schedule-view-control-grid {
|
||||
grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr) minmax(210px, 0.9fr);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.schedule-view-extra-grid {
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.schedule-view-period-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.schedule-view-period-buttons,
|
||||
.schedule-view-result-actions,
|
||||
.schedule-view-day-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.schedule-view-shell .btn-secondary {
|
||||
min-height: 42px;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.schedule-view-shell .btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.schedule-view-shell .btn-secondary:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.schedule-view-extra-filters {
|
||||
margin-top: 0.9rem;
|
||||
border-top: 1px solid var(--bg-card-border);
|
||||
padding-top: 0.85rem;
|
||||
}
|
||||
|
||||
.schedule-view-extra-filters summary {
|
||||
width: fit-content;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-view-result-actions {
|
||||
min-height: 42px;
|
||||
justify-content: flex-end;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.schedule-view-result-tabs {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
margin-top: 1rem;
|
||||
padding-bottom: 0.1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.schedule-view-result-tab {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.18rem;
|
||||
min-width: 150px;
|
||||
max-width: 240px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.schedule-view-result-tab span,
|
||||
.schedule-view-result-tab small {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-view-result-tab small {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.schedule-view-result-tab:hover,
|
||||
.schedule-view-result-tab.active {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-input));
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.schedule-view-empty-result {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.schedule-view-table-card {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schedule-view-table-header {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.schedule-view-table-stage {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-view-week-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.schedule-view-grid-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.schedule-view-matrix-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.schedule-view-matrix-table th,
|
||||
.schedule-view-matrix-table td {
|
||||
min-width: 150px;
|
||||
height: 172px;
|
||||
}
|
||||
|
||||
.schedule-view-lesson-card .lesson-meta + .lesson-meta {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.schedule-view-state {
|
||||
min-height: 100%;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.schedule-view-day-tabs {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.schedule-view-day-tab {
|
||||
min-width: 42px;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-view-day-tab.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--bg-input));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schedule-view-mobile-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.schedule-view-mobile-day-heading,
|
||||
.schedule-view-mobile-slot {
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.schedule-view-mobile-day-heading {
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
|
||||
.schedule-view-mobile-day-heading strong,
|
||||
.schedule-view-mobile-day-heading span,
|
||||
.schedule-view-mobile-slot-time strong,
|
||||
.schedule-view-mobile-slot-time span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.schedule-view-mobile-day-heading span,
|
||||
.schedule-view-mobile-slot-time span {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.schedule-view-mobile-slot {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schedule-view-mobile-slot-time {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-bottom: 1px solid var(--bg-card-border);
|
||||
}
|
||||
|
||||
.schedule-view-mobile-slot-content .room-week-unified {
|
||||
min-height: 124px;
|
||||
}
|
||||
|
||||
/* Настройка scrollbar для контейнера сетки */
|
||||
.workload-grid-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -580,7 +876,14 @@
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.schedule-view-control-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workload-target-control,
|
||||
.workload-display-control {
|
||||
min-width: 100%;
|
||||
}
|
||||
@@ -596,4 +899,54 @@
|
||||
.room-grid-container {
|
||||
height: 520px;
|
||||
}
|
||||
|
||||
.schedule-view-shell {
|
||||
min-height: auto;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.schedule-view-control-header,
|
||||
.schedule-view-results-header,
|
||||
.schedule-view-table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-view-control-grid,
|
||||
.schedule-view-extra-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-view-period-buttons,
|
||||
.schedule-view-result-actions,
|
||||
.schedule-view-day-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schedule-view-period-buttons .btn-secondary,
|
||||
.schedule-view-result-actions .btn-secondary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.schedule-view-result-actions {
|
||||
justify-content: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-view-result-actions span {
|
||||
flex: 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.schedule-view-result-tab {
|
||||
min-width: 72%;
|
||||
}
|
||||
|
||||
.schedule-view-table-card {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.schedule-view-day-tabs {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,111 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.filter-row select,
|
||||
.schedule-view-filter-grid select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.form-group select:focus,
|
||||
.filter-row select:focus,
|
||||
.schedule-view-filter-grid select:focus {
|
||||
background: var(--bg-input-focus);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stack-form textarea {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.stack-form textarea:focus {
|
||||
background: var(--bg-input-focus);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
.schedule-view-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(170px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.schedule-view-metrics,
|
||||
.department-workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.department-comment-item {
|
||||
padding: 0.85rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.metric-card strong,
|
||||
.department-comment-item strong {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.metric-card small,
|
||||
.department-comment-item span {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.department-workload-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.department-comment-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.department-comment-item p {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schedule-section-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
@@ -578,6 +683,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.schedule-view-filter-grid,
|
||||
.schedule-view-metrics,
|
||||
.department-workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-hours-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item[hidden],
|
||||
.settings-menu-item[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -109,6 +109,25 @@
|
||||
</svg>
|
||||
<span>Дисциплины</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="department-workspace">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18" />
|
||||
<path d="M5 21V7l7-4 7 4v14" />
|
||||
<path d="M9 21v-6h6v6" />
|
||||
<path d="M9 9h.01" />
|
||||
<path d="M15 9h.01" />
|
||||
</svg>
|
||||
<span>Кабинет кафедры</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="schedule-view">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>Просмотр расписаний</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="schedule">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
@@ -134,7 +153,7 @@
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||
</svg>
|
||||
<span>Загруженность аудиторий</span>
|
||||
<span>Загруженность</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="database">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@@ -9,6 +9,16 @@ export function isAuthenticatedAsAdmin() {
|
||||
return token && role === 'ADMIN';
|
||||
}
|
||||
|
||||
export function isAuthenticatedAsRole(expectedRole) {
|
||||
const role = localStorage.getItem('role');
|
||||
return token && role === expectedRole;
|
||||
}
|
||||
|
||||
export function isAuthenticatedAsAny(roles) {
|
||||
const role = localStorage.getItem('role');
|
||||
return token && roles.includes(role);
|
||||
}
|
||||
|
||||
function getHeaders(contentType = 'application/json') {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
@@ -31,9 +41,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
|
||||
|
||||
const response = await fetch(endpoint, options);
|
||||
|
||||
// Si status is 401 or 403, we should probably redirect to login,
|
||||
// but for now let's just throw an error or handle it in the view.
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
@@ -48,7 +55,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Shortcut methods
|
||||
export const api = {
|
||||
get: (url) => apiFetch(url, 'GET'),
|
||||
post: (url, body) => apiFetch(url, 'POST', body),
|
||||
|
||||
@@ -3,12 +3,14 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
|
||||
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
|
||||
}
|
||||
|
||||
import { isAuthenticatedAsAdmin } from './api.js';
|
||||
import { isAuthenticatedAsAny } from './api.js';
|
||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||
|
||||
// Auth check
|
||||
if (!isAuthenticatedAsAdmin()) {
|
||||
const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER'];
|
||||
const currentRole = localStorage.getItem('role');
|
||||
|
||||
if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
@@ -30,21 +32,44 @@ import {initDepartmentsData} from "./views/departments-data.js";
|
||||
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
|
||||
import {initProfiles} from "./views/profiles.js";
|
||||
import {initAcademicCalendar} from "./views/academic-calendar.js";
|
||||
import {initScheduleView} from "./views/schedule-view.js";
|
||||
import {initDepartmentWorkspace} from "./views/department-workspace.js";
|
||||
|
||||
// Configuration
|
||||
const ROUTES = {
|
||||
users: { title: 'Управление пользователями', file: 'views/users.html', init: initUsers },
|
||||
groups: { title: 'Управление группами', file: 'views/groups.html', init: initGroups },
|
||||
'edu-forms': { title: 'Формы обучения', file: 'views/edu-forms.html', init: initEduForms },
|
||||
equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments },
|
||||
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
|
||||
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
|
||||
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
|
||||
'academic-calendar': { title: 'Календарный учебный график', file: 'views/academic-calendar.html', init: initAcademicCalendar },
|
||||
'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload },
|
||||
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
|
||||
'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData },
|
||||
profiles: { title: 'Профили обучения', file: 'views/profiles.html', init: initProfiles },
|
||||
users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers },
|
||||
groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups },
|
||||
'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms },
|
||||
equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments },
|
||||
classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms },
|
||||
subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects },
|
||||
'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace },
|
||||
'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView },
|
||||
schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule },
|
||||
'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar },
|
||||
'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload },
|
||||
database: { title: 'База данных', file: '/admin/views/database.html', init: initDatabase },
|
||||
'departments-data': { title: 'Создание кафедры/специальности', file: '/admin/views/departments-data.html', init: initDepartmentsData },
|
||||
profiles: { title: 'Профили обучения', file: '/admin/views/profiles.html', init: initProfiles },
|
||||
};
|
||||
|
||||
const ROLE_NAVIGATION = {
|
||||
ADMIN: {
|
||||
defaultTab: 'users',
|
||||
tabs: Object.keys(ROUTES)
|
||||
},
|
||||
EDUCATION_OFFICE: {
|
||||
defaultTab: 'schedule-view',
|
||||
tabs: ['schedule-view', 'schedule', 'academic-calendar', 'auditorium-workload', 'classrooms', 'equipments']
|
||||
},
|
||||
DEPARTMENT: {
|
||||
defaultTab: 'department-workspace',
|
||||
tabs: ['department-workspace', 'schedule-view']
|
||||
},
|
||||
SCHEDULE_VIEWER: {
|
||||
defaultTab: 'schedule-view',
|
||||
tabs: ['schedule-view']
|
||||
}
|
||||
};
|
||||
|
||||
let currentTab = null;
|
||||
@@ -59,15 +84,13 @@ const menuToggle = document.getElementById('menu-toggle');
|
||||
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
||||
const btnLogout = document.getElementById('btn-logout');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
// Initial auth check
|
||||
if (!isAuthenticatedAsAdmin()) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
|
||||
const allowedTabs = new Set(roleNavigation.tabs);
|
||||
|
||||
// Setup Global Effects
|
||||
applyRippleEffect();
|
||||
closeAllDropdownsOnOutsideClick();
|
||||
configureRoleInterface();
|
||||
|
||||
// Init sidebar state from localStorage on load
|
||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
@@ -136,7 +159,7 @@ navItems.forEach(item => {
|
||||
});
|
||||
|
||||
async function switchTab(tab) {
|
||||
if (currentTab === tab || !ROUTES[tab]) return;
|
||||
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
|
||||
|
||||
// UI Update
|
||||
navItems.forEach(n => n.classList.remove('active'));
|
||||
@@ -147,7 +170,7 @@ async function switchTab(tab) {
|
||||
try {
|
||||
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
const response = await fetch(ROUTES[tab].file);
|
||||
if (!response.ok) throw new Error('Failed to load view');
|
||||
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
|
||||
|
||||
const html = await response.text();
|
||||
appContent.innerHTML = html;
|
||||
@@ -168,5 +191,24 @@ async function switchTab(tab) {
|
||||
sidebarOverlay.classList.remove('open');
|
||||
}
|
||||
|
||||
// Load default tab
|
||||
switchTab('users');
|
||||
function configureRoleInterface() {
|
||||
navItems.forEach(item => {
|
||||
item.hidden = !allowedTabs.has(item.dataset.tab);
|
||||
});
|
||||
|
||||
const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]');
|
||||
if (settingsLink) {
|
||||
settingsLink.hidden = !['ADMIN', 'EDUCATION_OFFICE'].includes(currentRole);
|
||||
}
|
||||
|
||||
const firstAllowedTitle = {
|
||||
ADMIN: 'Magistr',
|
||||
EDUCATION_OFFICE: 'Учебный отдел',
|
||||
DEPARTMENT: 'Кафедра',
|
||||
SCHEDULE_VIEWER: 'Расписания'
|
||||
}[currentRole] || 'Magistr';
|
||||
document.querySelector('.logo span').textContent = firstAllowedTitle;
|
||||
}
|
||||
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
|
||||
|
||||
@@ -3,9 +3,44 @@ import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
const NO_BUILDING = '__no_building__';
|
||||
const DISPLAY_ALL = 'all';
|
||||
const TARGET_CLASSROOMS = 'classrooms';
|
||||
const TARGET_TEACHERS = 'teachers';
|
||||
const TARGET_DEPARTMENTS = 'departments';
|
||||
const ROOM_PREFIX = 'room:';
|
||||
const TEACHER_PREFIX = 'teacher:';
|
||||
const DEPARTMENT_PREFIX = 'department:';
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const WORKLOAD_TARGETS = {
|
||||
[TARGET_CLASSROOMS]: {
|
||||
allLabel: 'Все аудитории',
|
||||
emptyLabel: 'Нет аудиторий по выбранным фильтрам',
|
||||
rowLabel: 'Аудитория',
|
||||
summaryLabel: 'Аудиторий',
|
||||
periodTitle: 'Нагрузка аудитории по неделям',
|
||||
selectError: 'Выберите аудиторию',
|
||||
prefix: ROOM_PREFIX
|
||||
},
|
||||
[TARGET_TEACHERS]: {
|
||||
allLabel: 'Все преподаватели',
|
||||
emptyLabel: 'Нет преподавателей',
|
||||
rowLabel: 'Преподаватель',
|
||||
summaryLabel: 'Преподавателей',
|
||||
periodTitle: 'Нагрузка преподавателя по неделям',
|
||||
selectError: 'Выберите преподавателя',
|
||||
prefix: TEACHER_PREFIX
|
||||
},
|
||||
[TARGET_DEPARTMENTS]: {
|
||||
allLabel: 'Все кафедры',
|
||||
emptyLabel: 'Нет кафедр',
|
||||
rowLabel: 'Кафедра',
|
||||
summaryLabel: 'Кафедр',
|
||||
periodTitle: 'Нагрузка кафедры по неделям',
|
||||
selectError: 'Выберите кафедру',
|
||||
prefix: DEPARTMENT_PREFIX
|
||||
}
|
||||
};
|
||||
|
||||
const DAY_SHORT_LABELS = {
|
||||
1: 'Пн',
|
||||
2: 'Вт',
|
||||
@@ -43,6 +78,7 @@ const CAPACITY_BUCKETS = [
|
||||
export async function initAuditoriumWorkload() {
|
||||
const dateInput = document.getElementById('workload-date');
|
||||
const refreshButton = document.getElementById('workload-refresh');
|
||||
const targetSelect = document.getElementById('workload-target-select');
|
||||
const displaySelect = document.getElementById('workload-display-select');
|
||||
const headerRow = document.getElementById('workload-header-row');
|
||||
const tbody = document.getElementById('workload-tbody');
|
||||
@@ -55,14 +91,18 @@ export async function initAuditoriumWorkload() {
|
||||
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
|
||||
|
||||
let classrooms = [];
|
||||
let teachers = [];
|
||||
let departments = [];
|
||||
let timeSlots = [];
|
||||
let equipments = [];
|
||||
let groups = [];
|
||||
let semesters = [];
|
||||
let lessonsByRoomSlot = new Map();
|
||||
let lessonsByEntitySlot = new Map();
|
||||
let failedGroupLoads = 0;
|
||||
let currentTarget = TARGET_CLASSROOMS;
|
||||
let currentView = DISPLAY_ALL;
|
||||
let selectedRoomId = null;
|
||||
let selectedEntityId = null;
|
||||
|
||||
setDefaultDate();
|
||||
initFilters();
|
||||
@@ -84,6 +124,16 @@ export async function initAuditoriumWorkload() {
|
||||
function bindEvents() {
|
||||
refreshButton?.addEventListener('click', loadCurrentView);
|
||||
dateInput?.addEventListener('change', loadCurrentView);
|
||||
targetSelect?.addEventListener('change', () => {
|
||||
currentTarget = targetSelect.value || TARGET_CLASSROOMS;
|
||||
currentView = DISPLAY_ALL;
|
||||
selectedRoomId = null;
|
||||
selectedEntityId = null;
|
||||
populateDisplayOptions();
|
||||
applyDisplaySelection();
|
||||
syncDisplayMode();
|
||||
loadCurrentView();
|
||||
});
|
||||
displaySelect?.addEventListener('change', () => {
|
||||
applyDisplaySelection();
|
||||
syncDisplayMode();
|
||||
@@ -106,25 +156,30 @@ export async function initAuditoriumWorkload() {
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
setTableLoading('Загрузка аудиторий и расписания...');
|
||||
setTableLoading('Загрузка справочников и расписания...');
|
||||
try {
|
||||
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears] = await Promise.all([
|
||||
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears, loadedTeachers, loadedDepartments] = await Promise.all([
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/equipments'),
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/admin/calendar/years').catch(() => [])
|
||||
api.get('/api/admin/calendar/years').catch(() => []),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/departments')
|
||||
]);
|
||||
classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name));
|
||||
teachers = [...loadedTeachers].sort((a, b) => naturalCompare(userName(a), userName(b)));
|
||||
departments = [...loadedDepartments].sort((a, b) => naturalCompare(departmentName(a), departmentName(b)));
|
||||
equipments = loadedEquipments;
|
||||
groups = loadedGroups;
|
||||
semesters = (academicYears || []).flatMap(year => year.semesters || []);
|
||||
currentTarget = targetSelect?.value || TARGET_CLASSROOMS;
|
||||
populateFilters();
|
||||
populateDisplayOptions();
|
||||
applyDisplaySelection();
|
||||
syncDisplayMode();
|
||||
await loadCurrentView();
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки данных загруженности аудиторий';
|
||||
const message = error.message || 'Ошибка загрузки данных загруженности';
|
||||
setTableError(message);
|
||||
setRoomError(message);
|
||||
}
|
||||
@@ -137,10 +192,10 @@ export async function initAuditoriumWorkload() {
|
||||
if (currentView === DISPLAY_ALL) {
|
||||
await loadScheduleForSelectedDate();
|
||||
} else {
|
||||
await loadRoomWorkload();
|
||||
await loadSelectedEntityWorkload();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки загруженности аудиторий';
|
||||
const message = error.message || 'Ошибка загрузки загруженности';
|
||||
if (currentView === DISPLAY_ALL) {
|
||||
setTableError(message);
|
||||
} else {
|
||||
@@ -162,9 +217,9 @@ export async function initAuditoriumWorkload() {
|
||||
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
|
||||
|
||||
if (!groups.length) {
|
||||
lessonsByRoomSlot = new Map();
|
||||
lessonsByEntitySlot = new Map();
|
||||
renderGrid();
|
||||
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
|
||||
showAlert('workload-alert', 'Группы не созданы, загруженность пуста', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -176,7 +231,7 @@ export async function initAuditoriumWorkload() {
|
||||
.filter(response => response.status === 'fulfilled')
|
||||
.flatMap(response => response.value || []);
|
||||
|
||||
lessonsByRoomSlot = groupLessonsByRoomSlot(lessons);
|
||||
lessonsByEntitySlot = groupLessonsByEntitySlot(lessons);
|
||||
renderGrid();
|
||||
|
||||
if (failedGroupLoads > 0) {
|
||||
@@ -184,30 +239,31 @@ export async function initAuditoriumWorkload() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoomWorkload() {
|
||||
async function loadSelectedEntityWorkload() {
|
||||
const date = dateInput?.value;
|
||||
const room = selectedRoom();
|
||||
const entity = selectedEntity();
|
||||
const target = targetConfig();
|
||||
if (!date) {
|
||||
showAlert('workload-alert', 'Выберите дату', 'error');
|
||||
setRoomError('Выберите дату для расчёта двухнедельного периода');
|
||||
return;
|
||||
}
|
||||
if (!room) {
|
||||
showAlert('workload-alert', 'Выберите аудиторию', 'error');
|
||||
setRoomError('Выберите аудиторию в поле «Отображение»');
|
||||
if (!entity) {
|
||||
showAlert('workload-alert', target.selectError, 'error');
|
||||
setRoomError(`${target.selectError} в поле «Отображение»`);
|
||||
return;
|
||||
}
|
||||
|
||||
hideAlert('workload-alert');
|
||||
setRoomLoading('Загрузка расписания аудитории на две недели...');
|
||||
setRoomLoading('Загрузка расписания на две недели...');
|
||||
const range = twoWeekRange(date);
|
||||
const dates = datesBetween(range.startDate, range.endDate);
|
||||
const [slotLoad, lessonLoad] = await Promise.all([
|
||||
loadSlotsForDates(dates),
|
||||
loadRoomLessons(range, room.id)
|
||||
loadEntityLessons(range, entity.id)
|
||||
]);
|
||||
|
||||
renderRoomWorkload(room, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
|
||||
renderEntityWorkload(entity, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
|
||||
failedSlotLoads: slotLoad.failedLoads,
|
||||
failedGroupLoads: lessonLoad.failedLoads,
|
||||
parityLessons: lessonLoad.parityLessons
|
||||
@@ -236,7 +292,7 @@ export async function initAuditoriumWorkload() {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoomLessons(range, roomId) {
|
||||
async function loadEntityLessons(range, entityId) {
|
||||
if (!groups.length) {
|
||||
return {
|
||||
lessons: [],
|
||||
@@ -252,9 +308,8 @@ export async function initAuditoriumWorkload() {
|
||||
const parityLessons = responses
|
||||
.filter(response => response.status === 'fulfilled')
|
||||
.flatMap(response => response.value || []);
|
||||
const lessons = uniqueLessons(
|
||||
parityLessons.filter(lesson => sameId(lesson.classroomId, roomId))
|
||||
);
|
||||
const lessons = uniqueLessons(parityLessons)
|
||||
.filter(lesson => entityIdsForLesson(lesson).some(id => sameId(id, entityId)));
|
||||
const lessonsByDateSlot = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
const slotKey = lessonSlotKey(lesson);
|
||||
@@ -311,12 +366,11 @@ export async function initAuditoriumWorkload() {
|
||||
function populateDisplayOptions() {
|
||||
if (!displaySelect) return;
|
||||
const selectedValue = displaySelect.value;
|
||||
const target = targetConfig();
|
||||
displaySelect.innerHTML = '';
|
||||
displaySelect.append(new Option('Все аудитории', DISPLAY_ALL));
|
||||
classrooms.forEach(room => {
|
||||
const details = [room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(', ');
|
||||
const suffix = details ? ` · ${details}` : '';
|
||||
displaySelect.append(new Option(`${room.name}${suffix}`, `${ROOM_PREFIX}${room.id}`));
|
||||
displaySelect.append(new Option(target.allLabel, DISPLAY_ALL));
|
||||
allEntities().forEach(entity => {
|
||||
displaySelect.append(new Option(entityOptionLabel(entity), `${target.prefix}${entity.id}`));
|
||||
});
|
||||
if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) {
|
||||
displaySelect.value = selectedValue;
|
||||
@@ -325,19 +379,24 @@ export async function initAuditoriumWorkload() {
|
||||
|
||||
function applyDisplaySelection() {
|
||||
const value = displaySelect?.value || DISPLAY_ALL;
|
||||
if (value.startsWith(ROOM_PREFIX)) {
|
||||
currentView = 'room';
|
||||
selectedRoomId = value.slice(ROOM_PREFIX.length);
|
||||
selectedRoomId = null;
|
||||
selectedEntityId = null;
|
||||
const target = targetConfig();
|
||||
if (value.startsWith(target.prefix)) {
|
||||
currentView = 'detail';
|
||||
selectedEntityId = value.slice(target.prefix.length);
|
||||
if (currentTarget === TARGET_CLASSROOMS) {
|
||||
selectedRoomId = selectedEntityId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
currentView = DISPLAY_ALL;
|
||||
selectedRoomId = null;
|
||||
}
|
||||
|
||||
function syncDisplayMode() {
|
||||
const roomMode = currentView !== DISPLAY_ALL;
|
||||
overviewFilters.forEach(filter => {
|
||||
filter.hidden = roomMode;
|
||||
filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS;
|
||||
});
|
||||
if (overviewBlock) overviewBlock.hidden = roomMode;
|
||||
if (roomBlock) roomBlock.hidden = !roomMode;
|
||||
@@ -346,9 +405,39 @@ export async function initAuditoriumWorkload() {
|
||||
}
|
||||
}
|
||||
|
||||
function selectedRoom() {
|
||||
if (!selectedRoomId) return null;
|
||||
return classrooms.find(room => sameId(room.id, selectedRoomId)) || null;
|
||||
function selectedEntity() {
|
||||
const id = selectedEntityId || selectedRoomId;
|
||||
if (!id) return null;
|
||||
return allEntities().find(entity => sameId(entity.id, id)) || null;
|
||||
}
|
||||
|
||||
function targetConfig() {
|
||||
return WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
|
||||
}
|
||||
|
||||
function allEntities() {
|
||||
if (currentTarget === TARGET_TEACHERS) return teachers;
|
||||
if (currentTarget === TARGET_DEPARTMENTS) return departments;
|
||||
return classrooms;
|
||||
}
|
||||
|
||||
function visibleEntities() {
|
||||
const source = currentTarget === TARGET_CLASSROOMS ? filteredClassrooms() : allEntities();
|
||||
if (!selectedEntityId) return source;
|
||||
return source.filter(entity => sameId(entity.id, selectedEntityId));
|
||||
}
|
||||
|
||||
function entityOptionLabel(entity) {
|
||||
if (currentTarget === TARGET_TEACHERS) {
|
||||
const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
|
||||
return details ? `${userName(entity)} · ${details}` : userName(entity);
|
||||
}
|
||||
if (currentTarget === TARGET_DEPARTMENTS) {
|
||||
const code = entity.departmentCode ? `код ${entity.departmentCode}` : '';
|
||||
return code ? `${departmentName(entity)} · ${code}` : departmentName(entity);
|
||||
}
|
||||
const details = [entity.building, entity.floor ? `${entity.floor} этаж` : null].filter(Boolean).join(', ');
|
||||
return details ? `${entity.name} · ${details}` : entity.name;
|
||||
}
|
||||
|
||||
function buildingOptions() {
|
||||
@@ -394,7 +483,7 @@ export async function initAuditoriumWorkload() {
|
||||
function uniqueLessons(lessons) {
|
||||
const unique = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
if (!lesson.classroomId || !lesson.timeSlotId) return;
|
||||
if (!lesson.timeSlotId) return;
|
||||
const key = [
|
||||
lesson.date,
|
||||
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
|
||||
@@ -422,44 +511,58 @@ export async function initAuditoriumWorkload() {
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function groupLessonsByRoomSlot(lessons) {
|
||||
function groupLessonsByEntitySlot(lessons) {
|
||||
const grouped = new Map();
|
||||
uniqueLessons(lessons).forEach(lesson => {
|
||||
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId);
|
||||
entityIdsForLesson(lesson).forEach(entityId => {
|
||||
const key = entitySlotKey(entityId, lesson.timeSlotId);
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(lesson);
|
||||
});
|
||||
});
|
||||
grouped.forEach(items => {
|
||||
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function entityIdsForLesson(lesson) {
|
||||
if (currentTarget === TARGET_TEACHERS) {
|
||||
return lesson.teacherId ? [lesson.teacherId] : [];
|
||||
}
|
||||
if (currentTarget === TARGET_DEPARTMENTS) {
|
||||
return departmentIdsForLesson(lesson);
|
||||
}
|
||||
return lesson.classroomId ? [lesson.classroomId] : [];
|
||||
}
|
||||
|
||||
function departmentIdsForLesson(lesson) {
|
||||
const departmentId = teacherDepartmentId(lesson.teacherId);
|
||||
return departmentId == null ? [] : [departmentId];
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const visibleClassrooms = filteredClassrooms();
|
||||
const rows = visibleEntities();
|
||||
const target = targetConfig();
|
||||
renderHeader();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!visibleClassrooms.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">${escapeHtml(target.emptyLabel)}</td></tr>`;
|
||||
renderSummary(0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
visibleClassrooms.forEach(room => {
|
||||
rows.forEach(entity => {
|
||||
const tr = document.createElement('tr');
|
||||
const roomCell = document.createElement('td');
|
||||
roomCell.className = `axis-cell${room.isAvailable === false ? ' room-unavailable' : ''}`;
|
||||
roomCell.innerHTML = `
|
||||
<strong>${escapeHtml(room.name)}</strong>
|
||||
<span>${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'}</span>
|
||||
${room.building ? `<small>${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}</small>` : ''}
|
||||
`;
|
||||
tr.appendChild(roomCell);
|
||||
const entityCell = document.createElement('td');
|
||||
entityCell.className = `axis-cell${currentTarget === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`;
|
||||
entityCell.innerHTML = entityAxisHtml(entity);
|
||||
tr.appendChild(entityCell);
|
||||
|
||||
timeSlots.forEach(slot => {
|
||||
const td = document.createElement('td');
|
||||
const lessons = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || [];
|
||||
const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || [];
|
||||
td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell';
|
||||
td.innerHTML = lessons.length
|
||||
? lessons.map(renderLessonCard).join('')
|
||||
@@ -470,15 +573,16 @@ export async function initAuditoriumWorkload() {
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
const occupiedCells = countOccupiedCells(visibleClassrooms);
|
||||
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length);
|
||||
const occupiedCells = countOccupiedCells(rows);
|
||||
renderSummary(rows.length, occupiedCells, rows.length * timeSlots.length);
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const target = targetConfig();
|
||||
headerRow.innerHTML = `
|
||||
<th class="top-left-cell">
|
||||
<span class="top-label">Время</span>
|
||||
<span class="bottom-label">Аудитория</span>
|
||||
<span class="bottom-label">${escapeHtml(target.rowLabel)}</span>
|
||||
</th>
|
||||
`;
|
||||
|
||||
@@ -493,10 +597,51 @@ export async function initAuditoriumWorkload() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoomWorkload(room, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
|
||||
function entityAxisHtml(entity) {
|
||||
if (currentTarget === TARGET_TEACHERS) {
|
||||
return `
|
||||
<strong>${escapeHtml(userName(entity))}</strong>
|
||||
<span>${escapeHtml(entity.jobTitle || 'Должность не указана')}</span>
|
||||
${entity.departmentName ? `<small>${escapeHtml(entity.departmentName)}</small>` : ''}
|
||||
`;
|
||||
}
|
||||
if (currentTarget === TARGET_DEPARTMENTS) {
|
||||
return `
|
||||
<strong>${escapeHtml(departmentName(entity))}</strong>
|
||||
<span>${entity.departmentCode ? `Код: ${escapeHtml(entity.departmentCode)}` : 'Код не указан'}</span>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<strong>${escapeHtml(entity.name)}</strong>
|
||||
<span>${entity.capacity ? `${escapeHtml(entity.capacity)} мест` : 'Вместимость не указана'}</span>
|
||||
${entity.building ? `<small>${escapeHtml(entity.building)}${entity.floor ? `, ${escapeHtml(entity.floor)} этаж` : ''}</small>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function entityName(entity) {
|
||||
if (currentTarget === TARGET_TEACHERS) return userName(entity);
|
||||
if (currentTarget === TARGET_DEPARTMENTS) return departmentName(entity);
|
||||
return entity?.name || 'Аудитория без названия';
|
||||
}
|
||||
|
||||
function entitySummaryDetails(entity) {
|
||||
if (currentTarget === TARGET_TEACHERS) {
|
||||
return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
|
||||
}
|
||||
if (currentTarget === TARGET_DEPARTMENTS) {
|
||||
return entity.departmentCode ? `код ${entity.departmentCode}` : '';
|
||||
}
|
||||
return [
|
||||
entity.capacity ? `${entity.capacity} мест` : null,
|
||||
entity.building || null,
|
||||
entity.floor ? `${entity.floor} этаж` : null
|
||||
].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function renderEntityWorkload(entity, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
|
||||
const slotRows = buildSlotRows(dates, slotsByDate, lessons);
|
||||
const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons);
|
||||
renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
|
||||
renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
|
||||
|
||||
if (!slotRows.length) {
|
||||
roomTables.innerHTML = '<div class="loading-row room-workload-state">За выбранный период нет временных слотов</div>';
|
||||
@@ -506,16 +651,12 @@ export async function initAuditoriumWorkload() {
|
||||
roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot);
|
||||
}
|
||||
|
||||
function renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
|
||||
function renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
|
||||
if (!roomSummary) return;
|
||||
const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot);
|
||||
const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot);
|
||||
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
|
||||
const roomMeta = [
|
||||
room.capacity ? `${room.capacity} мест` : null,
|
||||
room.building || null,
|
||||
room.floor ? `${room.floor} этаж` : null
|
||||
].filter(Boolean).join(', ');
|
||||
const details = entitySummaryDetails(entity);
|
||||
const warnings = [];
|
||||
if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`);
|
||||
if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`);
|
||||
@@ -523,8 +664,8 @@ export async function initAuditoriumWorkload() {
|
||||
|
||||
roomSummary.innerHTML = `
|
||||
<div class="room-summary-main">
|
||||
<strong>${escapeHtml(room.name)}</strong>
|
||||
${roomMeta ? `<span>${escapeHtml(roomMeta)}</span>` : ''}
|
||||
<strong>${escapeHtml(entityName(entity))}</strong>
|
||||
${details ? `<span>${escapeHtml(details)}</span>` : ''}
|
||||
</div>
|
||||
<span class="room-summary-chip">${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))}</span>
|
||||
<span class="room-summary-chip">Занято: ${occupiedCells} из ${totalCells} (${percent}%)</span>
|
||||
@@ -555,7 +696,7 @@ export async function initAuditoriumWorkload() {
|
||||
return `
|
||||
<section class="room-week-panel">
|
||||
<div class="room-week-heading">
|
||||
<h3>Нагрузка аудитории по неделям</h3>
|
||||
<h3>${escapeHtml(targetConfig().periodTitle)}</h3>
|
||||
<span>${escapeHtml(roomWeekCaption(datesByDay))}</span>
|
||||
</div>
|
||||
<div class="workload-grid-container room-grid-container">
|
||||
@@ -852,18 +993,19 @@ export async function initAuditoriumWorkload() {
|
||||
showAlert('workload-alert', message, 'error');
|
||||
}
|
||||
|
||||
function renderSummary(roomCount, occupiedCells, totalCells) {
|
||||
function renderSummary(entityCount, occupiedCells, totalCells) {
|
||||
if (!summary) return;
|
||||
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
|
||||
const date = dateInput?.value ? formatDate(dateInput.value) : '-';
|
||||
const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-';
|
||||
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : '';
|
||||
summary.textContent = `Дата: ${date}, ${day}. Аудиторий: ${roomCount}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
|
||||
const selected = selectedEntityId ? ', выбран один объект' : '';
|
||||
summary.textContent = `Дата: ${date}, ${day}. ${targetConfig().summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
|
||||
}
|
||||
|
||||
function countOccupiedCells(rooms) {
|
||||
return rooms.reduce((count, room) => count + timeSlots.filter(slot =>
|
||||
(lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0
|
||||
function countOccupiedCells(entities) {
|
||||
return entities.reduce((count, entity) => count + timeSlots.filter(slot =>
|
||||
(lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0
|
||||
).length, 0);
|
||||
}
|
||||
|
||||
@@ -883,8 +1025,8 @@ export async function initAuditoriumWorkload() {
|
||||
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
|
||||
}
|
||||
|
||||
function roomSlotKey(roomId, timeSlotId) {
|
||||
return `${roomId}:${timeSlotId}`;
|
||||
function entitySlotKey(entityId, timeSlotId) {
|
||||
return `${entityId}:${timeSlotId}`;
|
||||
}
|
||||
|
||||
function dateSlotKey(date, slotKey) {
|
||||
@@ -905,6 +1047,22 @@ export async function initAuditoriumWorkload() {
|
||||
return room.building ? String(room.building) : NO_BUILDING;
|
||||
}
|
||||
|
||||
function teacherDepartmentId(teacherId) {
|
||||
const teacher = teachers.find(item => sameId(item.id, teacherId));
|
||||
if (!teacher) return null;
|
||||
if (teacher.departmentId != null) return teacher.departmentId;
|
||||
if (!teacher.departmentName) return null;
|
||||
return departments.find(department => departmentName(department) === teacher.departmentName)?.id ?? null;
|
||||
}
|
||||
|
||||
function userName(user) {
|
||||
return user?.fullName || user?.username || 'Преподаватель без имени';
|
||||
}
|
||||
|
||||
function departmentName(department) {
|
||||
return department?.departmentName || department?.name || 'Кафедра без названия';
|
||||
}
|
||||
|
||||
function mergeNames(first = [], second = []) {
|
||||
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function initClassrooms() {
|
||||
|
||||
async function loadClassrooms() {
|
||||
try {
|
||||
const classrooms = await api.get('/api/classrooms');
|
||||
const classrooms = await api.get('/api/classrooms?includeArchived=true');
|
||||
renderClassrooms(classrooms);
|
||||
} catch (e) {
|
||||
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
@@ -57,7 +57,7 @@ export async function initClassrooms() {
|
||||
<td>
|
||||
<div class="status-cell">
|
||||
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
|
||||
${c.isAvailable ? 'Доступна' : 'Не доступна'}
|
||||
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
|
||||
</span>
|
||||
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
|
||||
@@ -66,7 +66,9 @@ export async function initClassrooms() {
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button>
|
||||
<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Удалить</button>
|
||||
${c.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -100,11 +102,19 @@ export async function initClassrooms() {
|
||||
const btnEdit = e.target.closest('.btn-edit-classroom');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Удалить аудиторию?')) return;
|
||||
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
|
||||
try {
|
||||
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка удаления'); }
|
||||
} catch (err) { alert('Ошибка архивирования'); }
|
||||
}
|
||||
|
||||
const btnRestore = e.target.closest('.btn-restore-classroom');
|
||||
if (btnRestore) {
|
||||
try {
|
||||
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
|
||||
loadClassrooms();
|
||||
} catch (err) { alert('Ошибка восстановления аудитории'); }
|
||||
}
|
||||
|
||||
if (btnToggleStatus) {
|
||||
@@ -124,7 +134,7 @@ export async function initClassrooms() {
|
||||
async function openEditClassroomModal(id) {
|
||||
try {
|
||||
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data
|
||||
const classrooms = await api.get('/api/classrooms');
|
||||
const classrooms = await api.get('/api/classrooms?includeArchived=true');
|
||||
editingClassroomData = classrooms.find(c => c.id == id);
|
||||
|
||||
if (!editingClassroomData) return;
|
||||
|
||||
263
frontend/admin/js/views/department-workspace.js
Normal file
263
frontend/admin/js/views/department-workspace.js
Normal 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} дисциплин`;
|
||||
}
|
||||
1025
frontend/admin/js/views/schedule-view.js
Normal file
1025
frontend/admin/js/views/schedule-view.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,22 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' };
|
||||
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' };
|
||||
const ROLE_LABELS = {
|
||||
ADMIN: 'Администратор',
|
||||
EDUCATION_OFFICE: 'Учебный отдел',
|
||||
DEPARTMENT: 'Кафедра',
|
||||
SCHEDULE_VIEWER: 'Просмотр расписаний',
|
||||
TEACHER: 'Преподаватель',
|
||||
STUDENT: 'Студент'
|
||||
};
|
||||
const ROLE_BADGE = {
|
||||
ADMIN: 'badge-admin',
|
||||
EDUCATION_OFFICE: 'badge-admin',
|
||||
DEPARTMENT: 'badge-teacher',
|
||||
SCHEDULE_VIEWER: 'badge-student',
|
||||
TEACHER: 'badge-teacher',
|
||||
STUDENT: 'badge-student'
|
||||
};
|
||||
|
||||
export async function initUsers() {
|
||||
const usersTbody = document.getElementById('users-tbody');
|
||||
@@ -10,7 +24,7 @@ export async function initUsers() {
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await api.get('/api/users');
|
||||
const users = await api.get('/api/users?includeArchived=true');
|
||||
renderUsers(users);
|
||||
} catch (e) {
|
||||
usersTbody.innerHTML =
|
||||
@@ -34,7 +48,9 @@ export async function initUsers() {
|
||||
<td>${escapeHtml(u.departmentName || '-')}</td>
|
||||
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
|
||||
<td>
|
||||
<button class="btn-delete" data-id="${u.id}">Удалить</button>
|
||||
${u.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${u.id}">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -77,12 +93,24 @@ export async function initUsers() {
|
||||
const deleteBtn = e.target.closest('.btn-delete');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
if (!confirm('Удалить пользователя?')) return;
|
||||
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
|
||||
try {
|
||||
await api.delete('/api/users/' + deleteBtn.dataset.id);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка удаления');
|
||||
alert(err.message || 'Ошибка архивирования');
|
||||
}
|
||||
});
|
||||
|
||||
usersTbody.addEventListener('click', async (e) => {
|
||||
const restoreBtn = e.target.closest('.btn-restore-user');
|
||||
if (!restoreBtn) return;
|
||||
|
||||
try {
|
||||
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка восстановления');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/drop
|
||||
// Проверка авторизации
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('role');
|
||||
if (!token || role !== 'ADMIN') {
|
||||
if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ const ROUTES = {
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_TABS = {
|
||||
ADMIN: ['general', 'time-slots'],
|
||||
EDUCATION_OFFICE: ['time-slots']
|
||||
};
|
||||
const allowedTabs = new Set(ROLE_TABS[role] || []);
|
||||
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';
|
||||
|
||||
let currentTab = null;
|
||||
|
||||
// DOM-элементы
|
||||
@@ -76,6 +83,7 @@ sidebarOverlay.addEventListener('click', () => {
|
||||
|
||||
// Навигация
|
||||
navItems.forEach(item => {
|
||||
item.hidden = !allowedTabs.has(item.dataset.tab);
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const tab = item.dataset.tab;
|
||||
@@ -84,7 +92,7 @@ navItems.forEach(item => {
|
||||
});
|
||||
|
||||
async function switchTab(tab) {
|
||||
if (currentTab === tab || !ROUTES[tab]) return;
|
||||
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
|
||||
|
||||
navItems.forEach(n => n.classList.remove('active'));
|
||||
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
|
||||
@@ -114,4 +122,4 @@ async function switchTab(tab) {
|
||||
}
|
||||
|
||||
// Вкладка по умолчанию
|
||||
switchTab('general');
|
||||
switchTab(defaultTab);
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row" style="margin-bottom: 1.5rem;">
|
||||
<h2>Загруженность аудиторий</h2>
|
||||
<h2>Загруженность</h2>
|
||||
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
|
||||
<div class="form-group workload-target-control">
|
||||
<label for="workload-target-select">Что смотреть</label>
|
||||
<select id="workload-target-select" class="workload-display-select" data-native-select="true">
|
||||
<option value="classrooms">Аудитории</option>
|
||||
<option value="teachers">Преподаватели</option>
|
||||
<option value="departments">Кафедры</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group workload-display-control">
|
||||
<label for="workload-display-select">Отображение</label>
|
||||
<select id="workload-display-select" class="workload-display-select" data-native-select="true">
|
||||
@@ -74,7 +83,7 @@
|
||||
<tr id="workload-header-row">
|
||||
<th class="top-left-cell">
|
||||
<span class="top-label">Время</span>
|
||||
<span class="bottom-label">Аудитория</span>
|
||||
<span class="bottom-label">Сущность</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
89
frontend/admin/views/department-workspace.html
Normal file
89
frontend/admin/views/department-workspace.html
Normal 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; Базы данных 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>
|
||||
122
frontend/admin/views/schedule-view.html
Normal file
122
frontend/admin/views/schedule-view.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<section class="schedule-view-shell">
|
||||
<div class="card schedule-view-control-card">
|
||||
<div class="schedule-view-control-header">
|
||||
<div>
|
||||
<h2>Расписания</h2>
|
||||
<span class="muted" id="schedule-view-period-label">Период не выбран</span>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="schedule-view-load">Показать</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-view-control-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-target">Что смотреть</label>
|
||||
<select id="schedule-view-target" data-native-select="true">
|
||||
<option value="groups">Группа</option>
|
||||
<option value="teachers">Преподаватель</option>
|
||||
<option value="classrooms">Аудитория</option>
|
||||
<option value="departments">Кафедра</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
|
||||
<label for="schedule-view-group">Группа</label>
|
||||
<select id="schedule-view-group" data-native-select="true">
|
||||
<option value="">Все группы</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
|
||||
<label for="schedule-view-teacher">Преподаватель</label>
|
||||
<select id="schedule-view-teacher" data-native-select="true">
|
||||
<option value="">Все преподаватели</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
|
||||
<label for="schedule-view-classroom">Аудитория</label>
|
||||
<select id="schedule-view-classroom" data-native-select="true">
|
||||
<option value="">Все аудитории</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
|
||||
<label for="schedule-view-department">Кафедра</label>
|
||||
<select id="schedule-view-department" data-native-select="true">
|
||||
<option value="">Все кафедры</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-date">Дата в периоде</label>
|
||||
<input type="date" id="schedule-view-date">
|
||||
</div>
|
||||
|
||||
<div class="form-group schedule-view-period-control">
|
||||
<span class="form-label">Переход</span>
|
||||
<div class="schedule-view-period-buttons">
|
||||
<button type="button" class="btn-secondary" id="schedule-view-prev-period" aria-label="Предыдущий период">‹</button>
|
||||
<button type="button" class="btn-secondary" id="schedule-view-today">Сегодня</button>
|
||||
<button type="button" class="btn-secondary" id="schedule-view-next-period" aria-label="Следующий период">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="schedule-view-extra-filters">
|
||||
<summary>Дополнительные фильтры</summary>
|
||||
<div class="schedule-view-extra-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-subject">Дисциплина</label>
|
||||
<select id="schedule-view-subject" data-native-select="true">
|
||||
<option value="">Все дисциплины</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-lesson-type">Тип занятия</label>
|
||||
<select id="schedule-view-lesson-type" data-native-select="true">
|
||||
<option value="">Все типы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-view-parity">Неделя</label>
|
||||
<select id="schedule-view-parity" data-native-select="true">
|
||||
<option value="">Любая</option>
|
||||
<option value="BOTH">Каждая</option>
|
||||
<option value="ODD">Нечётная</option>
|
||||
<option value="EVEN">Чётная</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
|
||||
</div>
|
||||
|
||||
<div class="card schedule-view-results-card">
|
||||
<div class="schedule-view-results-header">
|
||||
<div>
|
||||
<h2>Найденные расписания</h2>
|
||||
<span class="muted" id="schedule-view-count">0 занятий</span>
|
||||
</div>
|
||||
<div class="schedule-view-result-actions">
|
||||
<button type="button" class="btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">‹</button>
|
||||
<span id="schedule-view-active-label">Нет выбранного расписания</span>
|
||||
<button type="button" class="btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="card schedule-view-table-card">
|
||||
<div class="schedule-view-table-header">
|
||||
<div>
|
||||
<h2 id="schedule-view-table-title">Расписание</h2>
|
||||
<span class="muted" id="schedule-view-table-caption">Выберите параметры и нажмите «Показать»</span>
|
||||
</div>
|
||||
<div class="schedule-view-day-tabs" id="schedule-view-day-tabs" hidden></div>
|
||||
</div>
|
||||
<div class="schedule-view-table-stage" id="schedule-view-tables">
|
||||
<div class="loading-row schedule-view-state">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -16,6 +16,9 @@
|
||||
<select id="new-role">
|
||||
<option value="STUDENT">Студент</option>
|
||||
<option value="TEACHER">Преподаватель</option>
|
||||
<option value="DEPARTMENT">Кафедра</option>
|
||||
<option value="EDUCATION_OFFICE">Учебный отдел</option>
|
||||
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
|
||||
<option value="ADMIN">Администратор</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
14
frontend/department/index.html
Normal file
14
frontend/department/index.html
Normal 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>
|
||||
14
frontend/edu-office/index.html
Normal file
14
frontend/edu-office/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user