From 91729cff7d3be9076a9340bcfc90faabf1da2486 Mon Sep 17 00:00:00 2001 From: Zuev Date: Tue, 19 May 2026 23:10:56 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=B5=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8E=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=20+=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=84=D0=B5=D0=B4=D1=80=D0=B0=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=D0=BC=20+=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC?= =?UTF-8?q?=D0=BE=D1=80=D1=82=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md | 642 +++++------------- .../magistr/app/config/auth/AuthContext.java | 26 + .../app/config/auth/AuthSessionService.java | 33 + .../app/config/auth/AuthenticatedUser.java | 6 + .../config/auth/AuthorizationInterceptor.java | 80 +++ .../magistr/app/config/auth/RequireRoles.java | 14 + .../config/tenant/TenantDataSourceConfig.java | 5 + .../AcademicCalendarAdminController.java | 2 + .../AcademicCalendarController.java | 2 + .../app/controller/AuthController.java | 33 +- .../app/controller/ClassroomController.java | 43 +- .../app/controller/DatabaseController.java | 3 + .../app/controller/DepartmentController.java | 36 +- .../DepartmentWorkspaceController.java | 153 +++++ .../controller/EducationFormController.java | 4 + .../app/controller/EquipmentController.java | 27 +- .../app/controller/GroupController.java | 40 +- .../app/controller/LessonTypeController.java | 3 + .../app/controller/ScheduleController.java | 26 +- .../ScheduleOverrideController.java | 148 ++++ .../ScheduleRuleAdminController.java | 22 +- .../controller/ScheduleSearchController.java | 63 ++ .../app/controller/SpecialityController.java | 35 +- .../app/controller/SubgroupController.java | 11 +- .../app/controller/SubjectController.java | 41 +- .../controller/TeacherSubjectController.java | 38 +- .../controller/TimeSlotAdminController.java | 3 + .../app/controller/UserController.java | 162 ++++- .../app/controller/WorkloadController.java | 188 +++++ .../magistr/app/dto/ClassroomResponse.java | 37 + .../app/dto/DepartmentTransferRequest.java | 10 + .../magistr/app/dto/ScheduleOverrideDto.java | 19 + .../magistr/app/dto/SubjectCommentDto.java | 13 + .../dto/TeacherDepartmentAssignmentDto.java | 16 + .../com/magistr/app/dto/UserResponse.java | 9 + .../magistr/app/dto/WorkloadSummaryDto.java | 12 + .../java/com/magistr/app/model/Classroom.java | 2 +- .../com/magistr/app/model/Department.java | 2 +- .../com/magistr/app/model/EducationForm.java | 2 +- .../java/com/magistr/app/model/Equipment.java | 2 +- .../magistr/app/model/LifecycleEntity.java | 104 +++ .../main/java/com/magistr/app/model/Role.java | 3 + .../magistr/app/model/ScheduleOverride.java | 133 ++++ .../com/magistr/app/model/ScheduleRule.java | 34 + .../com/magistr/app/model/Speciality.java | 2 +- .../magistr/app/model/SpecialtyProfile.java | 2 +- .../com/magistr/app/model/StudentGroup.java | 2 +- .../java/com/magistr/app/model/Subgroup.java | 2 +- .../java/com/magistr/app/model/Subject.java | 2 +- .../com/magistr/app/model/SubjectComment.java | 64 ++ .../model/TeacherDepartmentAssignment.java | 109 +++ .../main/java/com/magistr/app/model/User.java | 2 +- .../app/repository/ClassroomRepository.java | 3 + .../app/repository/DepartmentRepository.java | 3 + .../app/repository/EquipmentRepository.java | 3 + .../app/repository/GroupRepository.java | 4 + .../ScheduleOverrideRepository.java | 29 + .../repository/ScheduleRuleRepository.java | 3 + .../app/repository/SpecialtiesRepository.java | 3 + .../app/repository/SubgroupRepository.java | 3 + .../repository/SubjectCommentRepository.java | 10 + .../app/repository/SubjectRepository.java | 4 + ...TeacherDepartmentAssignmentRepository.java | 50 ++ .../app/repository/UserRepository.java | 6 + .../app/service/ScheduleGeneratorService.java | 14 +- .../app/service/ScheduleQueryService.java | 246 +++++++ .../main/resources/db/migration/V1__init.sql | 185 ++++- docs/API.md | 164 ++++- docs/ARCHITECTURE.md | 12 +- docs/BUSINESS_LOGIC.md | 53 +- docs/DATABASE.md | 116 +++- docs/FRONTEND.md | 65 +- docs/README.md | 2 + frontend/admin/css/auditorium-workload.css | 47 ++ frontend/admin/css/components.css | 111 +++ frontend/admin/css/layout.css | 7 +- frontend/admin/index.html | 21 +- frontend/admin/js/api.js | 14 +- frontend/admin/js/main.js | 90 ++- .../admin/js/views/auditorium-workload.js | 306 +++++++-- frontend/admin/js/views/classrooms.js | 22 +- .../admin/js/views/department-workspace.js | 263 +++++++ frontend/admin/js/views/schedule-view.js | 355 ++++++++++ frontend/admin/js/views/users.js | 40 +- frontend/admin/settings/css/layout.css | 4 + frontend/admin/settings/js/main.js | 14 +- frontend/admin/views/auditorium-workload.html | 13 +- .../admin/views/department-workspace.html | 89 +++ frontend/admin/views/schedule-view.html | 74 ++ frontend/admin/views/users.html | 13 +- frontend/department/index.html | 14 + frontend/edu-office/index.html | 14 + 93 files changed, 4264 insertions(+), 699 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/config/auth/AuthContext.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/AuthenticatedUser.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java create mode 100644 backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/WorkloadController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/DepartmentTransferRequest.java create mode 100644 backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/TeacherDepartmentAssignmentDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/WorkloadSummaryDto.java create mode 100644 backend/src/main/java/com/magistr/app/model/LifecycleEntity.java create mode 100644 backend/src/main/java/com/magistr/app/model/ScheduleOverride.java create mode 100644 backend/src/main/java/com/magistr/app/model/SubjectComment.java create mode 100644 backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java create mode 100644 backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/SubjectCommentRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java create mode 100644 backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java create mode 100644 frontend/admin/js/views/department-workspace.js create mode 100644 frontend/admin/js/views/schedule-view.js create mode 100644 frontend/admin/views/department-workspace.html create mode 100644 frontend/admin/views/schedule-view.html create mode 100644 frontend/department/index.html create mode 100644 frontend/edu-office/index.html diff --git a/AGENTS.md b/AGENTS.md index 2d3d4b2..4686c47 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,8 @@ magistr/ ├── frontend/ # Статические файлы │ ├── admin/ # Интерфейс администратора │ │ └── settings/ # Страница настроек (отдельный SPA) +│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA +│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA │ ├── teacher/ # Интерфейс преподавателя │ └── student/ # Интерфейс студента ├── docs/ # 📖 Документация проекта diff --git a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md index 66b5d9b..ed632d6 100644 --- a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md +++ b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md @@ -1,541 +1,245 @@ -# План реализации задач после созвона с научным руководителем +# Отчёт об изменениях по задачам научного руководителя Дата: 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`. diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthContext.java b/backend/src/main/java/com/magistr/app/config/auth/AuthContext.java new file mode 100644 index 0000000..4e90d69 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthContext.java @@ -0,0 +1,26 @@ +package com.magistr.app.config.auth; + +public final class AuthContext { + + private static final ThreadLocal CURRENT_USER = new ThreadLocal<>(); + + private AuthContext() { + } + + public static void setCurrentUser(AuthenticatedUser user) { + CURRENT_USER.set(user); + } + + public static AuthenticatedUser getCurrentUser() { + return CURRENT_USER.get(); + } + + public static Long currentUserId() { + AuthenticatedUser user = getCurrentUser(); + return user == null ? null : user.id(); + } + + public static void clear() { + CURRENT_USER.remove(); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java b/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java new file mode 100644 index 0000000..3ccf675 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java @@ -0,0 +1,33 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.User; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class AuthSessionService { + + private final Map sessions = new ConcurrentHashMap<>(); + + public String createSession(User user) { + String token = UUID.randomUUID().toString(); + sessions.put(token, new AuthenticatedUser( + user.getId(), + user.getUsername(), + user.getRole(), + user.getDepartmentId() + )); + return token; + } + + public Optional findByToken(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + return Optional.ofNullable(sessions.get(token)); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthenticatedUser.java b/backend/src/main/java/com/magistr/app/config/auth/AuthenticatedUser.java new file mode 100644 index 0000000..1530b9d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthenticatedUser.java @@ -0,0 +1,6 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.Role; + +public record AuthenticatedUser(Long id, String username, Role role, Long departmentId) { +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java new file mode 100644 index 0000000..d07ae1d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java @@ -0,0 +1,80 @@ +package com.magistr.app.config.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.magistr.app.model.Role; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; + +@Component +public class AuthorizationInterceptor implements HandlerInterceptor { + + private final AuthSessionService sessionService; + private final ObjectMapper objectMapper; + + public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) { + this.sessionService = sessionService; + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { + if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + if (request.getRequestURI().equals("/api/auth/login")) { + return true; + } + + String token = bearerToken(request.getHeader("Authorization")); + AuthenticatedUser user = sessionService.findByToken(token).orElse(null); + if (user == null) { + writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему"); + return false; + } + + AuthContext.setCurrentUser(user); + RequireRoles roles = resolveRoles(handler); + if (roles != null && Arrays.stream(roles.value()).noneMatch(role -> role == user.role())) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, "Недостаточно прав для выполнения операции"); + return false; + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + AuthContext.clear(); + } + + private RequireRoles resolveRoles(Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return null; + } + RequireRoles methodRoles = handlerMethod.getMethodAnnotation(RequireRoles.class); + if (methodRoles != null) { + return methodRoles; + } + return handlerMethod.getBeanType().getAnnotation(RequireRoles.class); + } + + private String bearerToken(String header) { + if (header == null || !header.startsWith("Bearer ")) { + return null; + } + return header.substring("Bearer ".length()).trim(); + } + + private void writeError(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getOutputStream(), Map.of("message", message)); + } +} diff --git a/backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java b/backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java new file mode 100644 index 0000000..d3844b4 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java @@ -0,0 +1,14 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.Role; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RequireRoles { + Role[] value(); +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java index 71ced9e..767f897 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java @@ -2,6 +2,7 @@ package com.magistr.app.config.tenant; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.magistr.app.config.auth.AuthorizationInterceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -123,6 +124,9 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { @org.springframework.beans.factory.annotation.Autowired private TenantRoutingDataSource tenantRoutingDataSource; + @org.springframework.beans.factory.annotation.Autowired + private AuthorizationInterceptor authorizationInterceptor; + @Bean public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) { TenantInterceptor interceptor = new TenantInterceptor(); @@ -134,6 +138,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { // Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB) registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**"); + registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**"); } private List loadTenantsFromFile() { diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java index 8d15db0..d4116b7 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java @@ -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; diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 4352867..478b952 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -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; diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 7ef5b69..5e8abd8 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -2,6 +2,8 @@ package com.magistr.app.controller; import com.magistr.app.dto.LoginRequest; import com.magistr.app.dto.LoginResponse; +import com.magistr.app.config.auth.AuthSessionService; +import com.magistr.app.config.auth.AuthContext; import com.magistr.app.model.User; import com.magistr.app.repository.UserRepository; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -10,7 +12,6 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Optional; -import java.util.UUID; @RestController @RequestMapping("/api/auth") @@ -18,16 +19,23 @@ public class AuthController { private final UserRepository userRepository; private final BCryptPasswordEncoder passwordEncoder; + private final AuthSessionService sessionService; private static final Map ROLE_REDIRECTS = Map.of( "ADMIN", "/admin/", + "EDUCATION_OFFICE", "/admin/#schedule-view", + "DEPARTMENT", "/admin/#department-workspace", + "SCHEDULE_VIEWER", "/admin/#schedule-view", "TEACHER", "/teacher/", "STUDENT", "/student/" ); - public AuthController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { + public AuthController(UserRepository userRepository, + BCryptPasswordEncoder passwordEncoder, + AuthSessionService sessionService) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + this.sessionService = sessionService; } @PostMapping("/login") @@ -42,11 +50,30 @@ public class AuthController { } User user = userOpt.get(); - String token = UUID.randomUUID().toString(); + if (user.isArchivedRecord()) { + return ResponseEntity + .status(401) + .body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null)); + } + String token = sessionService.createSession(user); String roleName = user.getRole().name(); String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); Long departmentId = user.getDepartmentId(); return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId())); } + + @GetMapping("/me") + public ResponseEntity me() { + var user = AuthContext.getCurrentUser(); + if (user == null) { + return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему")); + } + return ResponseEntity.ok(Map.of( + "userId", user.id(), + "username", user.username(), + "role", user.role().name(), + "departmentId", user.departmentId() + )); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java index ae38ebb..89c76eb 100755 --- a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java +++ b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java @@ -1,9 +1,12 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.ClassroomRequest; import com.magistr.app.dto.ClassroomResponse; import com.magistr.app.model.Classroom; import com.magistr.app.model.Equipment; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.repository.ClassroomRepository; import com.magistr.app.repository.EquipmentRepository; import org.springframework.http.ResponseEntity; @@ -15,6 +18,7 @@ import java.util.Optional; @RestController @RequestMapping("/api/classrooms") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public class ClassroomController { private final ClassroomRepository classroomRepository; @@ -26,8 +30,12 @@ public class ClassroomController { } @GetMapping - public List getAllClassrooms() { - return classroomRepository.findAll().stream() + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List getAllClassrooms(@RequestParam(defaultValue = "false") boolean includeArchived) { + List classrooms = includeArchived + ? classroomRepository.findAll() + : classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + return classrooms.stream() .map(this::mapToResponse) .toList(); } @@ -52,7 +60,9 @@ public class ClassroomController { classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true); if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) { - List equipments = equipmentRepository.findAllById(request.getEquipmentIds()); + List equipments = equipmentRepository.findAllById(request.getEquipmentIds()).stream() + .filter(Equipment::isActiveRecord) + .toList(); classroom.setEquipments(new java.util.HashSet<>(equipments)); } @@ -96,6 +106,9 @@ public class ClassroomController { if (request.getEquipmentIds() != null) { List 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 classroomOpt = classroomRepository.findById(id); + if (classroomOpt.isEmpty()) { return ResponseEntity.notFound().build(); } - classroomRepository.deleteById(id); - return ResponseEntity.ok(Map.of("message", "Аудитория удалена")); + Classroom classroom = classroomOpt.get(); + classroom.archive("Выведена из эксплуатации"); + classroom.setIsAvailable(false); + classroomRepository.save(classroom); + return ResponseEntity.ok(Map.of("message", "Аудитория выведена из эксплуатации")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreClassroom(@PathVariable Long id) { + Optional classroomOpt = classroomRepository.findById(id); + if (classroomOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + Classroom classroom = classroomOpt.get(); + classroom.restore(); + classroom.setIsAvailable(true); + classroomRepository.save(classroom); + return ResponseEntity.ok(mapToResponse(classroom)); } private ClassroomResponse mapToResponse(Classroom c) { return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(), + c.getStatus(), c.getArchivedAt(), c.getArchiveReason(), new java.util.ArrayList<>(c.getEquipments())); } diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java index adb5727..27e2173 100755 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java @@ -1,10 +1,12 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.tenant.ConfigMapUpdater; import com.magistr.app.config.tenant.TenantConfig; import com.magistr.app.config.tenant.TenantConfigWatcher; import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.model.Role; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,6 +26,7 @@ import java.util.Map; */ @RestController @RequestMapping("/api/database") +@RequireRoles({Role.ADMIN}) public class DatabaseController { private final TenantRoutingDataSource routingDataSource; diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java index 3569f96..dfa9a96 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java @@ -1,8 +1,11 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateDepartmentRequest; import com.magistr.app.dto.DepartmentResponse; import com.magistr.app.model.Department; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.repository.DepartmentRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,6 +18,7 @@ import java.util.Map; @RestController @RequestMapping("/api/departments") +@RequireRoles({Role.ADMIN}) public class DepartmentController { private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class); @@ -26,10 +30,13 @@ public class DepartmentController { } @GetMapping - public List getAllDepartments() { + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List getAllDepartments(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение списка кафедр"); try { - List departments = departmentRepository.findAll(); + List departments = includeArchived + ? departmentRepository.findAll() + : departmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = departments.stream() .map( d -> new Department( d.getId(), @@ -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() + )); } } diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java new file mode 100644 index 0000000..a59fb43 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -0,0 +1,153 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.CreateSubjectRequest; +import com.magistr.app.dto.SubjectCommentDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.SubjectCommentRepository; +import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.ScheduleQueryService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/department") +@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) +public class DepartmentWorkspaceController { + + private final SubjectRepository subjectRepository; + private final SubjectCommentRepository subjectCommentRepository; + private final UserRepository userRepository; + private final ScheduleQueryService scheduleQueryService; + + public DepartmentWorkspaceController(SubjectRepository subjectRepository, + SubjectCommentRepository subjectCommentRepository, + UserRepository userRepository, + ScheduleQueryService scheduleQueryService) { + this.subjectRepository = subjectRepository; + this.subjectCommentRepository = subjectCommentRepository; + this.userRepository = userRepository; + this.scheduleQueryService = scheduleQueryService; + } + + @GetMapping("/subjects") + public List getSubjects(@RequestParam(required = false) Long departmentId) { + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + return subjectRepository.findByDepartmentIdAndStatusNot(effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); + } + + @PostMapping("/subjects/import") + public ResponseEntity importSubjects(@RequestBody List requests) { + if (requests == null || requests.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Передайте список дисциплин для загрузки")); + } + Long requestedDepartmentId = requests.stream() + .map(CreateSubjectRequest::getDepartmentId) + .filter(id -> id != null && id > 0) + .findFirst() + .orElse(null); + Long departmentId = effectiveDepartmentId(requestedDepartmentId); + if (departmentId == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна")); + } + List saved = requests.stream() + .filter(request -> request.getName() != null && !request.getName().isBlank()) + .map(request -> { + Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new); + subject.setName(request.getName().trim()); + subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim()); + subject.setDepartmentId(departmentId); + if (subject.isArchivedRecord()) { + subject.restore(); + } + return subjectRepository.save(subject); + }) + .toList(); + return ResponseEntity.ok(Map.of( + "message", "Дисциплины загружены", + "count", saved.size() + )); + } + + @GetMapping("/subjects/{subjectId}/comments") + public ResponseEntity getComments(@PathVariable Long subjectId) { + Subject subject = subjectRepository.findById(subjectId).orElse(null); + if (subject == null || !canAccessDepartment(subject.getDepartmentId())) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(subjectCommentRepository.findBySubjectIdOrderByCreatedAtDesc(subjectId).stream() + .map(this::toCommentDto) + .toList()); + } + + @PostMapping("/subjects/{subjectId}/comments") + public ResponseEntity addComment(@PathVariable Long subjectId, @RequestBody Map request) { + Subject subject = subjectRepository.findById(subjectId).orElse(null); + if (subject == null || !canAccessDepartment(subject.getDepartmentId())) { + return ResponseEntity.notFound().build(); + } + String commentText = request == null ? null : request.get("comment"); + if (commentText == null || commentText.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен")); + } + SubjectComment comment = new SubjectComment(); + comment.setSubject(subject); + comment.setComment(commentText.trim()); + Long currentUserId = AuthContext.currentUserId(); + if (currentUserId != null) { + userRepository.findById(currentUserId).ifPresent(comment::setAuthor); + } + return ResponseEntity.ok(toCommentDto(subjectCommentRepository.save(comment))); + } + + @GetMapping("/teachers") + public List getTeachers(@RequestParam(required = false) Long departmentId) { + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); + } + + @GetMapping("/schedule") + public ResponseEntity getSchedule( + @RequestParam(required = false) Long departmentId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + Long effectiveDepartmentId = effectiveDepartmentId(departmentId); + return ResponseEntity.ok(scheduleQueryService.search(null, null, null, effectiveDepartmentId, + null, null, null, null, startDate, endDate)); + } + + private SubjectCommentDto toCommentDto(SubjectComment comment) { + User author = comment.getAuthor(); + return new SubjectCommentDto( + comment.getId(), + comment.getSubject().getId(), + author == null ? null : author.getId(), + author == null ? null : author.getFullName(), + comment.getComment(), + comment.getCreatedAt() + ); + } + + private Long effectiveDepartmentId(Long requestedDepartmentId) { + var user = AuthContext.getCurrentUser(); + if (user != null && user.role() == Role.DEPARTMENT) { + return user.departmentId(); + } + return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId; + } + + private boolean canAccessDepartment(Long departmentId) { + var user = AuthContext.getCurrentUser(); + return user == null + || user.role() == Role.ADMIN + || (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId())); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java index b8885ce..31306db 100755 --- a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java +++ b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java @@ -1,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> getAll() { return educationFormRepository.findAllByOrderByNameAsc().stream() .map(ef -> Map.of("id", ef.getId(), "name", ef.getName())) diff --git a/backend/src/main/java/com/magistr/app/controller/EquipmentController.java b/backend/src/main/java/com/magistr/app/controller/EquipmentController.java index f305efb..dcbb582 100755 --- a/backend/src/main/java/com/magistr/app/controller/EquipmentController.java +++ b/backend/src/main/java/com/magistr/app/controller/EquipmentController.java @@ -1,5 +1,8 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.model.Equipment; import com.magistr.app.repository.EquipmentRepository; import org.springframework.http.ResponseEntity; @@ -10,6 +13,7 @@ import java.util.Map; @RestController @RequestMapping("/api/equipments") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) public class EquipmentController { private final EquipmentRepository equipmentRepository; @@ -19,8 +23,8 @@ public class EquipmentController { } @GetMapping - public List getAllEquipments() { - return equipmentRepository.findAll(); + public List getAllEquipments(@RequestParam(defaultValue = "false") boolean includeArchived) { + return includeArchived ? equipmentRepository.findAll() : equipmentRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); } @PostMapping @@ -42,10 +46,23 @@ public class EquipmentController { @DeleteMapping("/{id}") public ResponseEntity deleteEquipment(@PathVariable Long id) { - if (!equipmentRepository.existsById(id)) { + Equipment equipment = equipmentRepository.findById(id).orElse(null); + if (equipment == null) { return ResponseEntity.notFound().build(); } - equipmentRepository.deleteById(id); - return ResponseEntity.ok(Map.of("message", "Оборудование удалено")); + equipment.archive("Оборудование архивировано"); + equipmentRepository.save(equipment); + return ResponseEntity.ok(Map.of("message", "Оборудование архивировано")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreEquipment(@PathVariable Long id) { + Equipment equipment = equipmentRepository.findById(id).orElse(null); + if (equipment == null) { + return ResponseEntity.notFound().build(); + } + equipment.restore(); + equipmentRepository.save(equipment); + return ResponseEntity.ok(equipment); } } diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 8462ce7..1d31f25 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -1,11 +1,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 getAllGroups() { + public List getAllGroups(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех групп"); try { - List groups = groupRepository.findAll(); + List groups = includeArchived + ? groupRepository.findAll() + : groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = groups.stream() .map(this::mapToResponse) @@ -78,7 +84,7 @@ public class GroupController { public ResponseEntity getGroupsByDepartmentId(@PathVariable Long departmentId) { logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); try { - List groups = groupRepository.findByDepartmentId(departmentId); + List groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); if(groups.isEmpty()) { logger.info("Группы для кафедры с ID - {} не найдены", departmentId); @@ -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)) { diff --git a/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java b/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java index af69050..b55b973 100644 --- a/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java +++ b/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java @@ -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; diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java index 58cea9d..defcf6b 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java @@ -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 lessons = groupId != null - ? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate) - : scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); + List lessons = scheduleQueryService.search( + groupId, + teacherId, + null, + null, + null, + null, + null, + null, + startDate, + endDate + ); return ResponseEntity.ok(lessons); } catch (IllegalArgumentException e) { logger.info("Ошибка запроса динамического расписания: {}", e.getMessage()); diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java new file mode 100644 index 0000000..12c2f78 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java @@ -0,0 +1,148 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.ScheduleOverrideDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/edu-office/schedule/overrides") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE}) +public class ScheduleOverrideController { + + private final ScheduleOverrideRepository scheduleOverrideRepository; + private final ScheduleRuleSlotRepository scheduleRuleSlotRepository; + private final TimeSlotRepository timeSlotRepository; + private final ClassroomRepository classroomRepository; + private final UserRepository userRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleOverrideController(ScheduleOverrideRepository scheduleOverrideRepository, + ScheduleRuleSlotRepository scheduleRuleSlotRepository, + TimeSlotRepository timeSlotRepository, + ClassroomRepository classroomRepository, + UserRepository userRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleOverrideRepository = scheduleOverrideRepository; + this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; + this.timeSlotRepository = timeSlotRepository; + this.classroomRepository = classroomRepository; + this.userRepository = userRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getAll() { + return scheduleOverrideRepository.findAll().stream() + .map(this::toDto) + .toList(); + } + + @PostMapping + public ResponseEntity create(@RequestBody ScheduleOverrideDto request) { + return save(null, request); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody ScheduleOverrideDto request) { + if (!scheduleOverrideRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return save(id, request); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!scheduleOverrideRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + scheduleOverrideRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Точечное изменение расписания удалено")); + } + + private ResponseEntity save(Long id, ScheduleOverrideDto request) { + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + ScheduleOverride override = id == null + ? scheduleOverrideRepository + .findByBaseRuleSlotIdAndLessonDate(request.baseRuleSlotId(), request.lessonDate()) + .orElseGet(ScheduleOverride::new) + : scheduleOverrideRepository.findById(id).orElseGet(ScheduleOverride::new); + + override.setBaseRuleSlot(scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).orElseThrow()); + override.setLessonDate(request.lessonDate()); + override.setAction(request.action()); + override.setNewTimeSlot(request.newTimeSlotId() == null ? null : timeSlotRepository.findById(request.newTimeSlotId()).orElse(null)); + override.setNewClassroom(request.newClassroomId() == null ? null : classroomRepository.findById(request.newClassroomId()).orElse(null)); + override.setNewTeacher(request.newTeacherId() == null ? null : userRepository.findById(request.newTeacherId()).orElse(null)); + override.setNewLessonFormat(clean(request.newLessonFormat())); + override.setComment(clean(request.comment())); + override.setCreatedBy(AuthContext.currentUserId()); + + ScheduleOverride saved = scheduleOverrideRepository.save(override); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } + + private String validate(ScheduleOverrideDto request) { + if (request == null) { + return "Передайте данные изменения расписания"; + } + if (request.baseRuleSlotId() == null || scheduleRuleSlotRepository.findById(request.baseRuleSlotId()).isEmpty()) { + return "Базовый слот расписания не найден"; + } + if (request.lessonDate() == null) { + return "Дата пары обязательна"; + } + if (request.action() == null || !List.of("MOVE", "CANCEL", "REPLACE").contains(request.action())) { + return "Недопустимое действие изменения расписания"; + } + if (request.newClassroomId() != null) { + Classroom classroom = classroomRepository.findById(request.newClassroomId()).orElse(null); + if (classroom == null || classroom.isArchivedRecord() || Boolean.FALSE.equals(classroom.getIsAvailable())) { + return "Активная аудитория не найдена"; + } + } + if (request.newTeacherId() != null) { + User teacher = userRepository.findById(request.newTeacherId()).orElse(null); + if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) { + return "Активный преподаватель не найден"; + } + } + if (request.newTimeSlotId() != null && timeSlotRepository.findById(request.newTimeSlotId()).isEmpty()) { + return "Временной слот не найден"; + } + return null; + } + + private ScheduleOverrideDto toDto(ScheduleOverride override) { + return new ScheduleOverrideDto( + override.getId(), + override.getBaseRuleSlot().getId(), + override.getLessonDate(), + override.getAction(), + override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(), + override.getNewClassroom() == null ? null : override.getNewClassroom().getId(), + override.getNewTeacher() == null ? null : override.getNewTeacher().getId(), + override.getNewLessonFormat(), + override.getComment(), + override.getCreatedBy(), + override.getCreatedAt() + ); + } + + private String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index 516a73b..b0624b4 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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 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); diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java new file mode 100644 index 0000000..5d5c820 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java @@ -0,0 +1,63 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.model.Role; +import com.magistr.app.service.ScheduleQueryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.Map; + +@RestController +@RequestMapping("/api/schedule/search") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER, Role.TEACHER, Role.STUDENT}) +public class ScheduleSearchController { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleSearchController.class); + + private final ScheduleQueryService scheduleQueryService; + + public ScheduleSearchController(ScheduleQueryService scheduleQueryService) { + this.scheduleQueryService = scheduleQueryService; + } + + @GetMapping + public ResponseEntity search( + @RequestParam(required = false) Long groupId, + @RequestParam(required = false) Long teacherId, + @RequestParam(required = false) Long classroomId, + @RequestParam(required = false) Long departmentId, + @RequestParam(required = false) Long subjectId, + @RequestParam(required = false) Long lessonTypeId, + @RequestParam(required = false) Long timeSlotId, + @RequestParam(required = false) String parity, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}", + groupId, teacherId, classroomId, departmentId, startDate, endDate); + try { + return ResponseEntity.ok(scheduleQueryService.search( + groupId, + teacherId, + classroomId, + departmentId, + subjectId, + lessonTypeId, + timeSlotId, + parity, + startDate, + endDate + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } catch (Exception e) { + logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания")); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java index a601627..ed2d9d4 100644 --- a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java +++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java @@ -1,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 getAllSpecialties() { + public List getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение списка специальностей"); try { - List specialities = specialtiesRepository.findAll(); + List specialities = includeArchived + ? specialtiesRepository.findAll() + : specialtiesRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = specialities.stream() .map( s -> new Speciality( s.getId(), @@ -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") diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java index 7488d52..498821b 100644 --- a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -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", "Подгруппа используется в расписании")); } diff --git a/backend/src/main/java/com/magistr/app/controller/SubjectController.java b/backend/src/main/java/com/magistr/app/controller/SubjectController.java index 078c891..5674bb5 100755 --- a/backend/src/main/java/com/magistr/app/controller/SubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java @@ -1,7 +1,10 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.SubjectResponse; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; import com.magistr.app.model.Subject; import com.magistr.app.repository.SubjectRepository; import org.slf4j.Logger; @@ -16,6 +19,7 @@ import java.util.Map; @RestController @RequestMapping("/api/subjects") +@RequireRoles({Role.ADMIN}) public class SubjectController { private static final Logger logger = LoggerFactory.getLogger(SubjectController.class); @@ -27,10 +31,13 @@ public class SubjectController { } @GetMapping - public List getAllSubjects() { + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) + public List getAllSubjects(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех дисциплин"); try { - List subjects = subjectRepository.findAll(); + List subjects = includeArchived + ? subjectRepository.findAll() + : subjectRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = subjects.stream() .map(s -> new Subject( s.getId(), @@ -48,10 +55,11 @@ public class SubjectController { } @GetMapping("/{departmentId}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getSubjectsByDepartmentId(@PathVariable Long departmentId) { logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId); try{ - List subjects = subjectRepository.findByDepartmentId(departmentId); + List subjects = subjectRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); if(subjects.isEmpty()){ logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId); @@ -121,12 +129,31 @@ public class SubjectController { @DeleteMapping("/{id}") public ResponseEntity deleteSubject(@PathVariable Long id) { logger.info("Получен запрос на удаление дисциплины с ID: {}", id); - if (!subjectRepository.existsById(id)) { + Subject subject = subjectRepository.findById(id).orElse(null); + if (subject == null) { logger.info("Дисциплина с ID - {} не найдена", id); return ResponseEntity.notFound().build(); } - subjectRepository.deleteById(id); - logger.info("Дисциплина с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Дисциплина удалена")); + subject.archive("Дисциплина архивирована"); + subjectRepository.save(subject); + logger.info("Дисциплина с ID - {} успешно архивирована", id); + return ResponseEntity.ok(Map.of("message", "Дисциплина архивирована")); + } + + @PostMapping("/{id}/restore") + @RequireRoles({Role.ADMIN}) + public ResponseEntity restoreSubject(@PathVariable Long id) { + Subject subject = subjectRepository.findById(id).orElse(null); + if (subject == null) { + return ResponseEntity.notFound().build(); + } + subject.restore(); + subjectRepository.save(subject); + return ResponseEntity.ok(new SubjectResponse( + subject.getId(), + subject.getName(), + subject.getCode(), + subject.getDepartmentId() + )); } } diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java index 022006f..4ad3985 100755 --- a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java +++ b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java @@ -1,8 +1,13 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.TeacherSubjectResponse; +import com.magistr.app.model.Role; +import com.magistr.app.model.Subject; import com.magistr.app.model.TeacherSubject; import com.magistr.app.model.TeacherSubjectId; +import com.magistr.app.model.User; import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.TeacherSubjectRepository; import com.magistr.app.repository.UserRepository; @@ -14,6 +19,7 @@ import java.util.Map; @RestController @RequestMapping("/api/teacher-subjects") +@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) public class TeacherSubjectController { private final TeacherSubjectRepository teacherSubjectRepository; @@ -31,6 +37,7 @@ public class TeacherSubjectController { @GetMapping public List getAll() { return teacherSubjectRepository.findAll().stream() + .filter(ts -> canManage(ts.getUser(), ts.getSubject())) .map(ts -> new TeacherSubjectResponse( ts.getUserId(), ts.getUser().getUsername(), @@ -48,12 +55,17 @@ public class TeacherSubjectController { if (userId == null || subjectId == null) { return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны")); } - if (!userRepository.existsById(userId)) { + User user = userRepository.findById(userId).orElse(null); + if (user == null || user.getRole() != Role.TEACHER || user.isArchivedRecord()) { return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден")); } - if (!subjectRepository.existsById(subjectId)) { + Subject subject = subjectRepository.findById(subjectId).orElse(null); + if (subject == null || subject.isArchivedRecord()) { return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена")); } + if (!canManage(user, subject)) { + return ResponseEntity.status(403).body(Map.of("message", "Кафедра может связывать только своих преподавателей и свои дисциплины")); + } TeacherSubjectId id = new TeacherSubjectId(userId, subjectId); if (teacherSubjectRepository.existsById(id)) { @@ -76,10 +88,30 @@ public class TeacherSubjectController { } TeacherSubjectId id = new TeacherSubjectId(userId, subjectId); - if (!teacherSubjectRepository.existsById(id)) { + TeacherSubject teacherSubject = teacherSubjectRepository.findById(id).orElse(null); + if (teacherSubject == null) { return ResponseEntity.notFound().build(); } + if (!canManage(teacherSubject.getUser(), teacherSubject.getSubject())) { + return ResponseEntity.status(403).body(Map.of("message", "Кафедра может удалять только свои привязки")); + } teacherSubjectRepository.deleteById(id); return ResponseEntity.ok(Map.of("message", "Привязка удалена")); } + + private boolean canManage(User teacher, Subject subject) { + var currentUser = AuthContext.getCurrentUser(); + if (currentUser == null || currentUser.role() == Role.ADMIN) { + return true; + } + if (currentUser.role() != Role.DEPARTMENT) { + return false; + } + Long departmentId = currentUser.departmentId(); + return departmentId != null + && teacher != null + && subject != null + && departmentId.equals(teacher.getDepartmentId()) + && departmentId.equals(subject.getDepartmentId()); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java index 5fd72a5..efab2bb 100644 --- a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java @@ -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"; diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index 5f62585..e034fd2 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -1,43 +1,61 @@ package com.magistr.app.controller; +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateUserRequest; +import com.magistr.app.dto.DepartmentTransferRequest; +import com.magistr.app.dto.TeacherDepartmentAssignmentDto; import com.magistr.app.dto.UserResponse; import com.magistr.app.model.Department; +import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.Role; +import com.magistr.app.model.TeacherDepartmentAssignment; import com.magistr.app.model.User; import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/users") +@RequireRoles({Role.ADMIN}) public class UserController { private static final Logger logger = LoggerFactory.getLogger(UserController.class); private final UserRepository userRepository; private final DepartmentRepository departmentRepository; + private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository; private final BCryptPasswordEncoder passwordEncoder; - public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) { + public UserController(UserRepository userRepository, + BCryptPasswordEncoder passwordEncoder, + DepartmentRepository departmentRepository, + TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.departmentRepository = departmentRepository; + this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository; } @GetMapping - public List getAllUsers() { + public List getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) { logger.info("Получен запрос на получение всех пользователей"); try { - List users = userRepository.findAll(); + List users = includeArchived + ? userRepository.findAll() + : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); List response = users.stream() .map(u -> { @@ -45,13 +63,15 @@ public class UserController { .map(Department::getDepartmentName) .orElse("Неизвестно"); - return new UserResponse( + UserResponse userResponse = new UserResponse( u.getId(), u.getUsername(), u.getRole().name(), u.getFullName(), u.getJobTitle(), departmentName); + userResponse.setStatus(u.getStatus()); + return userResponse; }) .toList(); logger.info("Получено {} пользователей", response.size()); @@ -64,11 +84,12 @@ public class UserController { } @GetMapping("/teachers") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public List getTeachers() { logger.info("Запрос на получение пользователей с ролью 'Преподаватель'"); try { - List users = userRepository.findByRole(Role.TEACHER); + List users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED); List response = users.stream() .map(u -> { @@ -76,13 +97,15 @@ public class UserController { .map(Department::getDepartmentName) .orElse("Неизвестно"); - return new UserResponse( + UserResponse userResponse = new UserResponse( u.getId(), u.getUsername(), u.getRole().name(), u.getFullName(), u.getJobTitle(), departmentName); + userResponse.setStatus(u.getStatus()); + return userResponse; }) .toList(); logger.info("Получено {} преподавателей", response.size()); @@ -94,10 +117,15 @@ public class UserController { } @GetMapping("/teachers/{departmentId}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) public ResponseEntity getTeachersByDepartmentId(@PathVariable Long departmentId){ logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId); try { - List users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId); + List users = userRepository.findByRoleAndDepartmentIdAndStatusNot( + Role.TEACHER, + departmentId, + LifecycleEntity.STATUS_ARCHIVED + ); if (users.isEmpty()) { logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId); @@ -178,6 +206,19 @@ public class UserController { user.setJobTitle(request.getJobTitle()); user.setDepartmentId(request.getDepartmentId()); userRepository.save(user); + if (role == Role.TEACHER) { + Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null); + if (department != null) { + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(user); + assignment.setDepartment(department); + assignment.setValidFrom(LocalDate.now()); + assignment.setPrimaryAssignment(true); + assignment.setComment("Начальная кафедра при создании пользователя"); + assignment.setCreatedBy(AuthContext.currentUserId()); + teacherDepartmentAssignmentRepository.save(assignment); + } + } logger.info("Пользователь успешно создан с ID: {}", user.getId()); @@ -192,12 +233,111 @@ public class UserController { @DeleteMapping("/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { logger.info("Получен запрос на удаление пользователя с ID: {}", id); - if (!userRepository.existsById(id)) { + User user = userRepository.findById(id).orElse(null); + if (user == null) { logger.info("Пользователь с ID - {} не найден", id); return ResponseEntity.notFound().build(); } - userRepository.deleteById(id); - logger.info("Пользователь с ID - {} успешно удалён", id); - return ResponseEntity.ok(Map.of("message", "Пользователь удалён")); + user.archive("Пользователь архивирован"); + userRepository.save(user); + logger.info("Пользователь с ID - {} успешно архивирован", id); + return ResponseEntity.ok(Map.of("message", "Пользователь архивирован")); + } + + @PostMapping("/{id}/restore") + public ResponseEntity restoreUser(@PathVariable Long id) { + User user = userRepository.findById(id).orElse(null); + if (user == null) { + return ResponseEntity.notFound().build(); + } + user.restore(); + userRepository.save(user); + return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), + user.getFullName(), user.getJobTitle(), user.getDepartmentId())); + } + + @GetMapping("/{id}/department-history") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT}) + public ResponseEntity getDepartmentHistory(@PathVariable Long id) { + if (!userRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream() + .map(this::toAssignmentDto) + .toList()); + } + + @PostMapping("/{id}/department-transfer") + @Transactional + public ResponseEntity transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) { + User teacher = userRepository.findById(id).orElse(null); + if (teacher == null || teacher.getRole() != Role.TEACHER) { + return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден")); + } + if (teacher.isArchivedRecord()) { + return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести")); + } + if (request.departmentId() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна")); + } + Department department = departmentRepository.findById(request.departmentId()).orElse(null); + if (department == null || department.isArchivedRecord()) { + return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена")); + } + LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom(); + + teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id) + .ifPresent(current -> { + if (current.getValidFrom().isBefore(validFrom)) { + current.setValidTo(validFrom.minusDays(1)); + teacherDepartmentAssignmentRepository.save(current); + } else { + teacherDepartmentAssignmentRepository.delete(current); + } + }); + + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(teacher); + assignment.setDepartment(department); + assignment.setValidFrom(validFrom); + assignment.setPrimaryAssignment(true); + assignment.setComment(request.comment()); + assignment.setCreatedBy(AuthContext.currentUserId()); + teacherDepartmentAssignmentRepository.save(assignment); + + teacher.setDepartmentId(department.getId()); + userRepository.save(teacher); + + return ResponseEntity.ok(toAssignmentDto(assignment)); + } + + @GetMapping("/teachers/by-department/{departmentId}") + @RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT}) + public ResponseEntity getTeachersByDepartmentAtDate(@PathVariable Long departmentId, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + LocalDate effectiveDate = date == null ? LocalDate.now() : date; + return ResponseEntity.ok(teacherDepartmentAssignmentRepository + .findDepartmentTeachersAtDate(departmentId, effectiveDate) + .stream() + .map(TeacherDepartmentAssignment::getTeacher) + .filter(User::isActiveRecord) + .map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), + user.getFullName(), user.getJobTitle(), user.getDepartmentId())) + .toList()); + } + + private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) { + return new TeacherDepartmentAssignmentDto( + assignment.getId(), + assignment.getTeacher().getId(), + assignment.getTeacher().getFullName(), + assignment.getDepartment().getId(), + assignment.getDepartment().getDepartmentName(), + assignment.getValidFrom(), + assignment.getValidTo(), + assignment.getPrimaryAssignment(), + assignment.getComment() + ); } } diff --git a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java new file mode 100644 index 0000000..7d27645 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java @@ -0,0 +1,188 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.ClassroomResponse; +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.dto.WorkloadSummaryDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.ClassroomRepository; +import com.magistr.app.repository.DepartmentRepository; +import com.magistr.app.repository.UserRepository; +import com.magistr.app.service.ScheduleQueryService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/workload") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER}) +public class WorkloadController { + + private static final int ACADEMIC_HOURS_PER_LESSON = 2; + + private final ScheduleQueryService scheduleQueryService; + private final ClassroomRepository classroomRepository; + private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; + + public WorkloadController(ScheduleQueryService scheduleQueryService, + ClassroomRepository classroomRepository, + UserRepository userRepository, + DepartmentRepository departmentRepository) { + this.scheduleQueryService = scheduleQueryService; + this.classroomRepository = classroomRepository; + this.userRepository = userRepository; + this.departmentRepository = departmentRepository; + } + + @GetMapping("/teachers") + public List teacherWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + Map teachersById = userRepository.findAllById(lessons.stream() + .map(RenderedLessonDto::teacherId) + .filter(Objects::nonNull) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName, + teacherId -> { + User teacher = teachersById.get(teacherId); + return teacher == null ? null : teacher.getDepartmentId(); + }); + } + + @GetMapping("/classrooms") + public List classroomWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null); + } + + @GetMapping("/departments") + public List departmentWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + Map departments = departmentRepository.findAll().stream() + .collect(Collectors.toMap(Department::getId, Department::getDepartmentName)); + Map teachersById = userRepository.findAllById(lessons.stream() + .map(RenderedLessonDto::teacherId) + .filter(Objects::nonNull) + .collect(Collectors.toSet())) + .stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + + Map> byDepartment = lessons.stream() + .collect(Collectors.groupingBy(lesson -> { + User teacher = teachersById.get(lesson.teacherId()); + return teacher == null ? 0L : teacher.getDepartmentId(); + })); + + return byDepartment.entrySet().stream() + .map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + @GetMapping("/time-slots") + public List timeSlotWorkload( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) Long departmentId + ) { + List lessons = scheduleQueryService.search(null, null, null, departmentId, + null, null, null, null, startDate, endDate); + return summarize(lessons, RenderedLessonDto::timeSlotId, + lesson -> lesson.timeSlotOrder() == null + ? "Пара" + : lesson.timeSlotOrder() + " пара (" + lesson.startTime() + " - " + lesson.endTime() + ")", + ignored -> null); + } + + @GetMapping("/free-classrooms") + public List freeClassrooms( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam Long timeSlotId + ) { + Set busyClassroomIds = scheduleQueryService.search(null, null, null, null, + null, null, timeSlotId, null, date, date) + .stream() + .map(RenderedLessonDto::classroomId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream() + .filter(Classroom::getIsAvailable) + .filter(classroom -> !busyClassroomIds.contains(classroom.getId())) + .map(this::toClassroomResponse) + .toList(); + } + + private List summarize(List lessons, + Function idFn, + Function nameFn, + Function departmentFn) { + Map> grouped = lessons.stream() + .filter(lesson -> idFn.apply(lesson) != null) + .collect(Collectors.groupingBy(idFn, LinkedHashMap::new, Collectors.toList())); + return grouped.entrySet().stream() + .map(entry -> summary(entry.getKey(), nameFn.apply(entry.getValue().get(0)), departmentFn.apply(entry.getKey()), entry.getValue())) + .sorted(Comparator.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private WorkloadSummaryDto summary(Long id, String name, Long departmentId, List lessons) { + return new WorkloadSummaryDto( + id, + name, + departmentId, + departmentName(departmentId), + lessons.size(), + lessons.size() * ACADEMIC_HOURS_PER_LESSON, + lessons.stream() + .map(lesson -> lesson.date() + ":" + lesson.timeSlotId()) + .distinct() + .count() + ); + } + + private String departmentName(Long departmentId) { + if (departmentId == null) { + return null; + } + return departmentRepository.findById(departmentId) + .map(Department::getDepartmentName) + .orElse("Кафедра не найдена"); + } + + private ClassroomResponse toClassroomResponse(Classroom classroom) { + return new ClassroomResponse( + classroom.getId(), + classroom.getName(), + classroom.getCapacity(), + classroom.getBuilding(), + classroom.getFloor(), + classroom.getIsAvailable(), + classroom.getStatus(), + classroom.getArchivedAt(), + classroom.getArchiveReason(), + new ArrayList<>(classroom.getEquipments()) + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java index 06667dd..a587057 100755 --- a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java @@ -1,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 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 equipments) { + this(id, name, capacity, building, floor, isAvailable, null, null, null, equipments); + } + + public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor, + Boolean isAvailable, String status, LocalDateTime archivedAt, + String archiveReason, List equipments) { this.id = id; this.name = name; this.capacity = capacity; this.building = building; this.floor = floor; this.isAvailable = isAvailable; + this.status = status; + this.archivedAt = archivedAt; + this.archiveReason = archiveReason; this.equipments = equipments; } @@ -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 getEquipments() { return equipments; } diff --git a/backend/src/main/java/com/magistr/app/dto/DepartmentTransferRequest.java b/backend/src/main/java/com/magistr/app/dto/DepartmentTransferRequest.java new file mode 100644 index 0000000..4dd605a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/DepartmentTransferRequest.java @@ -0,0 +1,10 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record DepartmentTransferRequest( + Long departmentId, + LocalDate validFrom, + String comment +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java new file mode 100644 index 0000000..86a6f5e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java @@ -0,0 +1,19 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record ScheduleOverrideDto( + Long id, + Long baseRuleSlotId, + LocalDate lessonDate, + String action, + Long newTimeSlotId, + Long newClassroomId, + Long newTeacherId, + String newLessonFormat, + String comment, + Long createdBy, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java b/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java new file mode 100644 index 0000000..73a3777 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SubjectCommentDto.java @@ -0,0 +1,13 @@ +package com.magistr.app.dto; + +import java.time.LocalDateTime; + +public record SubjectCommentDto( + Long id, + Long subjectId, + Long authorId, + String authorName, + String comment, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherDepartmentAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherDepartmentAssignmentDto.java new file mode 100644 index 0000000..f51c379 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherDepartmentAssignmentDto.java @@ -0,0 +1,16 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record TeacherDepartmentAssignmentDto( + Long id, + Long teacherId, + String teacherName, + Long departmentId, + String departmentName, + LocalDate validFrom, + LocalDate validTo, + Boolean primaryAssignment, + String comment +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java index 2bc5ba7..fbcd2f3 100755 --- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java @@ -12,6 +12,7 @@ public class UserResponse { private String jobTitle; private String departmentName; private Long departmentId; + private String status; public UserResponse() { } @@ -69,4 +70,12 @@ public class UserResponse { public Long getDepartmentId() { return departmentId; } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/WorkloadSummaryDto.java b/backend/src/main/java/com/magistr/app/dto/WorkloadSummaryDto.java new file mode 100644 index 0000000..a5cac29 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/WorkloadSummaryDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record WorkloadSummaryDto( + Long id, + String name, + Long departmentId, + String departmentName, + long lessonCount, + int academicHours, + long occupiedSlotCount +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/Classroom.java b/backend/src/main/java/com/magistr/app/model/Classroom.java index a7f7653..aad7088 100755 --- a/backend/src/main/java/com/magistr/app/model/Classroom.java +++ b/backend/src/main/java/com/magistr/app/model/Classroom.java @@ -6,7 +6,7 @@ import java.util.Set; @Entity @Table(name = "classrooms") -public class Classroom { +public class Classroom extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/Department.java b/backend/src/main/java/com/magistr/app/model/Department.java index d14905e..bb6584a 100644 --- a/backend/src/main/java/com/magistr/app/model/Department.java +++ b/backend/src/main/java/com/magistr/app/model/Department.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name="departments") -public class Department { +public class Department extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/EducationForm.java b/backend/src/main/java/com/magistr/app/model/EducationForm.java index a2f1c58..f854ead 100755 --- a/backend/src/main/java/com/magistr/app/model/EducationForm.java +++ b/backend/src/main/java/com/magistr/app/model/EducationForm.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "education_forms") -public class EducationForm { +public class EducationForm extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/Equipment.java b/backend/src/main/java/com/magistr/app/model/Equipment.java index a550410..17f4c30 100755 --- a/backend/src/main/java/com/magistr/app/model/Equipment.java +++ b/backend/src/main/java/com/magistr/app/model/Equipment.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "equipments") -public class Equipment { +public class Equipment extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java new file mode 100644 index 0000000..4ae5483 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java @@ -0,0 +1,104 @@ +package com.magistr.app.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@MappedSuperclass +public abstract class LifecycleEntity { + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_ARCHIVED = "ARCHIVED"; + + @Column(name = "status", nullable = false, length = 20) + private String status = STATUS_ACTIVE; + + @Column(name = "active_from") + private LocalDate activeFrom; + + @Column(name = "active_to") + private LocalDate activeTo; + + @Column(name = "archived_at") + private LocalDateTime archivedAt; + + @Column(name = "archive_reason") + private String archiveReason; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status == null || status.isBlank() ? STATUS_ACTIVE : status; + } + + public LocalDate getActiveFrom() { + return activeFrom; + } + + public void setActiveFrom(LocalDate activeFrom) { + this.activeFrom = activeFrom; + } + + public LocalDate getActiveTo() { + return activeTo; + } + + public void setActiveTo(LocalDate activeTo) { + this.activeTo = activeTo; + } + + public LocalDateTime getArchivedAt() { + return archivedAt; + } + + public void setArchivedAt(LocalDateTime archivedAt) { + this.archivedAt = archivedAt; + } + + public String getArchiveReason() { + return archiveReason; + } + + public void setArchiveReason(String archiveReason) { + this.archiveReason = archiveReason; + } + + @JsonIgnore + public boolean isActiveRecord() { + return !STATUS_ARCHIVED.equals(status); + } + + @JsonIgnore + public boolean isArchivedRecord() { + return STATUS_ARCHIVED.equals(status); + } + + @JsonIgnore + public boolean isActiveOn(LocalDate date) { + if (date == null) { + return isActiveRecord(); + } + boolean afterStart = activeFrom == null || !date.isBefore(activeFrom); + boolean beforeEnd = activeTo == null || !date.isAfter(activeTo); + return afterStart && beforeEnd; + } + + public void archive(String reason) { + status = STATUS_ARCHIVED; + archivedAt = LocalDateTime.now(); + activeTo = LocalDate.now(); + archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim(); + } + + public void restore() { + status = STATUS_ACTIVE; + archivedAt = null; + archiveReason = null; + activeTo = null; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Role.java b/backend/src/main/java/com/magistr/app/model/Role.java index d2cfc71..72815c5 100755 --- a/backend/src/main/java/com/magistr/app/model/Role.java +++ b/backend/src/main/java/com/magistr/app/model/Role.java @@ -2,6 +2,9 @@ package com.magistr.app.model; public enum Role { ADMIN, + EDUCATION_OFFICE, + DEPARTMENT, + SCHEDULE_VIEWER, TEACHER, STUDENT } diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java new file mode 100644 index 0000000..85c9bbb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java @@ -0,0 +1,133 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "schedule_overrides") +public class ScheduleOverride { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "base_rule_slot_id", nullable = false) + private ScheduleRuleSlot baseRuleSlot; + + @Column(name = "lesson_date", nullable = false) + private LocalDate lessonDate; + + @Column(nullable = false, length = 20) + private String action; + + @ManyToOne + @JoinColumn(name = "new_time_slot_id") + private TimeSlot newTimeSlot; + + @ManyToOne + @JoinColumn(name = "new_classroom_id") + private Classroom newClassroom; + + @ManyToOne + @JoinColumn(name = "new_teacher_id") + private User newTeacher; + + @Column(name = "new_lesson_format", length = 30) + private String newLessonFormat; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + public Long getId() { + return id; + } + + public ScheduleRuleSlot getBaseRuleSlot() { + return baseRuleSlot; + } + + public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) { + this.baseRuleSlot = baseRuleSlot; + } + + public LocalDate getLessonDate() { + return lessonDate; + } + + public void setLessonDate(LocalDate lessonDate) { + this.lessonDate = lessonDate; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public TimeSlot getNewTimeSlot() { + return newTimeSlot; + } + + public void setNewTimeSlot(TimeSlot newTimeSlot) { + this.newTimeSlot = newTimeSlot; + } + + public Classroom getNewClassroom() { + return newClassroom; + } + + public void setNewClassroom(Classroom newClassroom) { + this.newClassroom = newClassroom; + } + + public User getNewTeacher() { + return newTeacher; + } + + public void setNewTeacher(User newTeacher) { + this.newTeacher = newTeacher; + } + + public String getNewLessonFormat() { + return newLessonFormat; + } + + public void setNewLessonFormat(String newLessonFormat) { + this.newLessonFormat = newLessonFormat; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public Long getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(Long createdBy) { + this.createdBy = createdBy; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java index bd64c09..c0bbb1c 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -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; } diff --git a/backend/src/main/java/com/magistr/app/model/Speciality.java b/backend/src/main/java/com/magistr/app/model/Speciality.java index 327360b..0d2938f 100644 --- a/backend/src/main/java/com/magistr/app/model/Speciality.java +++ b/backend/src/main/java/com/magistr/app/model/Speciality.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name="specialties") -public class Speciality { +public class Speciality extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java index 4c6ae37..8c4f143 100644 --- a/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java +++ b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java @@ -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) diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroup.java b/backend/src/main/java/com/magistr/app/model/StudentGroup.java index fa42974..03a0129 100755 --- a/backend/src/main/java/com/magistr/app/model/StudentGroup.java +++ b/backend/src/main/java/com/magistr/app/model/StudentGroup.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "student_groups") -public class StudentGroup { +public class StudentGroup extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/Subgroup.java b/backend/src/main/java/com/magistr/app/model/Subgroup.java index 851fec9..e2e5ca0 100644 --- a/backend/src/main/java/com/magistr/app/model/Subgroup.java +++ b/backend/src/main/java/com/magistr/app/model/Subgroup.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "subgroups") -public class Subgroup { +public class Subgroup extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/Subject.java b/backend/src/main/java/com/magistr/app/model/Subject.java index e5e922d..10e1751 100755 --- a/backend/src/main/java/com/magistr/app/model/Subject.java +++ b/backend/src/main/java/com/magistr/app/model/Subject.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "subjects") -public class Subject { +public class Subject extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/model/SubjectComment.java b/backend/src/main/java/com/magistr/app/model/SubjectComment.java new file mode 100644 index 0000000..b1b8d38 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/SubjectComment.java @@ -0,0 +1,64 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "subject_comments") +public class SubjectComment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "subject_id", nullable = false) + private Subject subject; + + @ManyToOne + @JoinColumn(name = "author_id") + private User author; + + @Column(nullable = false, columnDefinition = "TEXT") + private String comment; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + public Long getId() { + return id; + } + + public Subject getSubject() { + return subject; + } + + public void setSubject(Subject subject) { + this.subject = subject; + } + + public User getAuthor() { + return author; + } + + public void setAuthor(User author) { + this.author = author; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java b/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java new file mode 100644 index 0000000..737d249 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherDepartmentAssignment.java @@ -0,0 +1,109 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "teacher_department_assignments") +public class TeacherDepartmentAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @ManyToOne(optional = false) + @JoinColumn(name = "department_id", nullable = false) + private Department department; + + @Column(name = "valid_from", nullable = false) + private LocalDate validFrom; + + @Column(name = "valid_to") + private LocalDate validTo; + + @Column(name = "is_primary", nullable = false) + private Boolean primaryAssignment = true; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "created_by") + private Long createdBy; + + public Long getId() { + return id; + } + + public User getTeacher() { + return teacher; + } + + public void setTeacher(User teacher) { + this.teacher = teacher; + } + + public Department getDepartment() { + return department; + } + + public void setDepartment(Department department) { + this.department = department; + } + + public LocalDate getValidFrom() { + return validFrom; + } + + public void setValidFrom(LocalDate validFrom) { + this.validFrom = validFrom; + } + + public LocalDate getValidTo() { + return validTo; + } + + public void setValidTo(LocalDate validTo) { + this.validTo = validTo; + } + + public Boolean getPrimaryAssignment() { + return primaryAssignment; + } + + public void setPrimaryAssignment(Boolean primaryAssignment) { + this.primaryAssignment = primaryAssignment; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public Long getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(Long createdBy) { + this.createdBy = createdBy; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/User.java b/backend/src/main/java/com/magistr/app/model/User.java index 73e41c1..f70a768 100755 --- a/backend/src/main/java/com/magistr/app/model/User.java +++ b/backend/src/main/java/com/magistr/app/model/User.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; @Entity @Table(name = "users") -public class User { +public class User extends LifecycleEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java index aee60f2..c96cb49 100755 --- a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java @@ -4,7 +4,10 @@ import com.magistr.app.model.Classroom; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface ClassroomRepository extends JpaRepository { Optional findByName(String name); + + List findByStatusNot(String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java b/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java index 01fc400..3487ffb 100644 --- a/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/DepartmentRepository.java @@ -4,10 +4,13 @@ import com.magistr.app.model.Department; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface DepartmentRepository extends JpaRepository { Optional findByDepartmentName(String departmentName); Optional findByDepartmentCode(Long departmentCode); + + List findByStatusNot(String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java b/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java index 8d5a445..1287b09 100755 --- a/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java @@ -4,7 +4,10 @@ import com.magistr.app.model.Equipment; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface EquipmentRepository extends JpaRepository { Optional findByName(String name); + + List findByStatusNot(String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java index 520f8e7..4153fc8 100755 --- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java @@ -10,4 +10,8 @@ public interface GroupRepository extends JpaRepository { List findByEducationFormId(Long educationFormId); List findByDepartmentId(Long departmentId); + + List findByStatusNot(String status); + + List findByDepartmentIdAndStatusNot(Long departmentId, String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java new file mode 100644 index 0000000..2b48bc0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java @@ -0,0 +1,29 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleOverride; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface ScheduleOverrideRepository extends JpaRepository { + + @Query(""" + select o + from ScheduleOverride o + left join fetch o.baseRuleSlot baseSlot + left join fetch o.newTimeSlot + left join fetch o.newClassroom + left join fetch o.newTeacher + where o.lessonDate between :startDate and :endDate + """) + List findByLessonDateBetweenWithDetails( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + Optional findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java index 6b51ac5..94ca17a 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -26,6 +26,7 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' """) List findByGroupIdAndSemesterId( @Param("groupId") Long groupId, @@ -49,6 +50,7 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' """) List findByTeacherIdAndSemesterId( @Param("teacherId") Long teacherId, @@ -69,6 +71,7 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' order by r.id desc """) List findAllWithDetails(); diff --git a/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java b/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java index 7c63217..a3b3c23 100644 --- a/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SpecialtiesRepository.java @@ -4,10 +4,13 @@ import com.magistr.app.model.Speciality; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.List; public interface SpecialtiesRepository extends JpaRepository { Optional findBySpecialityName(String specialityName); Optional findBySpecialityCode(String specialityCode); + + List findByStatusNot(String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java index 0a4ac8f..49715e1 100644 --- a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -14,6 +14,7 @@ public interface SubgroupRepository extends JpaRepository { select subgroup from Subgroup subgroup join fetch subgroup.studentGroup studentGroup + where subgroup.status <> 'ARCHIVED' order by studentGroup.name, subgroup.name """) List findAllWithGroupsOrderByGroupNameAndName(); @@ -23,6 +24,7 @@ public interface SubgroupRepository extends JpaRepository { from Subgroup subgroup join fetch subgroup.studentGroup where subgroup.studentGroup.id = :groupId + and subgroup.status <> 'ARCHIVED' order by subgroup.name """) List findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId); @@ -41,6 +43,7 @@ public interface SubgroupRepository extends JpaRepository { from Subgroup subgroup where subgroup.studentGroup.id = :groupId and lower(subgroup.name) = lower(:name) + and subgroup.status <> 'ARCHIVED' """) boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name); } diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectCommentRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectCommentRepository.java new file mode 100644 index 0000000..86fc833 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SubjectCommentRepository.java @@ -0,0 +1,10 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.SubjectComment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SubjectCommentRepository extends JpaRepository { + List findBySubjectIdOrderByCreatedAtDesc(Long subjectId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java index 69a759f..dfb4888 100755 --- a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java @@ -10,4 +10,8 @@ public interface SubjectRepository extends JpaRepository { Optional findByName(String name); List findByDepartmentId(Long departmentId); + + List findByStatusNot(String status); + + List findByDepartmentIdAndStatusNot(Long departmentId, String status); } diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java new file mode 100644 index 0000000..8caaf02 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java @@ -0,0 +1,50 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherDepartmentAssignment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface TeacherDepartmentAssignmentRepository extends JpaRepository { + + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.teacher + join fetch a.department + where a.teacher.id = :teacherId + order by a.validFrom desc, a.id desc + """) + List findHistoryByTeacherId(@Param("teacherId") Long teacherId); + + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.teacher + join fetch a.department + where a.teacher.id = :teacherId + and a.primaryAssignment = true + and a.validTo is null + """) + Optional findOpenPrimaryByTeacherId(@Param("teacherId") Long teacherId); + + @Query(""" + select a + from TeacherDepartmentAssignment a + join fetch a.teacher + join fetch a.department + where a.department.id = :departmentId + and a.primaryAssignment = true + and a.validFrom <= :date + and (a.validTo is null or a.validTo >= :date) + order by a.teacher.fullName + """) + List findDepartmentTeachersAtDate( + @Param("departmentId") Long departmentId, + @Param("date") LocalDate date + ); +} diff --git a/backend/src/main/java/com/magistr/app/repository/UserRepository.java b/backend/src/main/java/com/magistr/app/repository/UserRepository.java index 35932e4..f0510b2 100755 --- a/backend/src/main/java/com/magistr/app/repository/UserRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java @@ -14,4 +14,10 @@ public interface UserRepository extends JpaRepository { List findByRole(Role role); List findByRoleAndDepartmentId(Role role, Long departmentId); + + List findByStatusNot(String status); + + List findByRoleAndStatusNot(Role role, String status); + + List findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status); } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 83925d4..9449bd6 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -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 eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); @@ -193,6 +196,12 @@ public class ScheduleGeneratorService { Map consumedHoursByBucket = new HashMap<>(); for (ScheduleRuleSlot slot : slots) { + if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) { + continue; + } + if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) { + continue; + } ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType()); if (weekNumber < rule.startWeekFor(category)) { continue; @@ -254,10 +263,13 @@ public class ScheduleGeneratorService { private List 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(); diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java new file mode 100644 index 0000000..348044f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -0,0 +1,246 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.ScheduleOverride; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.ScheduleOverrideRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class ScheduleQueryService { + + private final ScheduleGeneratorService scheduleGeneratorService; + private final GroupRepository groupRepository; + private final ScheduleOverrideRepository scheduleOverrideRepository; + + public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, + GroupRepository groupRepository, + ScheduleOverrideRepository scheduleOverrideRepository) { + this.scheduleGeneratorService = scheduleGeneratorService; + this.groupRepository = groupRepository; + this.scheduleOverrideRepository = scheduleOverrideRepository; + } + + public List search(Long groupId, + Long teacherId, + Long classroomId, + Long departmentId, + Long subjectId, + Long lessonTypeId, + Long timeSlotId, + String parity, + LocalDate startDate, + LocalDate endDate) { + List groups = resolveGroups(groupId, departmentId); + List generatedLessons = groups.stream() + .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) + .toList(); + List lessons = applyOverrides(generatedLessons, startDate, endDate).stream() + .filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId)) + .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId)) + .filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId)) + .filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId)) + .filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId)) + .filter(lesson -> parity == null || parity.isBlank() || lesson.parity().name().equalsIgnoreCase(parity)) + .toList(); + return deduplicate(lessons); + } + + private List applyOverrides(List lessons, LocalDate startDate, LocalDate endDate) { + Map overrides = scheduleOverrideRepository + .findByLessonDateBetweenWithDetails(startDate, endDate) + .stream() + .collect(Collectors.toMap( + override -> overrideKey(override.getBaseRuleSlot().getId(), override.getLessonDate()), + override -> override, + (first, second) -> second + )); + if (overrides.isEmpty()) { + return lessons; + } + return lessons.stream() + .map(lesson -> { + ScheduleOverride override = overrides.get(overrideKey(lesson.scheduleRuleSlotId(), lesson.date())); + return override == null ? lesson : applyOverride(lesson, override); + }) + .filter(Objects::nonNull) + .toList(); + } + + private RenderedLessonDto applyOverride(RenderedLessonDto lesson, ScheduleOverride override) { + if ("CANCEL".equals(override.getAction())) { + return null; + } + Long timeSlotId = lesson.timeSlotId(); + Integer timeSlotOrder = lesson.timeSlotOrder(); + java.time.LocalTime startTime = lesson.startTime(); + java.time.LocalTime endTime = lesson.endTime(); + if (override.getNewTimeSlot() != null) { + timeSlotId = override.getNewTimeSlot().getId(); + timeSlotOrder = override.getNewTimeSlot().getOrderNumber(); + startTime = override.getNewTimeSlot().getStartTime(); + endTime = override.getNewTimeSlot().getEndTime(); + } + + Long classroomId = override.getNewClassroom() == null ? lesson.classroomId() : override.getNewClassroom().getId(); + String classroomName = override.getNewClassroom() == null ? lesson.classroomName() : override.getNewClassroom().getName(); + Long teacherId = override.getNewTeacher() == null ? lesson.teacherId() : override.getNewTeacher().getId(); + String teacherName = override.getNewTeacher() == null ? lesson.teacherName() : ScheduleGeneratorService.displayUserName(override.getNewTeacher()); + String lessonFormat = override.getNewLessonFormat() == null ? lesson.lessonFormat() : override.getNewLessonFormat(); + + return new RenderedLessonDto( + lesson.scheduleRuleId(), + lesson.scheduleRuleSlotId(), + lesson.date(), + lesson.dayOfWeek(), + lesson.dayName(), + lesson.weekNumber(), + lesson.parity(), + timeSlotId, + timeSlotOrder, + startTime, + endTime, + lesson.subjectId(), + lesson.subjectName(), + teacherId, + teacherName, + classroomId, + classroomName, + lesson.lessonTypeId(), + lesson.lessonTypeName(), + lessonFormat, + lesson.subgroupId(), + lesson.subgroupName(), + lesson.subgroupIds(), + lesson.subgroupNames(), + lesson.groupIds(), + lesson.groupNames(), + lesson.activityType(), + lesson.lessonTypeAcademicHours(), + lesson.consumedLessonTypeAcademicHoursBeforeLesson(), + lesson.remainingLessonTypeAcademicHoursAfterLesson() + ); + } + + private String overrideKey(Long scheduleRuleSlotId, LocalDate date) { + return scheduleRuleSlotId + ":" + date; + } + + private List resolveGroups(Long groupId, Long departmentId) { + if (groupId != null) { + return groupRepository.findById(groupId) + .filter(StudentGroup::isActiveRecord) + .map(List::of) + .orElse(List.of()); + } + if (departmentId != null) { + return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); + } + return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + } + + private List deduplicate(List lessons) { + Map unique = new LinkedHashMap<>(); + for (RenderedLessonDto lesson : lessons) { + String key = lessonKey(lesson); + unique.computeIfAbsent(key, ignored -> new MutableLesson(lesson)).merge(lesson); + } + return unique.values().stream() + .map(MutableLesson::toDto) + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private String lessonKey(RenderedLessonDto lesson) { + return String.join(":", + String.valueOf(lesson.date()), + String.valueOf(lesson.scheduleRuleSlotId()), + String.valueOf(lesson.classroomId()), + String.valueOf(lesson.timeSlotOrder()), + String.valueOf(lesson.teacherId()), + String.valueOf(lesson.subjectId()), + sortedSignature(lesson.subgroupIds()) + ); + } + + private String sortedSignature(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + return values.stream() + .map(String::valueOf) + .sorted() + .collect(Collectors.joining(",")); + } + + private static class MutableLesson { + private final RenderedLessonDto source; + private final LinkedHashSet groupIds = new LinkedHashSet<>(); + private final LinkedHashSet groupNames = new LinkedHashSet<>(); + private final LinkedHashSet subgroupIds = new LinkedHashSet<>(); + private final LinkedHashSet subgroupNames = new LinkedHashSet<>(); + + MutableLesson(RenderedLessonDto source) { + this.source = source; + } + + void merge(RenderedLessonDto lesson) { + if (lesson.groupIds() != null) { + groupIds.addAll(lesson.groupIds()); + } + if (lesson.groupNames() != null) { + groupNames.addAll(lesson.groupNames()); + } + if (lesson.subgroupIds() != null) { + subgroupIds.addAll(lesson.subgroupIds()); + } + if (lesson.subgroupNames() != null) { + subgroupNames.addAll(lesson.subgroupNames()); + } + } + + RenderedLessonDto toDto() { + return new RenderedLessonDto( + source.scheduleRuleId(), + source.scheduleRuleSlotId(), + source.date(), + source.dayOfWeek(), + source.dayName(), + source.weekNumber(), + source.parity(), + source.timeSlotId(), + source.timeSlotOrder(), + source.startTime(), + source.endTime(), + source.subjectId(), + source.subjectName(), + source.teacherId(), + source.teacherName(), + source.classroomId(), + source.classroomName(), + source.lessonTypeId(), + source.lessonTypeName(), + source.lessonFormat(), + subgroupIds.size() == 1 ? subgroupIds.iterator().next() : null, + subgroupNames.size() == 1 ? subgroupNames.iterator().next() : null, + List.copyOf(subgroupIds), + List.copyOf(subgroupNames), + List.copyOf(groupIds), + List.copyOf(groupNames), + source.activityType(), + source.lessonTypeAcademicHours(), + source.consumedLessonTypeAcademicHoursBeforeLesson(), + source.remainingLessonTypeAcademicHoursAfterLesson() + ); + } + } +} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 8bba67b..45fb370 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -9,7 +9,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS departments ( id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL, - code BIGINT UNIQUE NOT NULL + code BIGINT UNIQUE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + active_from DATE, + active_to DATE, + archived_at TIMESTAMP, + archived_by BIGINT, + archive_reason TEXT ); INSERT INTO departments (name, code) VALUES @@ -20,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 оборудования'; diff --git a/docs/API.md b/docs/API.md index babf842..85bd686 100644 --- a/docs/API.md +++ b/docs/API.md @@ -46,6 +46,32 @@ > После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer ` +Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. + +Redirect по ролям: + +| Роль | Redirect | +|------|----------| +| `ADMIN` | `/admin/` | +| `EDUCATION_OFFICE` | `/admin/#schedule-view` | +| `DEPARTMENT` | `/admin/#department-workspace` | +| `SCHEDULE_VIEWER` | `/admin/#schedule-view` | +| `TEACHER` | `/teacher/` | +| `STUDENT` | `/student/` | + +### `GET /api/auth/me` + +Возвращает текущего пользователя по bearer-токену. + +```json +{ + "userId": 1, + "username": "admin", + "role": "ADMIN", + "departmentId": 1 +} +``` + --- ## Пользователи @@ -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` + +Восстановление архивной аудитории. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f4e3cf9..1c5a700 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,7 @@ graph TD - **Порт:** 8080 (внутренний) - **ORM:** Hibernate (JPA), `ddl-auto=none` - **Миграции:** Flyway (программный запуск при подключении тенанта) -- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены +- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей ### PostgreSQL - **Версия:** `postgres:alpine3.23` @@ -131,12 +131,16 @@ sequenceDiagram ## Аутентификация -Система использует **простую модель аутентификации** без JWT или Spring Security фильтров: +Система использует простую модель аутентификации без JWT и без полноценного Spring Security: 1. Клиент отправляет `POST /api/auth/login` с `username` и `password` 2. Backend проверяет пароль через `BCryptPasswordEncoder` 3. При успехе возвращается: - UUID-токен (для заголовка `Authorization: Bearer`) - - Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`) - - Redirect URL (`/admin/`, `/teacher/`, `/student/`) + - Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`) + - Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`) 4. Токен хранится в `localStorage` на клиенте + +Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно. + +`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index fe9d3a1..8d729ea 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -2,19 +2,33 @@ ## Ролевая модель -Система поддерживает три роли пользователей: +Система поддерживает шесть ролей пользователей: | Роль | Enum | Возможности | |------|------|------------| -| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). | +| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | +| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | +| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | +| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в недельных матричных таблицах. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | После авторизации пользователь перенаправляется на свой интерфейс: - `ADMIN` → `/admin/` +- `EDUCATION_OFFICE` → `/admin/#schedule-view` +- `DEPARTMENT` → `/admin/#department-workspace` +- `SCHEDULE_VIEWER` → `/admin/#schedule-view` - `TEACHER` → `/teacher/` - `STUDENT` → `/student/` +Bearer-токен проверяется на backend. Frontend-скрытие пунктов меню является только удобством, а не источником прав. + +Для роли `DEPARTMENT` backend дополнительно ограничивает изменения рамками кафедры текущего пользователя: + +- загрузка и комментарии дисциплин идут через `/api/department/*`; +- общий `/api/subjects` доступен кафедре только на чтение; +- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя. + --- ## Управление ресурсами @@ -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` — заменить преподавателя, аудиторию или формат. + +Изменения не переписывают базовое правило, а накладываются поверх сгенерированного расписания на конкретную дату. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index b4af127..9e3b82d 100644 --- a/docs/DATABASE.md +++ b/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 } @@ -118,6 +126,24 @@ erDiagram VARCHAR qualification_level INT experience_years } + + teacher_department_assignments { + BIGSERIAL id PK + BIGINT teacher_id FK + BIGINT department_id FK + DATE valid_from + DATE valid_to + BOOLEAN is_primary + TEXT comment + } + + subject_comments { + BIGSERIAL id PK + BIGINT subject_id FK + BIGINT author_id FK + TEXT comment + TIMESTAMP created_at + } teacher_lesson_types { BIGINT user_id FK,PK @@ -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-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 1439ce6..4e3c0b2 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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` показывает найденные занятия только в табличной матрице: период разбивается по учебным неделям, строки — даты, столбцы — пары, а внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. +- Вкладка `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` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Вкладка `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`. diff --git a/docs/README.md b/docs/README.md index 875f486..1cb77ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -90,6 +90,8 @@ magistr/ │ ├── admin/ # Админ-панель (деканат) │ │ ├── js/views/ # Модули представлений │ │ └── css/ # Стили +│ ├── department/ # Redirect в кабинет кафедры внутри admin SPA +│ ├── edu-office/ # Redirect в кабинет учебного отдела внутри admin SPA │ ├── teacher/ # Интерфейс преподавателя │ └── student/ # Интерфейс студента ├── docs/ # 📖 Документация (вы здесь) diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 584aa8f..31cfdd4 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -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,47 @@ background: var(--bg-input); } +.schedule-view-table-stack { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.schedule-view-week-panel { + display: flex; + flex-direction: column; + gap: 0; +} + +.schedule-view-grid-container { + max-height: none; +} + +.schedule-view-matrix-table { + min-width: 980px; +} + +.schedule-view-matrix-table th, +.schedule-view-matrix-table td { + min-width: 150px; + height: 118px; +} + +.schedule-view-matrix-table .schedule-view-date-cell { + width: 150px; + min-width: 150px; +} + +.schedule-view-lesson-card .lesson-meta + .lesson-meta { + margin-top: 0.1rem; +} + +.schedule-view-state { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + /* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; @@ -581,6 +627,7 @@ } @media (max-width: 768px) { + .workload-target-control, .workload-display-control { min-width: 100%; } diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 19a65e4..7213ea9 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -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; @@ -493,6 +598,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; } diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css index e08b63e..3265a1a 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -72,6 +72,11 @@ overflow: hidden; } +.nav-item[hidden], +.settings-menu-item[hidden] { + display: none !important; +} + .nav-item::before { content: ''; position: absolute; @@ -411,4 +416,4 @@ visibility: visible; pointer-events: auto; } -} \ No newline at end of file +} diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 361f729..99d71b2 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -109,6 +109,25 @@ Дисциплины + + + + + + + + + Кабинет кафедры + + + + + + + Просмотр расписаний + @@ -134,7 +153,7 @@ - Загруженность аудиторий + Загруженность diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index 0c3cec1..5e96564 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -9,6 +9,16 @@ export function isAuthenticatedAsAdmin() { return token && role === 'ADMIN'; } +export function isAuthenticatedAsRole(expectedRole) { + const role = localStorage.getItem('role'); + return token && role === expectedRole; +} + +export function isAuthenticatedAsAny(roles) { + const role = localStorage.getItem('role'); + return token && roles.includes(role); +} + function getHeaders(contentType = 'application/json') { const headers = { 'Authorization': `Bearer ${token}` @@ -31,9 +41,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { const response = await fetch(endpoint, options); - // Si status is 401 or 403, we should probably redirect to login, - // but for now let's just throw an error or handle it in the view. - let data; try { data = await response.json(); @@ -48,7 +55,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { return data; } -// Shortcut methods export const api = { get: (url) => apiFetch(url, 'GET'), post: (url, body) => apiFetch(url, 'POST', body), diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index dabb058..4b73c3d 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -3,12 +3,14 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); } -import { isAuthenticatedAsAdmin } from './api.js'; +import { isAuthenticatedAsAny } from './api.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; -// Auth check -if (!isAuthenticatedAsAdmin()) { +const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER']; +const currentRole = localStorage.getItem('role'); + +if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) { window.location.href = '/'; } @@ -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 = '
Загрузка...
'; 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); diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js index a8b8446..4f4a28f 100644 --- a/frontend/admin/js/views/auditorium-workload.js +++ b/frontend/admin/js/views/auditorium-workload.js @@ -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,12 +511,14 @@ 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); - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key).push(lesson); + entityIdsForLesson(lesson).forEach(entityId => { + const key = entitySlotKey(entityId, lesson.timeSlotId); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(lesson); + }); }); grouped.forEach(items => { items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); @@ -435,31 +526,43 @@ export async function initAuditoriumWorkload() { 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 = `Нет аудиторий по выбранным фильтрам`; + if (!rows.length) { + tbody.innerHTML = `${escapeHtml(target.emptyLabel)}`; 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 = ` - ${escapeHtml(room.name)} - ${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'} - ${room.building ? `${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}` : ''} - `; - 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 = ` Время - Аудитория + ${escapeHtml(target.rowLabel)} `; @@ -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 ` + ${escapeHtml(userName(entity))} + ${escapeHtml(entity.jobTitle || 'Должность не указана')} + ${entity.departmentName ? `${escapeHtml(entity.departmentName)}` : ''} + `; + } + if (currentTarget === TARGET_DEPARTMENTS) { + return ` + ${escapeHtml(departmentName(entity))} + ${entity.departmentCode ? `Код: ${escapeHtml(entity.departmentCode)}` : 'Код не указан'} + `; + } + return ` + ${escapeHtml(entity.name)} + ${entity.capacity ? `${escapeHtml(entity.capacity)} мест` : 'Вместимость не указана'} + ${entity.building ? `${escapeHtml(entity.building)}${entity.floor ? `, ${escapeHtml(entity.floor)} этаж` : ''}` : ''} + `; + } + + function entityName(entity) { + if (currentTarget === TARGET_TEACHERS) return userName(entity); + if (currentTarget === TARGET_DEPARTMENTS) return departmentName(entity); + return entity?.name || 'Аудитория без названия'; + } + + function entitySummaryDetails(entity) { + if (currentTarget === TARGET_TEACHERS) { + return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', '); + } + if (currentTarget === TARGET_DEPARTMENTS) { + return entity.departmentCode ? `код ${entity.departmentCode}` : ''; + } + return [ + entity.capacity ? `${entity.capacity} мест` : null, + entity.building || null, + entity.floor ? `${entity.floor} этаж` : null + ].filter(Boolean).join(', '); + } + + function renderEntityWorkload(entity, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) { const slotRows = buildSlotRows(dates, slotsByDate, lessons); const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons); - renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates); + renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates); if (!slotRows.length) { roomTables.innerHTML = '
За выбранный период нет временных слотов
'; @@ -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 = `
- ${escapeHtml(room.name)} - ${roomMeta ? `${escapeHtml(roomMeta)}` : ''} + ${escapeHtml(entityName(entity))} + ${details ? `${escapeHtml(details)}` : ''}
${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))} Занято: ${occupiedCells} из ${totalCells} (${percent}%) @@ -555,7 +696,7 @@ export async function initAuditoriumWorkload() { return `
-

Нагрузка аудитории по неделям

+

${escapeHtml(targetConfig().periodTitle)}

${escapeHtml(roomWeekCaption(datesByDay))}
@@ -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)); } diff --git a/frontend/admin/js/views/classrooms.js b/frontend/admin/js/views/classrooms.js index 419aa2e..3353bff 100755 --- a/frontend/admin/js/views/classrooms.js +++ b/frontend/admin/js/views/classrooms.js @@ -31,7 +31,7 @@ export async function initClassrooms() { async function loadClassrooms() { try { - const classrooms = await api.get('/api/classrooms'); + const classrooms = await api.get('/api/classrooms?includeArchived=true'); renderClassrooms(classrooms); } catch (e) { classroomsTbody.innerHTML = 'Ошибка загрузки'; @@ -57,7 +57,7 @@ export async function initClassrooms() {
- ${c.isAvailable ? 'Доступна' : 'Не доступна'} + ${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')} - + ${c.status === 'ARCHIVED' + ? `` + : ``} `; }).join(''); @@ -100,11 +102,19 @@ export async function initClassrooms() { const btnEdit = e.target.closest('.btn-edit-classroom'); if (btnDelete) { - if (!confirm('Удалить аудиторию?')) return; + if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return; try { await api.delete('/api/classrooms/' + btnDelete.dataset.id); loadClassrooms(); - } catch (err) { alert('Ошибка удаления'); } + } catch (err) { alert('Ошибка архивирования'); } + } + + const btnRestore = e.target.closest('.btn-restore-classroom'); + if (btnRestore) { + try { + await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {}); + loadClassrooms(); + } catch (err) { alert('Ошибка восстановления аудитории'); } } if (btnToggleStatus) { @@ -124,7 +134,7 @@ export async function initClassrooms() { async function openEditClassroomModal(id) { try { // Can optimize by using already loaded classrooms, but fetch is safer to get fresh data - const classrooms = await api.get('/api/classrooms'); + const classrooms = await api.get('/api/classrooms?includeArchived=true'); editingClassroomData = classrooms.find(c => c.id == id); if (!editingClassroomData) return; diff --git a/frontend/admin/js/views/department-workspace.js b/frontend/admin/js/views/department-workspace.js new file mode 100644 index 0000000..36ebb97 --- /dev/null +++ b/frontend/admin/js/views/department-workspace.js @@ -0,0 +1,263 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +const ROLE_DEPARTMENT = 'DEPARTMENT'; + +export async function initDepartmentWorkspace() { + const role = localStorage.getItem('role'); + const userDepartmentId = localStorage.getItem('departmentId'); + const departmentField = document.getElementById('department-workspace-department-field'); + const departmentSelect = document.getElementById('department-workspace-department'); + const startInput = document.getElementById('department-workspace-start'); + const endInput = document.getElementById('department-workspace-end'); + const refreshButton = document.getElementById('department-workspace-refresh'); + const importForm = document.getElementById('department-subject-import-form'); + const subjectsTbody = document.getElementById('department-subjects-tbody'); + const subjectCount = document.getElementById('department-subject-count'); + const commentsCard = document.getElementById('department-comments-card'); + const commentsTitle = document.getElementById('department-comments-title'); + const commentsList = document.getElementById('department-comment-list'); + const commentsForm = document.getElementById('department-comment-form'); + const commentsSubjectInput = document.getElementById('department-comment-subject-id'); + const commentsTextInput = document.getElementById('department-comment-text'); + const commentsCloseButton = document.getElementById('department-comments-close'); + + setDefaultDates(startInput, endInput); + refreshButton?.addEventListener('click', loadWorkspace); + departmentSelect?.addEventListener('change', loadWorkspace); + startInput?.addEventListener('change', loadWorkload); + endInput?.addEventListener('change', loadWorkload); + importForm?.addEventListener('submit', importSubjects); + commentsForm?.addEventListener('submit', saveComment); + commentsCloseButton?.addEventListener('click', () => { + commentsCard.hidden = true; + commentsSubjectInput.value = ''; + }); + subjectsTbody?.addEventListener('click', event => { + const button = event.target.closest('.department-comments-open'); + if (button) { + openComments(button.dataset.subjectId, button.dataset.subjectName); + } + }); + + try { + await loadDepartments(role, userDepartmentId); + if (role === ROLE_DEPARTMENT) { + departmentField.hidden = true; + departmentSelect.value = userDepartmentId || ''; + } + await loadWorkspace(); + } catch (error) { + showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error'); + } + + async function loadWorkspace() { + hideAlert('department-workspace-alert'); + await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]); + } + + async function loadSubjects() { + const departmentId = selectedDepartmentId(); + subjectsTbody.innerHTML = 'Загрузка...'; + subjectCount.textContent = 'Загрузка...'; + try { + const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`); + subjectCount.textContent = subjectCountLabel(subjects.length); + if (!subjects.length) { + subjectsTbody.innerHTML = 'Дисциплины не найдены'; + return; + } + subjectsTbody.innerHTML = subjects.map(subject => ` + + ${escapeHtml(subject.code || '-')} + ${escapeHtml(subject.name || '-')} + ${escapeHtml(subject.description || '-')} + + + + + `).join(''); + } catch (error) { + subjectsTbody.innerHTML = `${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}`; + subjectCount.textContent = '0 дисциплин'; + } + } + + async function importSubjects(event) { + event.preventDefault(); + const alertId = 'department-subject-import-alert'; + hideAlert(alertId); + const departmentId = selectedDepartmentId(); + const rows = document.getElementById('department-subject-import-text').value + .split('\n') + .map(row => row.trim()) + .filter(Boolean) + .map(row => { + const [code, ...nameParts] = row.split(';'); + return { + code: (code || '').trim(), + name: nameParts.join(';').trim(), + departmentId: Number(departmentId) + }; + }) + .filter(item => item.name); + + if (!departmentId) { + showAlert(alertId, 'Выберите кафедру', 'error'); + return; + } + if (!rows.length) { + showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error'); + return; + } + + try { + const result = await api.post('/api/department/subjects/import', rows); + showAlert(alertId, result.message || 'Дисциплины загружены', 'success'); + document.getElementById('department-subject-import-text').value = ''; + await loadSubjects(); + } catch (error) { + showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error'); + } + } + + async function openComments(subjectId, subjectName) { + commentsCard.hidden = false; + commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`; + commentsSubjectInput.value = subjectId; + commentsTextInput.value = ''; + hideAlert('department-comment-alert'); + commentsList.textContent = 'Загрузка...'; + + try { + const comments = await api.get(`/api/department/subjects/${subjectId}/comments`); + commentsList.innerHTML = comments.length + ? comments.map(comment => ` +
+ ${escapeHtml(comment.authorName || 'Автор не указан')} + ${escapeHtml(formatDateTime(comment.createdAt))} +

${escapeHtml(comment.comment)}

+
+ `).join('') + : '
Комментариев пока нет
'; + } catch (error) { + commentsList.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки комментариев')}
`; + } + } + + async function saveComment(event) { + event.preventDefault(); + const subjectId = commentsSubjectInput.value; + const comment = commentsTextInput.value.trim(); + if (!subjectId) { + showAlert('department-comment-alert', 'Выберите дисциплину', 'error'); + return; + } + if (!comment) { + showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error'); + return; + } + + try { + await api.post(`/api/department/subjects/${subjectId}/comments`, { comment }); + showAlert('department-comment-alert', 'Комментарий сохранён', 'success'); + await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', '')); + } catch (error) { + showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error'); + } + } + + async function loadTeachers() { + const container = document.getElementById('department-teachers-list'); + container.textContent = 'Загрузка...'; + try { + const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`); + container.innerHTML = teachers.length + ? teachers.map(teacher => ` +
+ ${escapeHtml(teacher.fullName || teacher.username || '-')} + ${escapeHtml(teacher.jobTitle || 'Должность не указана')} +
+ `).join('') + : '
Преподаватели не найдены
'; + } catch (error) { + container.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}
`; + } + } + + async function loadWorkload() { + const container = document.getElementById('department-workload-list'); + const params = new URLSearchParams({ + startDate: startInput.value, + endDate: endInput.value + }); + if (selectedDepartmentId()) { + params.set('departmentId', selectedDepartmentId()); + } + container.textContent = 'Загрузка...'; + try { + const workload = await api.get(`/api/workload/teachers?${params}`); + container.innerHTML = workload.length + ? workload.map(item => ` +
+ ${escapeHtml(item.name || '-')} + ${escapeHtml(String(item.academicHours ?? 0))} ак. ч. + ${escapeHtml(String(item.lessonCount ?? 0))} занятий +
+ `).join('') + : '
Нагрузка не найдена
'; + } catch (error) { + container.innerHTML = `
${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}
`; + } + } + + function selectedDepartmentId() { + return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value; + } +} + +async function loadDepartments(role, userDepartmentId) { + const departments = await api.get('/api/departments'); + fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру'); + if (role === ROLE_DEPARTMENT && userDepartmentId) { + document.getElementById('department-workspace-department').value = userDepartmentId; + } else if (!document.getElementById('department-workspace-department').value && departments.length) { + document.getElementById('department-workspace-department').value = departments[0].id; + } +} + +function fillSelect(id, items, valueFn, labelFn, emptyLabel) { + const select = document.getElementById(id); + if (!select) return; + select.innerHTML = `` + + (items || []).map(item => ``).join(''); +} + +function setDefaultDates(startInput, endInput) { + const today = new Date(); + const nextWeek = new Date(); + nextWeek.setDate(today.getDate() + 7); + startInput.value = toIsoDate(today); + endInput.value = toIsoDate(nextWeek); +} + +function toIsoDate(date) { + return date.toISOString().slice(0, 10); +} + +function formatDateTime(value) { + if (!value) return '-'; + return String(value).replace('T', ' ').slice(0, 16); +} + +function subjectCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return `${count} дисциплин`; + if (count % 10 === 1) return `${count} дисциплина`; + if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`; + return `${count} дисциплин`; +} diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js new file mode 100644 index 0000000..85d61ba --- /dev/null +++ b/frontend/admin/js/views/schedule-view.js @@ -0,0 +1,355 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +const ROLE_DEPARTMENT = 'DEPARTMENT'; +const PARITY_LABELS = { + BOTH: 'каждая', + ODD: 'нечётная', + EVEN: 'чётная' +}; + +export async function initScheduleView() { + const role = localStorage.getItem('role'); + const userDepartmentId = localStorage.getItem('departmentId'); + const startInput = document.getElementById('schedule-view-start'); + const endInput = document.getElementById('schedule-view-end'); + const loadButton = document.getElementById('schedule-view-load'); + const departmentField = document.getElementById('schedule-view-department-field'); + const departmentSelect = document.getElementById('schedule-view-department'); + const tables = document.getElementById('schedule-view-tables'); + const countLabel = document.getElementById('schedule-view-count'); + + setDefaultDates(startInput, endInput); + loadButton?.addEventListener('click', loadData); + + try { + await loadDictionaries(role, userDepartmentId); + if (role === ROLE_DEPARTMENT) { + departmentField.hidden = true; + departmentSelect.value = userDepartmentId || ''; + } + await loadData(); + } catch (error) { + setTableState(error.message || 'Ошибка загрузки справочников'); + } + + async function loadData() { + hideAlert('schedule-view-alert'); + setTableState('Загрузка...'); + countLabel.textContent = 'Загрузка...'; + + const params = new URLSearchParams({ + startDate: startInput.value, + endDate: endInput.value + }); + addOptional(params, 'groupId', valueOf('schedule-view-group')); + addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); + addOptional(params, 'classroomId', valueOf('schedule-view-classroom')); + addOptional(params, 'departmentId', role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value); + addOptional(params, 'subjectId', valueOf('schedule-view-subject')); + addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type')); + addOptional(params, 'parity', valueOf('schedule-view-parity')); + + try { + const lessons = await api.get(`/api/schedule/search?${params}`); + renderLessons(lessons); + } catch (error) { + const message = error.message || 'Ошибка загрузки расписания'; + showAlert('schedule-view-alert', message, 'error'); + setTableState(message); + countLabel.textContent = '0 занятий'; + } + } + + function renderLessons(lessons) { + countLabel.textContent = lessonCountLabel(lessons.length); + if (!lessons.length) { + setTableState('Занятия не найдены'); + return; + } + const slots = collectSlots(lessons); + const lessonsByDateSlot = groupLessonsByDateSlot(lessons); + const weeks = groupDatesByWeek(lessons.map(lesson => lesson.date)); + tables.innerHTML = weeks + .map(week => renderWeekTable(week, slots, lessonsByDateSlot)) + .join(''); + } + + function renderWeekTable(week, slots, lessonsByDateSlot) { + const rows = week.dates.map(date => ` + + + ${escapeHtml(dayTitle(date, lessonsByDateSlot))} + ${escapeHtml(formatDate(date))} + + ${slots.map(slot => renderSlotCell(date, slot, lessonsByDateSlot)).join('')} + + `).join(''); + + return ` +
+
+

${escapeHtml(week.label)}

+ ${escapeHtml(weekCaption(week.dates))} +
+
+ + + + + ${slots.map(renderSlotHeader).join('')} + + + ${rows} +
+ Время + Дата +
+
+
+ `; + } + + function renderSlotHeader(slot) { + return ` + + ${escapeHtml(slot.orderLabel)} + ${escapeHtml(slot.timeLabel)} + ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} + + `; + } + + function renderSlotCell(date, slot, lessonsByDateSlot) { + const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || []; + return ` + + ${lessons.length ? lessons.map(renderLessonCard).join('') : 'Свободно'} + + `; + } + + function renderLessonCard(lesson) { + const subgroupText = (lesson.subgroupNames || []).join(', '); + const meta = [ + lesson.lessonTypeName, + lesson.lessonFormat, + PARITY_LABELS[lesson.parity] || lesson.parity + ].filter(Boolean).join(' · '); + + return ` +
+
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
+
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
+ ${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''} +
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
+
${escapeHtml(lesson.classroomName || 'Аудитория не указана')}
+ ${meta ? `
${escapeHtml(meta)}
` : ''} + ${lesson.scheduleRuleSlotId ? `
ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}
` : ''} +
+ `; + } + + function setTableState(message) { + if (!tables) return; + tables.innerHTML = `
${escapeHtml(message)}
`; + } +} + +async function loadDictionaries(role, userDepartmentId) { + const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([ + api.get('/api/groups'), + api.get('/api/users/teachers'), + api.get('/api/classrooms'), + api.get('/api/departments'), + api.get('/api/subjects'), + api.get('/api/lesson-types') + ]); + + fillSelect('schedule-view-group', groups, item => item.id, item => item.name, 'Все группы'); + fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели'); + fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории'); + fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры'); + fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); + fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); + + if (role === ROLE_DEPARTMENT && userDepartmentId) { + document.getElementById('schedule-view-department').value = userDepartmentId; + } +} + +function setDefaultDates(startInput, endInput) { + const today = new Date(); + const nextWeek = new Date(); + nextWeek.setDate(today.getDate() + 7); + startInput.value = toIsoDate(today); + endInput.value = toIsoDate(nextWeek); +} + +function fillSelect(id, items, valueFn, labelFn, emptyLabel) { + const select = document.getElementById(id); + if (!select) return; + select.innerHTML = `` + + (items || []).map(item => ``).join(''); +} + +function addOptional(params, key, value) { + if (value) params.set(key, value); +} + +function valueOf(id) { + return document.getElementById(id)?.value || ''; +} + +function roomLabel(room) { + return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); +} + +function formatSlot(lesson) { + const order = lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара'; + const time = lesson.startTime && lesson.endTime + ? ` (${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)})` + : ''; + return `${order}${time}`; +} + +function collectSlots(lessons) { + const slots = new Map(); + lessons.forEach(lesson => { + const key = lessonSlotKey(lesson); + const existing = slots.get(key); + const next = { + key, + id: lesson.timeSlotId, + orderNumber: lesson.timeSlotOrder, + startTime: lesson.startTime, + endTime: lesson.endTime, + orderLabel: lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара', + timeLabel: lesson.startTime || lesson.endTime + ? `${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)}` + : 'Время не указано' + }; + if (!existing || existing.timeLabel === 'Время не указано') { + slots.set(key, next); + } + }); + return Array.from(slots.values()).sort(compareSlots); +} + +function groupLessonsByDateSlot(lessons) { + const grouped = new Map(); + lessons.forEach(lesson => { + if (!lesson.date) return; + const key = dateSlotKey(lesson.date, lessonSlotKey(lesson)); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(lesson); + }); + grouped.forEach(items => { + items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName) + || naturalCompare(a.teacherName, b.teacherName) + || naturalCompare((a.groupNames || []).join(','), (b.groupNames || []).join(','))); + }); + return grouped; +} + +function groupDatesByWeek(dates) { + const groups = new Map(); + Array.from(new Set(dates.filter(Boolean))) + .sort((a, b) => naturalCompare(a, b)) + .forEach(date => { + const key = weekStart(date); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(date); + }); + return Array.from(groups.entries()).map(([startDate, weekDates]) => ({ + startDate, + dates: weekDates, + label: `Неделя ${formatDate(startDate)} - ${formatDate(addDaysToIso(startDate, 6))}` + })); +} + +function dayTitle(date, lessonsByDateSlot) { + for (const [key, lessons] of lessonsByDateSlot.entries()) { + if (key.startsWith(`${date}:`) && lessons[0]?.dayName) { + return lessons[0].dayName; + } + } + return dayName(date); +} + +function weekCaption(dates) { + const first = dates[0]; + const last = dates[dates.length - 1]; + return `${formatDate(first)} - ${formatDate(last)}`; +} + +function lessonSlotKey(lesson) { + const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`; + return value ? String(value) : 'unknown'; +} + +function dateSlotKey(date, slotKey) { + return `${date}:${slotKey}`; +} + +function weekStart(value) { + const date = parseIsoDate(value); + const day = date.getDay() || 7; + date.setDate(date.getDate() + 1 - day); + return toIsoDate(date); +} + +function addDaysToIso(value, days) { + const date = parseIsoDate(value); + date.setDate(date.getDate() + days); + return toIsoDate(date); +} + +function parseIsoDate(value) { + const [year, month, day] = String(value).split('-').map(Number); + return new Date(year, month - 1, day); +} + +function dayName(value) { + return parseIsoDate(value).toLocaleDateString('ru-RU', { weekday: 'long' }); +} + +function formatDate(value) { + if (!value) return '-'; + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; +} + +function trimTime(value) { + return String(value || '').slice(0, 5); +} + +function toIsoDate(date) { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); +} + +function lessonCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return `${count} занятий`; + if (count % 10 === 1) return `${count} занятие`; + if ([2, 3, 4].includes(count % 10)) return `${count} занятия`; + return `${count} занятий`; +} + +function compareSlots(a, b) { + const first = Number(a.orderNumber || a.key); + const second = Number(b.orderNumber || b.key); + if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) { + return first - second; + } + return naturalCompare(a.startTime, b.startTime) + || naturalCompare(a.key, b.key); +} + +function naturalCompare(a, b) { + return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); +} diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js index 2008ca3..5fc555d 100644 --- a/frontend/admin/js/views/users.js +++ b/frontend/admin/js/views/users.js @@ -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() { ${escapeHtml(u.departmentName || '-')} ${ROLE_LABELS[u.role] || escapeHtml(u.role)} - + ${u.status === 'ARCHIVED' + ? `` + : ``} `).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 || 'Ошибка восстановления'); } }); diff --git a/frontend/admin/settings/css/layout.css b/frontend/admin/settings/css/layout.css index 0c66136..7261429 100644 --- a/frontend/admin/settings/css/layout.css +++ b/frontend/admin/settings/css/layout.css @@ -72,6 +72,10 @@ overflow: hidden; } +.nav-item[hidden] { + display: none !important; +} + .nav-item::before { content: ''; position: absolute; diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js index f9a8a90..3f99b58 100644 --- a/frontend/admin/settings/js/main.js +++ b/frontend/admin/settings/js/main.js @@ -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); diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html index ccf4f30..71755ad 100644 --- a/frontend/admin/views/auditorium-workload.html +++ b/frontend/admin/views/auditorium-workload.html @@ -1,10 +1,19 @@
-

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

+

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

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

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

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

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

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

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

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

Фильтры расписания

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

Таблицы расписания

+ 0 занятий +
+
+
Загрузка...
+
+
diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index 6c9327e..b3e093c 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -13,11 +13,14 @@
- +
diff --git a/frontend/department/index.html b/frontend/department/index.html new file mode 100644 index 0000000..4c02630 --- /dev/null +++ b/frontend/department/index.html @@ -0,0 +1,14 @@ + + + + + + Кабинет кафедры + + + + + + diff --git a/frontend/edu-office/index.html b/frontend/edu-office/index.html new file mode 100644 index 0000000..66e6d71 --- /dev/null +++ b/frontend/edu-office/index.html @@ -0,0 +1,14 @@ + + + + + + Учебный отдел + + + + + +