From 8f71b9b2b5b885722f73eb138f0dc2bac21d7e3e Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 29 Apr 2026 15:42:05 +0300 Subject: [PATCH 01/16] =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D0=BF=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/config.toml | 21 + DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 317 ++++++++ .../AcademicCalendarAdminController.java | 317 ++++++++ .../app/controller/AuthController.java | 2 +- .../app/controller/LessonsController.java | 4 +- .../app/controller/ScheduleController.java | 56 ++ .../ScheduleRuleAdminController.java | 253 +++++++ .../controller/TimeSlotAdminController.java | 106 +++ .../app/dto/AcademicCalendarMatrixDto.java | 13 + .../com/magistr/app/dto/AcademicYearDto.java | 13 + .../java/com/magistr/app/dto/HolidayDto.java | 12 + .../com/magistr/app/dto/LoginResponse.java | 14 + .../magistr/app/dto/RenderedLessonDto.java | 39 + .../com/magistr/app/dto/ScheduleRuleDto.java | 21 + .../magistr/app/dto/ScheduleRuleSlotDto.java | 22 + .../java/com/magistr/app/dto/SemesterDto.java | 15 + .../java/com/magistr/app/dto/TimeSlotDto.java | 12 + .../app/model/AcademicActivityType.java | 8 + .../app/model/AcademicCalendarMatrix.java | 77 ++ .../com/magistr/app/model/AcademicYear.java | 55 ++ .../java/com/magistr/app/model/Holiday.java | 56 ++ .../com/magistr/app/model/ScheduleParity.java | 7 + .../com/magistr/app/model/ScheduleRule.java | 97 +++ .../magistr/app/model/ScheduleRuleSlot.java | 125 ++++ .../java/com/magistr/app/model/Semester.java | 68 ++ .../java/com/magistr/app/model/TimeSlot.java | 66 ++ .../AcademicCalendarMatrixRepository.java | 23 + .../repository/AcademicYearRepository.java | 11 + .../app/repository/HolidayRepository.java | 16 + .../repository/ScheduleRuleRepository.java | 85 +++ .../ScheduleRuleSlotRepository.java | 7 + .../app/repository/SemesterRepository.java | 18 + .../app/repository/TimeSlotRepository.java | 13 + .../app/service/AcademicDateService.java | 87 +++ .../app/service/ScheduleGeneratorService.java | 295 ++++++++ .../main/resources/db/migration/V1__init.sql | 261 ++++++- docs/API.md | 148 +++- docs/BUSINESS_LOGIC.md | 37 +- docs/DATABASE.md | 173 ++++- docs/FRONTEND.md | 36 +- frontend/script.js | 1 + frontend/student/index.html | 650 +++++++++++----- frontend/teacher/index.html | 694 ++++++++++++------ 43 files changed, 3894 insertions(+), 457 deletions(-) create mode 100644 .codex/config.toml create mode 100644 DYNAMIC_SCHEDULE_IMPLEMENTATION.md create mode 100644 backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/ScheduleController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java create mode 100644 backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/HolidayDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/SemesterDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java create mode 100644 backend/src/main/java/com/magistr/app/model/AcademicActivityType.java create mode 100644 backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java create mode 100644 backend/src/main/java/com/magistr/app/model/AcademicYear.java create mode 100644 backend/src/main/java/com/magistr/app/model/Holiday.java create mode 100644 backend/src/main/java/com/magistr/app/model/ScheduleParity.java create mode 100644 backend/src/main/java/com/magistr/app/model/ScheduleRule.java create mode 100644 backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java create mode 100644 backend/src/main/java/com/magistr/app/model/Semester.java create mode 100644 backend/src/main/java/com/magistr/app/model/TimeSlot.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/HolidayRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/SemesterRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java create mode 100644 backend/src/main/java/com/magistr/app/service/AcademicDateService.java create mode 100644 backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java mode change 100755 => 100644 frontend/student/index.html mode change 100755 => 100644 frontend/teacher/index.html diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..2ef8f23 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,21 @@ +#:schema https://developers.openai.com/codex/config-schema.json + +web_search = "live" + +developer_instructions = """ +Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент. +Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов. +При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня. +Используй проектные скиллы из .agents/skills, когда задача соответствует их description. +""" + +[skills] +include_instructions = true + +[[skills.config]] +path = "../.agents/skills/auto-update-docs" +enabled = true + +[[skills.config]] +path = "../.agents/skills/frontend-design" +enabled = true diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md new file mode 100644 index 0000000..10dde97 --- /dev/null +++ b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -0,0 +1,317 @@ +# Реализация динамического расписания + +Этот файл фиксирует текущий результат внедрения динамической системы расписания: что уже добавлено в проект, как связаны база данных, backend, API и frontend, как работает генератор расписания и какие ограничения остаются для следующего этапа. + +## Статус внедрения + +Реализован первый рабочий срез динамического расписания. Новая модель теперь является базовой схемой проекта и создаётся сразу в `V1__init.sql`. + +Старые таблицы `lessons` и `schedule_data` пока физически остаются в `V1__init.sql`, потому что связанный старый код будет удаляться отдельным шагом. Они больше не заполняются тестовыми данными и не являются источником для новой модели расписания. + +## Что добавлено + +### База данных + +Динамическая схема встроена в базовую Flyway-миграцию `V1__init.sql`. + +Она создаёт следующие таблицы: + +| Таблица | Назначение | +|---------|------------| +| `time_slots` | Настраиваемая сетка пар: номер, время начала, время окончания, длительность | +| `academic_years` | Учебные годы | +| `semesters` | Семестры учебного года, от `start_date` считается первая неделя | +| `holidays` | Праздники и исключённые даты, когда занятия не проводятся | +| `academic_calendar_matrix` | Матрица учебного графика по семестру, курсу, специальности и неделе | +| `schedule_rules` | Правила проведения дисциплины с лимитом академических часов | +| `schedule_rule_groups` | Привязка правила к одной или нескольким группам | +| `schedule_rule_slots` | Конкретные шаблонные слоты правила: день, чётность, пара, преподаватель, аудитория, тип и формат | + +Миграция также создаёт тестовые данные сразу в новой модели: + +1. Создаёт базовые временные слоты. +2. Создаёт учебные годы `2024-2025` и `2025-2026`. +3. Заполняет матрицу учебного графика базовым статусом `THEORY`. +4. Создаёт тестовые `schedule_rules` для групп и дисциплин. +5. Создаёт привязки групп в `schedule_rule_groups`, включая потоковую лекцию. +6. Создаёт шаблонные занятия в `schedule_rule_slots`. + +### Backend + +Добавлены JPA-модели для новой схемы: + +- `TimeSlot` +- `AcademicYear` +- `Semester` +- `Holiday` +- `AcademicCalendarMatrix` +- `ScheduleRule` +- `ScheduleRuleSlot` +- `AcademicActivityType` +- `ScheduleParity` + +Добавлены DTO для API: + +- `TimeSlotDto` +- `AcademicYearDto` +- `SemesterDto` +- `HolidayDto` +- `AcademicCalendarMatrixDto` +- `ScheduleRuleDto` +- `ScheduleRuleSlotDto` +- `RenderedLessonDto` + +Добавлены репозитории для новых таблиц: + +- `TimeSlotRepository` +- `AcademicYearRepository` +- `SemesterRepository` +- `HolidayRepository` +- `AcademicCalendarMatrixRepository` +- `ScheduleRuleRepository` +- `ScheduleRuleSlotRepository` + +Добавлены сервисы: + +| Сервис | Назначение | +|--------|------------| +| `AcademicDateService` | Календарная математика: семестр по дате, номер недели, чётность, праздник, курс группы, тип активности | +| `ScheduleGeneratorService` | Генерация расписания группы или преподавателя на диапазон дат | + +Старый `LessonsController` пока физически присутствует в коде и помечен как `@Deprecated`. Это технический хвост до удаления старых экранов и таблиц, а не целевой слой совместимости. + +### API + +Добавлен новый endpoint просмотра расписания: + +```http +GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 +GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 +``` + +Правило запроса: нужно передать ровно один идентификатор — `groupId` или `teacherId`. + +Ответ — массив `RenderedLessonDto`, где у каждого занятия есть: + +- полная дата занятия; +- номер дня недели; +- название дня; +- номер недели семестра; +- чётность; +- временной слот; +- дисциплина; +- преподаватель; +- аудитория; +- тип и формат занятия; +- группы; +- лимит часов правила; +- уже списанные часы перед занятием; +- остаток часов после занятия. + +Добавлены административные API: + +| API | Назначение | +|-----|------------| +| `/api/admin/time-slots` | CRUD временных слотов | +| `/api/admin/calendar/years` | CRUD учебных годов | +| `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | +| `/api/admin/calendar/semesters/{id}` | Обновление семестра | +| `/api/admin/calendar/holidays` | CRUD праздников | +| `/api/admin/calendar/matrix` | Получение и массовое сохранение матрицы учебного графика | +| `/api/admin/schedule-rules` | CRUD правил расписания | + +### Авторизация + +Ответ `POST /api/auth/login` расширен полем `userId`. + +Frontend сохраняет его в `localStorage`, чтобы кабинет преподавателя мог запрашивать личное расписание через: + +```http +GET /api/schedule?teacherId={userId}&startDate=...&endDate=... +``` + +### Frontend + +Заглушки кабинетов заменены на базовые экраны просмотра расписания. + +#### Кабинет студента + +Файл: `frontend/student/index.html`. + +Текущая модель пользователя-студента не содержит прямой связи с учебной группой, поэтому экран работает через выбор группы: + +1. Загружает список групп из `/api/groups`. +2. Сохраняет выбранную группу в `localStorage.studentGroupId`. +3. Строит недельный диапазон дат. +4. Запрашивает расписание через `GET /api/schedule?groupId=...`. +5. Показывает занятия по дням недели. + +#### Кабинет преподавателя + +Файл: `frontend/teacher/index.html`. + +Экран использует `localStorage.userId`, который появляется после нового ответа авторизации: + +1. Определяет текущую неделю. +2. Позволяет переключать неделю стрелками или через выбор даты. +3. Запрашивает расписание через `GET /api/schedule?teacherId=...`. +4. Показывает занятия преподавателя в недельной сетке. +5. В занятии отображаются дисциплина, время, тип, аудитория и группы. + +## Как работает генерация расписания + +Генерация выполняется на backend по запросу клиента. Фактические занятия не хранятся отдельными строками на каждую дату, а вычисляются из правил. + +### Расписание группы + +Алгоритм `buildScheduleForGroup(groupId, startDate, endDate)`: + +1. Проверяет корректность диапазона дат. +2. Находит группу. +3. Для каждой даты диапазона определяет семестр. +4. Вычисляет номер недели семестра. +5. Вычисляет текущий курс группы по `year_start_study`. +6. Проверяет матрицу учебного графика. +7. Пропускает дату, если это праздник или не `THEORY`. +8. Загружает правила группы для найденного семестра. +9. Для каждого правила считает, сколько академических часов уже проведено до текущей даты. +10. Если лимит `total_academic_hours` ещё не исчерпан, проецирует подходящие слоты правила на текущую дату. + +### Расписание преподавателя + +Алгоритм `buildScheduleForTeacher(teacherId, startDate, endDate)` похож, но правила ищутся не по группе, а по преподавателю в `schedule_rule_slots.teacher_id`. + +В ответе занятие дополнительно содержит все группы, привязанные к правилу. Это поддерживает потоковые лекции. + +### Чётность недели + +Чётность вычисляется от `semesters.start_date`. + +Настройка: + +```properties +app.schedule.even-week-is-upper=false +``` + +По умолчанию: + +- нечётная неделя семестра соответствует `ODD`; +- чётная неделя семестра соответствует `EVEN`. + +Если `app.schedule.even-week-is-upper=true`, соответствие инвертируется. + +### Праздники + +Праздник считается пропуском занятия: + +- занятие не отображается в расписании; +- академические часы не списываются; +- дисциплина фактически продолжается дольше, пока не будет исчерпан лимит часов. + +### Лимит часов + +Каждый `schedule_rules.total_academic_hours` задаёт общий лимит академических часов дисциплины. + +Генератор симулирует период от `active_from_date` до запрошенной даты и считает только реально проведённые слоты: + +- один слот = 2 академических часа; +- праздники не считаются; +- недели с активностью не `THEORY` не считаются; +- после достижения лимита правило перестаёт выводиться. + +## Поток данных + +Новая базовая БД сразу создаёт рабочую модель так: + +```text +V1__init.sql + -> time_slots + -> academic_years / semesters / holidays / academic_calendar_matrix + -> schedule_rules + -> schedule_rule_groups + -> schedule_rule_slots +``` + +Для просмотра: + +```text +frontend + -> GET /api/schedule + -> ScheduleController + -> ScheduleGeneratorService + -> schedule_rules + schedule_rule_slots + calendar tables + -> RenderedLessonDto[] +``` + +## Что проверено + +Проверка backend: + +```bash +docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile +``` + +Результат: компиляция backend прошла успешно. + +Проверка миграции: + +1. Поднят временный PostgreSQL-контейнер на образе `postgres:alpine3.23`. +2. Применён `V1__init.sql`. +3. Проверены контрольные количества строк новой модели. + +Контрольный результат: + +| Объект | Количество | +|--------|------------| +| `schedule_rules` | 4 | +| `schedule_rule_groups` | 5 | +| `schedule_rule_slots` | 4 | +| `time_slots` | 7 | +| `academic_calendar_matrix` | 132 | +| `lessons` | 0 | +| `schedule_data` | 0 | + +Также выполнен: + +```bash +git diff --check +``` + +Результат: замечаний по whitespace нет. + +## Ограничения текущего среза + +Этот срез создаёт рабочее ядро динамического расписания, но ещё не закрывает весь продуктовый объём. + +Ограничения: + +- полноценный UI конструктора правил в админке ещё не реализован; +- визуальный редактор матрицы учебного графика в админке ещё не реализован; +- UI управления праздниками и учебными годами пока доступен только через API; +- строгая проверка конфликтов преподавателей, аудиторий и групп при сохранении правил ещё не реализована; +- студент пока выбирает группу вручную, потому что в модели пользователя нет связи `student -> group`; +- старые таблицы и контроллеры `lessons` / `schedule_data` ещё физически есть, но больше не используются для seed-расписания. + +## Следующий этап + +Рекомендуемый следующий этап: + +1. Добавить в админку вкладку настройки временных слотов. +2. Добавить UI учебных годов, семестров и праздников. +3. Добавить визуальную матрицу учебного графика. +4. Добавить конструктор правил расписания. +5. Реализовать backend-валидацию конфликтов: + - преподаватель не может вести разные занятия одновременно; + - аудитория не может быть занята двумя разными занятиями одновременно; + - группа не может быть на двух занятиях одновременно, кроме корректного деления по подгруппам. +6. Удалить старые экраны, контроллеры, DTO, модели и репозитории `lessons` / `schedule_data`. +7. Удалить старые таблицы `lessons` и `schedule_data` из `V1__init.sql`. + +## Связанные документы + +- `SCHEDULE_PROPOSAL.md` — исходная концепция динамического расписания. +- `SCHEDULE_TASKS.md` — исходная декомпозиция задач. +- `docs/API.md` — описание REST API. +- `docs/DATABASE.md` — описание схемы БД. +- `docs/BUSINESS_LOGIC.md` — бизнес-правила. +- `docs/FRONTEND.md` — frontend-архитектура. diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java new file mode 100644 index 0000000..d3c1214 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java @@ -0,0 +1,317 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.*; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/calendar") +public class AcademicCalendarAdminController { + + private final AcademicYearRepository academicYearRepository; + private final SemesterRepository semesterRepository; + private final HolidayRepository holidayRepository; + private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, + SemesterRepository semesterRepository, + HolidayRepository holidayRepository, + AcademicCalendarMatrixRepository calendarMatrixRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.academicYearRepository = academicYearRepository; + this.semesterRepository = semesterRepository; + this.holidayRepository = holidayRepository; + this.calendarMatrixRepository = calendarMatrixRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping("/years") + public List getYears() { + return academicYearRepository.findAll().stream() + .sorted(Comparator.comparing(AcademicYear::getStartDate).reversed()) + .map(this::toAcademicYearDto) + .toList(); + } + + @PostMapping("/years") + public ResponseEntity createYear(@RequestBody AcademicYearDto request) { + String validationError = validateYear(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (academicYearRepository.findByTitle(request.title()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует")); + } + + AcademicYear year = new AcademicYear(); + applyYear(year, request); + return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + } + + @PutMapping("/years/{id}") + public ResponseEntity updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) { + AcademicYear year = academicYearRepository.findById(id).orElse(null); + if (year == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validateYear(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + applyYear(year, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + } + + @DeleteMapping("/years/{id}") + public ResponseEntity deleteYear(@PathVariable Long id) { + if (!academicYearRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + academicYearRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Учебный год удалён")); + } + + @GetMapping("/years/{academicYearId}/semesters") + public List getSemesters(@PathVariable Long academicYearId) { + return semesterRepository.findByAcademicYearIdOrderByStartDateAsc(academicYearId).stream() + .map(this::toSemesterDto) + .toList(); + } + + @PostMapping("/years/{academicYearId}/semesters") + public ResponseEntity createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) { + AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null); + if (year == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + String validationError = validateSemester(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году")); + } + + Semester semester = new Semester(); + semester.setAcademicYear(year); + applySemester(semester, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); + } + + @PutMapping("/semesters/{id}") + public ResponseEntity updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) { + Semester semester = semesterRepository.findById(id).orElse(null); + if (semester == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validateSemester(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + applySemester(semester, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); + } + + @GetMapping("/holidays") + public ResponseEntity getHolidays(@RequestParam Long academicYearId) { + if (!academicYearRepository.existsById(academicYearId)) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + return ResponseEntity.ok(holidayRepository.findByAcademicYearIdOrderByDateAsc(academicYearId).stream() + .map(this::toHolidayDto) + .toList()); + } + + @PostMapping("/holidays") + public ResponseEntity createHoliday(@RequestBody HolidayDto request) { + AcademicYear year = academicYearRepository.findById(request.academicYearId()).orElse(null); + if (year == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + if (request.date() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна")); + } + if (holidayRepository.existsByAcademicYearIdAndDate(year.getId(), request.date())) { + return ResponseEntity.badRequest().body(Map.of("message", "Эта дата уже добавлена в исключения")); + } + + Holiday holiday = new Holiday(); + holiday.setAcademicYear(year); + holiday.setDate(request.date()); + holiday.setDescription(request.description()); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + } + + @PutMapping("/holidays/{id}") + public ResponseEntity updateHoliday(@PathVariable Long id, @RequestBody HolidayDto request) { + Holiday holiday = holidayRepository.findById(id).orElse(null); + if (holiday == null) { + return ResponseEntity.notFound().build(); + } + if (request.date() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна")); + } + holiday.setDate(request.date()); + holiday.setDescription(request.description()); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + } + + @DeleteMapping("/holidays/{id}") + public ResponseEntity deleteHoliday(@PathVariable Long id) { + if (!holidayRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + holidayRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Праздник удалён")); + } + + @GetMapping("/matrix") + public ResponseEntity getMatrix(@RequestParam Long semesterId, + @RequestParam Integer courseNumber, + @RequestParam Long specialtyId) { + if (!semesterRepository.existsById(semesterId)) { + return ResponseEntity.badRequest().body(Map.of("message", "Семестр не найден")); + } + return ResponseEntity.ok(calendarMatrixRepository + .findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc(semesterId, courseNumber, specialtyId) + .stream() + .map(this::toMatrixDto) + .toList()); + } + + @PutMapping("/matrix") + @Transactional + public ResponseEntity saveMatrix(@RequestBody List rows) { + if (rows == null || rows.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну строку матрицы")); + } + + try { + for (AcademicCalendarMatrixDto row : rows) { + Semester semester = semesterRepository.findById(row.semesterId()) + .orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); + if (row.courseNumber() == null || row.courseNumber() <= 0 + || row.specialtyId() == null + || row.weekNumber() == null || row.weekNumber() <= 0) { + throw new IllegalArgumentException("Курс, специальность и номер недели обязательны"); + } + AcademicCalendarMatrix matrix = calendarMatrixRepository + .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( + row.semesterId(), + row.courseNumber(), + row.specialtyId(), + row.weekNumber() + ) + .orElseGet(AcademicCalendarMatrix::new); + matrix.setSemester(semester); + matrix.setCourseNumber(row.courseNumber()); + matrix.setSpecialtyId(row.specialtyId()); + matrix.setWeekNumber(row.weekNumber()); + matrix.setActivityType(row.activityType() == null ? AcademicActivityType.THEORY : row.activityType()); + calendarMatrixRepository.save(matrix); + } + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Матрица учебного графика сохранена")); + } + + private String validateYear(AcademicYearDto request) { + if (request.title() == null || request.title().isBlank()) { + return "Название учебного года обязательно"; + } + if (request.startDate() == null || request.endDate() == null) { + return "Даты начала и окончания учебного года обязательны"; + } + if (request.endDate().isBefore(request.startDate())) { + return "Дата окончания не может быть раньше даты начала"; + } + return null; + } + + private String validateSemester(SemesterDto request) { + if (request.semesterType() == null) { + return "Тип семестра обязателен"; + } + if (request.startDate() == null || request.endDate() == null) { + return "Даты начала и окончания семестра обязательны"; + } + if (request.endDate().isBefore(request.startDate())) { + return "Дата окончания семестра не может быть раньше даты начала"; + } + return null; + } + + private void applyYear(AcademicYear year, AcademicYearDto request) { + year.setTitle(request.title().trim()); + year.setStartDate(request.startDate()); + year.setEndDate(request.endDate()); + } + + private void applySemester(Semester semester, SemesterDto request) { + semester.setSemesterType(request.semesterType()); + semester.setStartDate(request.startDate()); + semester.setEndDate(request.endDate()); + } + + private AcademicYearDto toAcademicYearDto(AcademicYear year) { + List semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId()) + .stream() + .map(this::toSemesterDto) + .toList(); + return new AcademicYearDto(year.getId(), year.getTitle(), year.getStartDate(), year.getEndDate(), semesters); + } + + private SemesterDto toSemesterDto(Semester semester) { + return new SemesterDto( + semester.getId(), + semester.getAcademicYear().getId(), + semester.getAcademicYear().getTitle(), + semester.getSemesterType(), + semester.getStartDate(), + semester.getEndDate() + ); + } + + private HolidayDto toHolidayDto(Holiday holiday) { + return new HolidayDto( + holiday.getId(), + holiday.getDate(), + holiday.getAcademicYear().getId(), + holiday.getAcademicYear().getTitle(), + holiday.getDescription() + ); + } + + private AcademicCalendarMatrixDto toMatrixDto(AcademicCalendarMatrix matrix) { + return new AcademicCalendarMatrixDto( + matrix.getId(), + matrix.getSemester().getId(), + matrix.getCourseNumber(), + matrix.getSpecialtyId(), + matrix.getWeekNumber(), + matrix.getActivityType() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 14c7f2a..7ef5b69 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -47,6 +47,6 @@ public class AuthController { String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); Long departmentId = user.getDepartmentId(); - return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId)); + return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId())); } } diff --git a/backend/src/main/java/com/magistr/app/controller/LessonsController.java b/backend/src/main/java/com/magistr/app/controller/LessonsController.java index 3fe103c..d0b4119 100755 --- a/backend/src/main/java/com/magistr/app/controller/LessonsController.java +++ b/backend/src/main/java/com/magistr/app/controller/LessonsController.java @@ -162,6 +162,7 @@ public class LessonsController { } //Запрос для получения всего списка занятий + @Deprecated @GetMapping public List getAllLessons() { logger.info("Запрос на получение всех занятий"); @@ -221,6 +222,7 @@ public class LessonsController { } //Запрос на получение всех занятий для конкретного преподавателя + @Deprecated @GetMapping("/{teacherId}") public ResponseEntity getLessonsById(@PathVariable Long teacherId) { logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId); @@ -504,4 +506,4 @@ public class LessonsController { response.put("time", lesson.getTime()); return response; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java new file mode 100644 index 0000000..58cea9d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java @@ -0,0 +1,56 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.service.ScheduleGeneratorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/schedule") +public class ScheduleController { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class); + + private final ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleController(ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public ResponseEntity getSchedule( + @RequestParam(required = false) Long groupId, + @RequestParam(required = false) Long teacherId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + logger.info("Запрос динамического расписания: groupId={}, teacherId={}, startDate={}, endDate={}", + groupId, teacherId, startDate, endDate); + + if ((groupId == null && teacherId == null) || (groupId != null && teacherId != null)) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId")); + } + + try { + List lessons = groupId != null + ? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate) + : scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); + return ResponseEntity.ok(lessons); + } catch (IllegalArgumentException e) { + logger.info("Ошибка запроса динамического расписания: {}", e.getMessage()); + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } catch (Exception e) { + logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e); + return ResponseEntity.internalServerError() + .body(Map.of("message", "Произошла ошибка при генерации расписания")); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java new file mode 100644 index 0000000..3787a44 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -0,0 +1,253 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.ScheduleRuleDto; +import com.magistr.app.dto.ScheduleRuleSlotDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.time.DayOfWeek; +import java.util.*; + +@RestController +@RequestMapping("/api/admin/schedule-rules") +public class ScheduleRuleAdminController { + + private final ScheduleRuleRepository scheduleRuleRepository; + private final SubjectRepository subjectRepository; + private final SemesterRepository semesterRepository; + private final GroupRepository groupRepository; + private final TimeSlotRepository timeSlotRepository; + private final UserRepository userRepository; + private final ClassroomRepository classroomRepository; + private final LessonTypesRepository lessonTypesRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository, + SubjectRepository subjectRepository, + SemesterRepository semesterRepository, + GroupRepository groupRepository, + TimeSlotRepository timeSlotRepository, + UserRepository userRepository, + ClassroomRepository classroomRepository, + LessonTypesRepository lessonTypesRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleRuleRepository = scheduleRuleRepository; + this.subjectRepository = subjectRepository; + this.semesterRepository = semesterRepository; + this.groupRepository = groupRepository; + this.timeSlotRepository = timeSlotRepository; + this.userRepository = userRepository; + this.classroomRepository = classroomRepository; + this.lessonTypesRepository = lessonTypesRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getAll(@RequestParam(required = false) Long semesterId, + @RequestParam(required = false) Long groupId) { + return scheduleRuleRepository.findAllWithDetails().stream() + .filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId)) + .filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId))) + .map(this::toDto) + .toList(); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + return scheduleRuleRepository.findByIdWithDetails(id) + .>map(rule -> ResponseEntity.ok(toDto(rule))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + @Transactional + public ResponseEntity create(@RequestBody ScheduleRuleDto request) { + String validationError = validateRule(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + try { + ScheduleRule rule = new ScheduleRule(); + applyRule(rule, request); + ScheduleRule saved = scheduleRuleRepository.save(rule); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @PutMapping("/{id}") + @Transactional + public ResponseEntity update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) { + ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null); + if (rule == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validateRule(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + try { + applyRule(rule, request); + ScheduleRule saved = scheduleRuleRepository.save(rule); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!scheduleRuleRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + scheduleRuleRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Правило расписания удалено")); + } + + private String validateRule(ScheduleRuleDto request) { + if (request.subjectId() == null) { + return "Дисциплина обязательна"; + } + if (request.semesterId() == null) { + return "Семестр обязателен"; + } + if (request.activeFromDate() == null) { + return "Дата начала правила обязательна"; + } + if (request.totalAcademicHours() == null || request.totalAcademicHours() <= 0) { + return "Количество академических часов должно быть больше нуля"; + } + if (request.groupIds() == null || request.groupIds().isEmpty()) { + return "Нужно выбрать хотя бы одну группу"; + } + if (request.slots() == null || request.slots().isEmpty()) { + return "Добавьте хотя бы один слот занятия"; + } + return null; + } + + private void applyRule(ScheduleRule rule, ScheduleRuleDto request) { + Subject subject = subjectRepository.findById(request.subjectId()) + .orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена")); + 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("Одна или несколько групп не найдены"); + } + + rule.setSubject(subject); + rule.setSemester(semester); + rule.setActiveFromDate(request.activeFromDate()); + rule.setTotalAcademicHours(request.totalAcademicHours()); + rule.getGroups().clear(); + rule.getGroups().addAll(groups); + + rule.getSlots().clear(); + for (ScheduleRuleSlotDto slotDto : request.slots()) { + rule.getSlots().add(buildSlot(rule, slotDto)); + } + } + + private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) { + if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) { + throw new IllegalArgumentException("День недели должен быть от 1 до 7"); + } + if (slotDto.parity() == null) { + throw new IllegalArgumentException("Чётность недели обязательна"); + } + TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId()) + .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); + User teacher = userRepository.findById(slotDto.teacherId()) + .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); + Classroom classroom = classroomRepository.findById(slotDto.classroomId()) + .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")); + LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) + .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); + if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) { + throw new IllegalArgumentException("Формат занятия обязателен"); + } + + ScheduleRuleSlot slot = new ScheduleRuleSlot(); + slot.setScheduleRule(rule); + slot.setDayOfWeek(slotDto.dayOfWeek()); + slot.setParity(slotDto.parity()); + slot.setTimeSlot(timeSlot); + slot.setSubgroupId(slotDto.subgroupId()); + slot.setTeacher(teacher); + slot.setClassroom(classroom); + slot.setLessonType(lessonType); + slot.setLessonFormat(slotDto.lessonFormat().trim()); + return slot; + } + + private ScheduleRuleDto toDto(ScheduleRule rule) { + List groups = rule.getGroups().stream() + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + List slots = rule.getSlots().stream() + .sorted(Comparator + .comparing(ScheduleRuleSlot::getDayOfWeek) + .thenComparing(slot -> slot.getTimeSlot().getOrderNumber()) + .thenComparing(ScheduleRuleSlot::getId)) + .map(this::toSlotDto) + .toList(); + + return new ScheduleRuleDto( + rule.getId(), + rule.getSubject().getId(), + rule.getSubject().getName(), + rule.getSemester().getId(), + rule.getSemester().getAcademicYear().getTitle(), + rule.getSemester().getSemesterType(), + rule.getActiveFromDate(), + rule.getTotalAcademicHours(), + groups.stream().map(StudentGroup::getId).toList(), + groups.stream().map(StudentGroup::getName).toList(), + slots + ); + } + + private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) { + TimeSlot timeSlot = slot.getTimeSlot(); + return new ScheduleRuleSlotDto( + slot.getId(), + slot.getDayOfWeek(), + dayName(slot.getDayOfWeek()), + slot.getParity(), + timeSlot.getId(), + timeSlot.getOrderNumber(), + formatTimeSlot(timeSlot), + slot.getSubgroupId(), + slot.getTeacher().getId(), + ScheduleGeneratorService.displayUserName(slot.getTeacher()), + slot.getClassroom().getId(), + slot.getClassroom().getName(), + slot.getLessonType().getId(), + slot.getLessonType().getLessonType(), + slot.getLessonFormat() + ); + } + + private String dayName(Integer dayOfWeek) { + if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) { + return "Неизвестно"; + } + return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek)); + } + + private String formatTimeSlot(TimeSlot timeSlot) { + return timeSlot.getStartTime() + " - " + timeSlot.getEndTime(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java new file mode 100644 index 0000000..987308d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java @@ -0,0 +1,106 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.TimeSlotDto; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.repository.TimeSlotRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/time-slots") +public class TimeSlotAdminController { + + private final TimeSlotRepository timeSlotRepository; + + public TimeSlotAdminController(TimeSlotRepository timeSlotRepository) { + this.timeSlotRepository = timeSlotRepository; + } + + @GetMapping + public List getAll() { + return timeSlotRepository.findAllByOrderByOrderNumberAsc().stream() + .map(this::toDto) + .toList(); + } + + @PostMapping + public ResponseEntity create(@RequestBody TimeSlotDto request) { + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (timeSlotRepository.existsByOrderNumber(request.orderNumber())) { + return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует")); + } + + TimeSlot timeSlot = new TimeSlot(); + apply(timeSlot, request); + return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody TimeSlotDto request) { + TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null); + if (timeSlot == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (timeSlotRepository.existsByOrderNumber(request.orderNumber()) + && !timeSlot.getOrderNumber().equals(request.orderNumber())) { + return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует")); + } + + apply(timeSlot, request); + return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!timeSlotRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + timeSlotRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); + } + + private String validate(TimeSlotDto request) { + if (request.orderNumber() == null || request.orderNumber() <= 0) { + return "Номер пары должен быть больше нуля"; + } + if (request.startTime() == null || request.endTime() == null) { + return "Время начала и окончания обязательно"; + } + if (!request.startTime().isBefore(request.endTime())) { + return "Время начала должно быть раньше времени окончания"; + } + return null; + } + + private void apply(TimeSlot timeSlot, TimeSlotDto request) { + timeSlot.setOrderNumber(request.orderNumber()); + timeSlot.setStartTime(request.startTime()); + timeSlot.setEndTime(request.endTime()); + int duration = request.durationMinutes() != null && request.durationMinutes() > 0 + ? request.durationMinutes() + : (int) Duration.between(request.startTime(), request.endTime()).toMinutes(); + timeSlot.setDurationMinutes(duration); + } + + private TimeSlotDto toDto(TimeSlot timeSlot) { + return new TimeSlotDto( + timeSlot.getId(), + timeSlot.getOrderNumber(), + timeSlot.getStartTime(), + timeSlot.getEndTime(), + timeSlot.getDurationMinutes() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java new file mode 100644 index 0000000..6fad2dd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java @@ -0,0 +1,13 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.AcademicActivityType; + +public record AcademicCalendarMatrixDto( + Long id, + Long semesterId, + Integer courseNumber, + Long specialtyId, + Integer weekNumber, + AcademicActivityType activityType +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java new file mode 100644 index 0000000..5671392 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java @@ -0,0 +1,13 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; +import java.util.List; + +public record AcademicYearDto( + Long id, + String title, + LocalDate startDate, + LocalDate endDate, + List semesters +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/HolidayDto.java b/backend/src/main/java/com/magistr/app/dto/HolidayDto.java new file mode 100644 index 0000000..8c0bc19 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/HolidayDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record HolidayDto( + Long id, + LocalDate date, + Long academicYearId, + String academicYearTitle, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java index 851619b..8b8b4e8 100755 --- a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java @@ -8,17 +8,23 @@ public class LoginResponse { private String role; private String redirect; private Long departmentId; + private Long userId; public LoginResponse() { } public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) { + this(success, message, token, role, redirect, departmentId, null); + } + + public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId, Long userId) { this.success = success; this.message = message; this.token = token; this.role = role; this.redirect = redirect; this.departmentId = departmentId; + this.userId = userId; } public boolean isSuccess() { @@ -68,4 +74,12 @@ public class LoginResponse { public void setDepartmentId(Long departmentId) { this.departmentId = departmentId; } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java new file mode 100644 index 0000000..59a5f25 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -0,0 +1,39 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.AcademicActivityType; +import com.magistr.app.model.ScheduleParity; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public record RenderedLessonDto( + Long scheduleRuleId, + Long scheduleRuleSlotId, + LocalDate date, + Integer dayOfWeek, + String dayName, + Integer weekNumber, + ScheduleParity parity, + Long timeSlotId, + Integer timeSlotOrder, + LocalTime startTime, + LocalTime endTime, + Long subjectId, + String subjectName, + Long teacherId, + String teacherName, + Long classroomId, + String classroomName, + Long lessonTypeId, + String lessonTypeName, + String lessonFormat, + Long subgroupId, + List groupIds, + List groupNames, + AcademicActivityType activityType, + Integer totalAcademicHours, + Integer consumedAcademicHoursBeforeLesson, + Integer remainingAcademicHoursAfterLesson +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java new file mode 100644 index 0000000..be3b0ed --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java @@ -0,0 +1,21 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.SemesterType; + +import java.time.LocalDate; +import java.util.List; + +public record ScheduleRuleDto( + Long id, + Long subjectId, + String subjectName, + Long semesterId, + String academicYearTitle, + SemesterType semesterType, + LocalDate activeFromDate, + Integer totalAcademicHours, + List groupIds, + List groupNames, + List slots +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java new file mode 100644 index 0000000..6f01af3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java @@ -0,0 +1,22 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.ScheduleParity; + +public record ScheduleRuleSlotDto( + Long id, + Integer dayOfWeek, + String dayName, + ScheduleParity parity, + Long timeSlotId, + Integer timeSlotOrder, + String timeSlotLabel, + Long subgroupId, + Long teacherId, + String teacherName, + Long classroomId, + String classroomName, + Long lessonTypeId, + String lessonTypeName, + String lessonFormat +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/SemesterDto.java b/backend/src/main/java/com/magistr/app/dto/SemesterDto.java new file mode 100644 index 0000000..16b22d0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SemesterDto.java @@ -0,0 +1,15 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.SemesterType; + +import java.time.LocalDate; + +public record SemesterDto( + Long id, + Long academicYearId, + String academicYearTitle, + SemesterType semesterType, + LocalDate startDate, + LocalDate endDate +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java new file mode 100644 index 0000000..33df851 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +import java.time.LocalTime; + +public record TimeSlotDto( + Long id, + Integer orderNumber, + LocalTime startTime, + LocalTime endTime, + Integer durationMinutes +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java b/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java new file mode 100644 index 0000000..2b4cfb2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java @@ -0,0 +1,8 @@ +package com.magistr.app.model; + +public enum AcademicActivityType { + THEORY, + EXAM, + VACATION, + PRACTICE +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java new file mode 100644 index 0000000..1d92917 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java @@ -0,0 +1,77 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendar_matrix") +public class AcademicCalendarMatrix { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @Column(name = "course_number", nullable = false) + private Integer courseNumber; + + @Column(name = "specialty_id", nullable = false) + private Long specialtyId; + + @Column(name = "week_number", nullable = false) + private Integer weekNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "activity_type", nullable = false, length = 20) + private AcademicActivityType activityType; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Semester getSemester() { + return semester; + } + + public void setSemester(Semester semester) { + this.semester = semester; + } + + public Integer getCourseNumber() { + return courseNumber; + } + + public void setCourseNumber(Integer courseNumber) { + this.courseNumber = courseNumber; + } + + public Long getSpecialtyId() { + return specialtyId; + } + + public void setSpecialtyId(Long specialtyId) { + this.specialtyId = specialtyId; + } + + public Integer getWeekNumber() { + return weekNumber; + } + + public void setWeekNumber(Integer weekNumber) { + this.weekNumber = weekNumber; + } + + public AcademicActivityType getActivityType() { + return activityType; + } + + public void setActivityType(AcademicActivityType activityType) { + this.activityType = activityType; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicYear.java b/backend/src/main/java/com/magistr/app/model/AcademicYear.java new file mode 100644 index 0000000..87920c3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicYear.java @@ -0,0 +1,55 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "academic_years") +public class AcademicYear { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 20) + private String title; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Holiday.java b/backend/src/main/java/com/magistr/app/model/Holiday.java new file mode 100644 index 0000000..c01ca07 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Holiday.java @@ -0,0 +1,56 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "holidays") +public class Holiday { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate date; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @Column(length = 255) + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleParity.java b/backend/src/main/java/com/magistr/app/model/ScheduleParity.java new file mode 100644 index 0000000..421a3a3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleParity.java @@ -0,0 +1,7 @@ +package com.magistr.app.model; + +public enum ScheduleParity { + BOTH, + EVEN, + ODD +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java new file mode 100644 index 0000000..8cddb99 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -0,0 +1,97 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "schedule_rules") +public class ScheduleRule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "subject_id", nullable = false) + private Subject subject; + + @ManyToOne(optional = false) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @Column(name = "active_from_date", nullable = false) + private LocalDate activeFromDate; + + @Column(name = "total_academic_hours", nullable = false) + private Integer totalAcademicHours; + + @ManyToMany + @JoinTable( + name = "schedule_rule_groups", + joinColumns = @JoinColumn(name = "schedule_rule_id"), + inverseJoinColumns = @JoinColumn(name = "group_id") + ) + private Set groups = new HashSet<>(); + + @OneToMany(mappedBy = "scheduleRule", cascade = CascadeType.ALL, orphanRemoval = true) + private Set slots = new HashSet<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Subject getSubject() { + return subject; + } + + public void setSubject(Subject subject) { + this.subject = subject; + } + + public Semester getSemester() { + return semester; + } + + public void setSemester(Semester semester) { + this.semester = semester; + } + + public LocalDate getActiveFromDate() { + return activeFromDate; + } + + public void setActiveFromDate(LocalDate activeFromDate) { + this.activeFromDate = activeFromDate; + } + + public Integer getTotalAcademicHours() { + return totalAcademicHours; + } + + public void setTotalAcademicHours(Integer totalAcademicHours) { + this.totalAcademicHours = totalAcademicHours; + } + + public Set getGroups() { + return groups; + } + + public void setGroups(Set groups) { + this.groups = groups; + } + + public Set getSlots() { + return slots; + } + + public void setSlots(Set slots) { + this.slots = slots; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java new file mode 100644 index 0000000..a837635 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java @@ -0,0 +1,125 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "schedule_rule_slots") +public class ScheduleRuleSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "schedule_rule_id", nullable = false) + private ScheduleRule scheduleRule; + + @Column(name = "day_of_week", nullable = false) + private Integer dayOfWeek; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private ScheduleParity parity; + + @ManyToOne(optional = false) + @JoinColumn(name = "time_slot_id", nullable = false) + private TimeSlot timeSlot; + + @Column(name = "subgroup_id") + private Long subgroupId; + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @ManyToOne(optional = false) + @JoinColumn(name = "classroom_id", nullable = false) + private Classroom classroom; + + @ManyToOne(optional = false) + @JoinColumn(name = "lesson_type_id", nullable = false) + private LessonType lessonType; + + @Column(name = "lesson_format", nullable = false, length = 30) + private String lessonFormat; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public ScheduleRule getScheduleRule() { + return scheduleRule; + } + + public void setScheduleRule(ScheduleRule scheduleRule) { + this.scheduleRule = scheduleRule; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public ScheduleParity getParity() { + return parity; + } + + public void setParity(ScheduleParity parity) { + this.parity = parity; + } + + public TimeSlot getTimeSlot() { + return timeSlot; + } + + public void setTimeSlot(TimeSlot timeSlot) { + this.timeSlot = timeSlot; + } + + public Long getSubgroupId() { + return subgroupId; + } + + public void setSubgroupId(Long subgroupId) { + this.subgroupId = subgroupId; + } + + public User getTeacher() { + return teacher; + } + + public void setTeacher(User teacher) { + this.teacher = teacher; + } + + public Classroom getClassroom() { + return classroom; + } + + public void setClassroom(Classroom classroom) { + this.classroom = classroom; + } + + public LessonType getLessonType() { + return lessonType; + } + + public void setLessonType(LessonType lessonType) { + this.lessonType = lessonType; + } + + public String getLessonFormat() { + return lessonFormat; + } + + public void setLessonFormat(String lessonFormat) { + this.lessonFormat = lessonFormat; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Semester.java b/backend/src/main/java/com/magistr/app/model/Semester.java new file mode 100644 index 0000000..85a79e1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Semester.java @@ -0,0 +1,68 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "semesters") +public class Semester { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @Enumerated(EnumType.STRING) + @Column(name = "semester_type", nullable = false, length = 20) + private SemesterType semesterType; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public SemesterType getSemesterType() { + return semesterType; + } + + public void setSemesterType(SemesterType semesterType) { + this.semesterType = semesterType; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlot.java b/backend/src/main/java/com/magistr/app/model/TimeSlot.java new file mode 100644 index 0000000..f1a3402 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TimeSlot.java @@ -0,0 +1,66 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalTime; + +@Entity +@Table(name = "time_slots") +public class TimeSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_number", nullable = false) + private Integer orderNumber; + + @Column(name = "start_time", nullable = false) + private LocalTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalTime endTime; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Integer getOrderNumber() { + return orderNumber; + } + + public void setOrderNumber(Integer orderNumber) { + this.orderNumber = orderNumber; + } + + public LocalTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalTime startTime) { + this.startTime = startTime; + } + + public LocalTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalTime endTime) { + this.endTime = endTime; + } + + public Integer getDurationMinutes() { + return durationMinutes; + } + + public void setDurationMinutes(Integer durationMinutes) { + this.durationMinutes = durationMinutes; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java new file mode 100644 index 0000000..70964d6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java @@ -0,0 +1,23 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarMatrix; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarMatrixRepository extends JpaRepository { + + Optional findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( + Long semesterId, + Integer courseNumber, + Long specialtyId, + Integer weekNumber + ); + + List findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc( + Long semesterId, + Integer courseNumber, + Long specialtyId + ); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java new file mode 100644 index 0000000..ffc3846 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java @@ -0,0 +1,11 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicYear; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AcademicYearRepository extends JpaRepository { + + Optional findByTitle(String title); +} diff --git a/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java b/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java new file mode 100644 index 0000000..43ec732 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java @@ -0,0 +1,16 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Holiday; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface HolidayRepository extends JpaRepository { + + List findByAcademicYearIdOrderByDateAsc(Long academicYearId); + + List findByAcademicYearIdAndDateBetween(Long academicYearId, LocalDate startDate, LocalDate endDate); + + boolean existsByAcademicYearIdAndDate(Long academicYearId, LocalDate date); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java new file mode 100644 index 0000000..b4573c6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -0,0 +1,85 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleRule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ScheduleRuleRepository extends JpaRepository { + + @Query(""" + select distinct r + from ScheduleRule r + join r.groups g + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where g.id = :groupId + and sem.id = :semesterId + """) + List findByGroupIdAndSemesterId( + @Param("groupId") Long groupId, + @Param("semesterId") Long semesterId + ); + + @Query(""" + select distinct r + from ScheduleRule r + join r.slots teacherSlot + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where teacherSlot.teacher.id = :teacherId + and sem.id = :semesterId + """) + List findByTeacherIdAndSemesterId( + @Param("teacherId") Long teacherId, + @Param("semesterId") Long semesterId + ); + + @Query(""" + select distinct r + from ScheduleRule r + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + order by r.id desc + """) + List findAllWithDetails(); + + @Query(""" + select distinct r + from ScheduleRule r + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where r.id = :id + """) + java.util.Optional findByIdWithDetails(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java new file mode 100644 index 0000000..a6f2d74 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java @@ -0,0 +1,7 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleRuleSlot; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScheduleRuleSlotRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java new file mode 100644 index 0000000..d347f61 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java @@ -0,0 +1,18 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Semester; +import com.magistr.app.model.SemesterType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface SemesterRepository extends JpaRepository { + + Optional findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate); + + List findByAcademicYearIdOrderByStartDateAsc(Long academicYearId); + + Optional findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java new file mode 100644 index 0000000..7576c1a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java @@ -0,0 +1,13 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TimeSlot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TimeSlotRepository extends JpaRepository { + + List findAllByOrderByOrderNumberAsc(); + + boolean existsByOrderNumber(Integer orderNumber); +} diff --git a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java new file mode 100644 index 0000000..0a2248d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java @@ -0,0 +1,87 @@ +package com.magistr.app.service; + +import com.magistr.app.model.*; +import com.magistr.app.repository.AcademicCalendarMatrixRepository; +import com.magistr.app.repository.HolidayRepository; +import com.magistr.app.repository.SemesterRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +@Service +public class AcademicDateService { + + private final SemesterRepository semesterRepository; + private final HolidayRepository holidayRepository; + private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final boolean evenWeekIsUpper; + + public AcademicDateService(SemesterRepository semesterRepository, + HolidayRepository holidayRepository, + AcademicCalendarMatrixRepository calendarMatrixRepository, + @Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) { + this.semesterRepository = semesterRepository; + this.holidayRepository = holidayRepository; + this.calendarMatrixRepository = calendarMatrixRepository; + this.evenWeekIsUpper = evenWeekIsUpper; + } + + public Optional findSemester(LocalDate date) { + return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date); + } + + public int getWeekNumber(Semester semester, LocalDate date) { + long days = ChronoUnit.DAYS.between(semester.getStartDate(), date); + if (days < 0) { + return 0; + } + return (int) (days / 7) + 1; + } + + public ScheduleParity getParity(Semester semester, LocalDate date) { + int weekNumber = getWeekNumber(semester, date); + boolean evenWeek = weekNumber % 2 == 0; + if (evenWeekIsUpper) { + return evenWeek ? ScheduleParity.ODD : ScheduleParity.EVEN; + } + return evenWeek ? ScheduleParity.EVEN : ScheduleParity.ODD; + } + + public boolean isHoliday(Semester semester, LocalDate date) { + if (semester == null || semester.getAcademicYear() == null) { + return false; + } + return holidayRepository.existsByAcademicYearIdAndDate(semester.getAcademicYear().getId(), date); + } + + public int getCourseNumber(StudentGroup group, LocalDate date) { + int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1; + return academicYearStart - group.getYearStartStudy() + 1; + } + + public AcademicActivityType getActivityType(StudentGroup group, Semester semester, LocalDate date) { + int weekNumber = getWeekNumber(semester, date); + int courseNumber = getCourseNumber(group, date); + if (weekNumber < 1 || courseNumber < 1) { + return AcademicActivityType.VACATION; + } + + return calendarMatrixRepository + .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( + semester.getId(), + courseNumber, + group.getSpecialityCode(), + weekNumber + ) + .map(AcademicCalendarMatrix::getActivityType) + .orElse(AcademicActivityType.THEORY); + } + + public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { + return getActivityType(group, semester, date) == AcademicActivityType.THEORY + && !isHoliday(semester, date); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java new file mode 100644 index 0000000..a7554cc --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -0,0 +1,295 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.model.*; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.ScheduleRuleRepository; +import com.magistr.app.repository.UserRepository; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.TextStyle; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class ScheduleGeneratorService { + + private static final int ACADEMIC_HOURS_PER_SLOT = 2; + private static final long MAX_RANGE_DAYS = 120; + + private final ScheduleRuleRepository scheduleRuleRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final AcademicDateService academicDateService; + private final Map consumedHoursCache = new ConcurrentHashMap<>(); + + public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, + GroupRepository groupRepository, + UserRepository userRepository, + AcademicDateService academicDateService) { + this.scheduleRuleRepository = scheduleRuleRepository; + this.groupRepository = groupRepository; + this.userRepository = userRepository; + this.academicDateService = academicDateService; + } + + public List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); + StudentGroup group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("Группа не найдена")); + + Map> rulesBySemester = new HashMap<>(); + List result = new ArrayList<>(); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty()) { + continue; + } + + Semester semester = semesterOpt.get(); + if (!academicDateService.isTheoryDay(group, semester, date)) { + continue; + } + + List rules = rulesBySemester.computeIfAbsent( + semester.getId(), + semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId) + ); + + for (ScheduleRule rule : sortRules(rules)) { + renderRuleForDate(rule, date, group, null, result); + } + } + + return sortRenderedLessons(result); + } + + public List buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); + if (!userRepository.existsById(teacherId)) { + throw new IllegalArgumentException("Преподаватель не найден"); + } + + Map> rulesBySemester = new HashMap<>(); + List result = new ArrayList<>(); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty()) { + continue; + } + + Semester semester = semesterOpt.get(); + List rules = rulesBySemester.computeIfAbsent( + semester.getId(), + semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId) + ); + + for (ScheduleRule rule : sortRules(rules)) { + renderRuleForDate(rule, date, null, teacherId, result); + } + } + + return sortRenderedLessons(result); + } + + public void clearCache() { + consumedHoursCache.clear(); + } + + private void validateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("Дата начала и дата окончания обязательны"); + } + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); + } + if (java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) { + throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); + } + } + + private List sortRules(List rules) { + return rules.stream() + .sorted(Comparator + .comparing((ScheduleRule rule) -> rule.getSubject().getName(), Comparator.nullsLast(String::compareTo)) + .thenComparing(ScheduleRule::getId)) + .toList(); + } + + private List sortRenderedLessons(List lessons) { + return lessons.stream() + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private void renderRuleForDate(ScheduleRule rule, + LocalDate date, + StudentGroup targetGroup, + Long targetTeacherId, + List result) { + if (rule.getActiveFromDate() != null && date.isBefore(rule.getActiveFromDate())) { + return; + } + if (rule.getTotalAcademicHours() == null || rule.getTotalAcademicHours() <= 0) { + return; + } + + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) { + return; + } + + Semester semester = semesterOpt.get(); + if (academicDateService.isHoliday(semester, date)) { + return; + } + + List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); + if (eligibleGroups.isEmpty()) { + return; + } + + ScheduleParity dateParity = academicDateService.getParity(semester, date); + int dayOfWeek = date.getDayOfWeek().getValue(); + List slots = rule.getSlots().stream() + .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) + .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) + .filter(slot -> targetTeacherId == null || Objects.equals(slot.getTeacher().getId(), targetTeacherId)) + .sorted(Comparator + .comparing((ScheduleRuleSlot slot) -> slot.getTimeSlot().getOrderNumber(), Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ScheduleRuleSlot::getId)) + .toList(); + + if (slots.isEmpty()) { + return; + } + + int consumedHours = calculateConsumedHoursBeforeDate(rule, targetGroup, date); + int weekNumber = academicDateService.getWeekNumber(semester, date); + + for (ScheduleRuleSlot slot : slots) { + if (consumedHours >= rule.getTotalAcademicHours()) { + break; + } + int remainingAfterLesson = Math.max(0, rule.getTotalAcademicHours() - consumedHours - ACADEMIC_HOURS_PER_SLOT); + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, consumedHours, remainingAfterLesson)); + consumedHours += ACADEMIC_HOURS_PER_SLOT; + } + } + + 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 rule.getGroups().stream() + .filter(group -> academicDateService.isTheoryDay(group, semester, date)) + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + } + + private int calculateConsumedHoursBeforeDate(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) { + Long groupId = targetGroup == null ? null : targetGroup.getId(); + String cacheKey = rule.getId() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate; + return consumedHoursCache.computeIfAbsent(cacheKey, ignored -> calculateConsumedHours(rule, targetGroup, targetDate)); + } + + private int calculateConsumedHours(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) { + LocalDate start = rule.getActiveFromDate() == null + ? rule.getSemester().getStartDate() + : rule.getActiveFromDate(); + if (start.isBefore(rule.getSemester().getStartDate())) { + start = rule.getSemester().getStartDate(); + } + + int consumed = 0; + for (LocalDate date = start; date.isBefore(targetDate); date = date.plusDays(1)) { + Semester semester = rule.getSemester(); + if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) { + continue; + } + if (academicDateService.isHoliday(semester, date)) { + continue; + } + if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) { + continue; + } + + int dayOfWeek = date.getDayOfWeek().getValue(); + ScheduleParity dateParity = academicDateService.getParity(semester, date); + long activeSlots = rule.getSlots().stream() + .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) + .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) + .count(); + consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; + if (consumed >= rule.getTotalAcademicHours()) { + return consumed; + } + } + return consumed; + } + + private RenderedLessonDto toRenderedLesson(ScheduleRule rule, + ScheduleRuleSlot slot, + LocalDate date, + int weekNumber, + ScheduleParity dateParity, + List groups, + int consumedBeforeLesson, + int remainingAfterLesson) { + TimeSlot timeSlot = slot.getTimeSlot(); + List sortedGroups = groups.stream() + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + + return new RenderedLessonDto( + rule.getId(), + slot.getId(), + date, + date.getDayOfWeek().getValue(), + dayName(date.getDayOfWeek()), + weekNumber, + dateParity, + timeSlot.getId(), + timeSlot.getOrderNumber(), + timeSlot.getStartTime(), + timeSlot.getEndTime(), + rule.getSubject().getId(), + rule.getSubject().getName(), + slot.getTeacher().getId(), + displayUserName(slot.getTeacher()), + slot.getClassroom().getId(), + slot.getClassroom().getName(), + slot.getLessonType().getId(), + slot.getLessonType().getLessonType(), + slot.getLessonFormat(), + slot.getSubgroupId(), + sortedGroups.stream().map(StudentGroup::getId).toList(), + sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), + AcademicActivityType.THEORY, + rule.getTotalAcademicHours(), + consumedBeforeLesson, + remainingAfterLesson + ); + } + + public static String dayName(DayOfWeek dayOfWeek) { + String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU")); + return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1); + } + + public static String displayUserName(User user) { + if (user.getFullName() != null && !user.getFullName().isBlank()) { + return user.getFullName(); + } + return user.getUsername(); + } +} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 7416e4d..2eb31e9 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -210,7 +210,227 @@ CREATE TABLE IF NOT EXISTS teacher_lesson_types ( ); -- ========================================== --- Основная таблица Расписания (Lessons) +-- Динамическое расписание: календарь, слоты и правила +-- ========================================== + +CREATE TABLE IF NOT EXISTS time_slots ( + id BIGSERIAL PRIMARY KEY, + order_number INT NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + duration_minutes INT NOT NULL, + CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0), + CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time), + CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0), + CONSTRAINT uq_time_slots_order UNIQUE (order_number), + CONSTRAINT uq_time_slots_range UNIQUE (start_time, end_time) +); + +INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VALUES + (1, '08:00', '09:30', 90), + (2, '09:40', '11:10', 90), + (3, '11:40', '13:10', 90), + (4, '13:20', '14:50', 90), + (5, '15:00', '16:30', 90), + (6, '16:50', '18:20', 90), + (7, '18:30', '20:00', 90) +ON CONFLICT (start_time, end_time) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_years ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(20) NOT NULL UNIQUE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date) +); + +INSERT INTO academic_years (title, start_date, end_date) VALUES + ('2024-2025', '2024-09-01', '2025-06-30'), + ('2025-2026', '2025-09-01', '2026-06-30') +ON CONFLICT (title) DO NOTHING; + +CREATE TABLE IF NOT EXISTS semesters ( + id BIGSERIAL PRIMARY KEY, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + semester_type VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + CONSTRAINT chk_semesters_type CHECK (semester_type IN ('autumn', 'spring')), + CONSTRAINT chk_semesters_dates CHECK (start_date <= end_date), + CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type) +); + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31) +FROM academic_years +ON CONFLICT (academic_year_id, semester_type) DO NOTHING; + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date +FROM academic_years +ON CONFLICT (academic_year_id, semester_type) DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date); + +CREATE TABLE IF NOT EXISTS holidays ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + description VARCHAR(255), + CONSTRAINT uq_holidays_year_date UNIQUE (academic_year_id, date) +); + +CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date); + +CREATE TABLE IF NOT EXISTS academic_calendar_matrix ( + id BIGSERIAL PRIMARY KEY, + semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, + course_number INT NOT NULL, + specialty_id BIGINT NOT NULL REFERENCES specialties(id), + week_number INT NOT NULL, + activity_type VARCHAR(20) NOT NULL, + CONSTRAINT chk_calendar_course_positive CHECK (course_number > 0), + CONSTRAINT chk_calendar_week_positive CHECK (week_number > 0), + CONSTRAINT chk_calendar_activity_type CHECK (activity_type IN ('THEORY', 'EXAM', 'VACATION', 'PRACTICE')), + CONSTRAINT uq_calendar_matrix UNIQUE (semester_id, course_number, specialty_id, week_number) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_lookup + ON academic_calendar_matrix(semester_id, course_number, specialty_id, week_number); + +INSERT INTO academic_calendar_matrix (semester_id, course_number, specialty_id, week_number, activity_type) +SELECT + s.id, + SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 AS course_number, + sg.specialty_code AS specialty_id, + weeks.week_number, + 'THEORY' +FROM semesters s +JOIN academic_years ay ON ay.id = s.academic_year_id +CROSS JOIN student_groups sg +CROSS JOIN GENERATE_SERIES(1, 22) AS weeks(week_number) +WHERE SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 > 0 +ON CONFLICT (semester_id, course_number, specialty_id, week_number) DO NOTHING; + +CREATE TABLE IF NOT EXISTS schedule_rules ( + id BIGSERIAL PRIMARY KEY, + subject_id BIGINT NOT NULL REFERENCES subjects(id), + semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, + active_from_date DATE NOT NULL, + total_academic_hours INT NOT NULL, + CONSTRAINT chk_schedule_rules_hours_positive CHECK (total_academic_hours > 0) +); + +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 TABLE IF NOT EXISTS schedule_rule_groups ( + schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, + PRIMARY KEY (schedule_rule_id, group_id) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rule_groups_group ON schedule_rule_groups(group_id); + +CREATE TABLE IF NOT EXISTS schedule_rule_slots ( + id BIGSERIAL PRIMARY KEY, + schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE, + day_of_week INT NOT NULL, + parity VARCHAR(10) NOT NULL, + time_slot_id BIGINT NOT NULL REFERENCES time_slots(id), + subgroup_id BIGINT NULL REFERENCES subgroups(id) ON DELETE SET NULL, + teacher_id BIGINT NOT NULL REFERENCES users(id), + classroom_id BIGINT NOT NULL REFERENCES classrooms(id), + lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), + lesson_format VARCHAR(30) NOT NULL, + 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 ('Очно', 'Онлайн')) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id); + +CREATE TEMP TABLE tmp_default_rules AS +SELECT + NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, + rule_key, + subject_id, + semester_id, + active_from_date, + total_academic_hours +FROM ( + SELECT + data.rule_key, + subj.id AS subject_id, + sem.id AS semester_id, + data.active_from_date, + data.total_academic_hours + FROM (VALUES + ('stream_math', 'Высшая математика', '2025-2026', 'spring', DATE '2026-02-02', 72), + ('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', DATE '2026-02-02', 72), + ('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', DATE '2026-02-02', 72), + ('ib_philosophy', 'Философия', '2025-2026', 'spring', DATE '2026-02-02', 54) + ) AS data(rule_key, subject_name, year_title, semester_type, active_from_date, total_academic_hours) + JOIN subjects subj ON subj.name = data.subject_name + JOIN academic_years ay ON ay.title = data.year_title + JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type +) prepared_rules; + +INSERT INTO schedule_rules (id, subject_id, semester_id, active_from_date, total_academic_hours) +SELECT schedule_rule_id, subject_id, semester_id, active_from_date, total_academic_hours +FROM tmp_default_rules; + +INSERT INTO schedule_rule_groups (schedule_rule_id, group_id) +SELECT + rules.schedule_rule_id, + sg.id +FROM (VALUES + ('stream_math', 'ИВТ-21-1'), + ('stream_math', 'ИБ-41м'), + ('ivt_informatics_lab', 'ИВТ-21-1'), + ('ivt_databases_practice', 'ИВТ-21-1'), + ('ib_philosophy', 'ИБ-41м') +) AS data(rule_key, group_name) +JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key +JOIN student_groups sg ON sg.name = data.group_name +ON CONFLICT DO NOTHING; + +INSERT INTO schedule_rule_slots ( + schedule_rule_id, + day_of_week, + parity, + time_slot_id, + teacher_id, + classroom_id, + lesson_type_id, + lesson_format +) +SELECT + rules.schedule_rule_id, + data.day_of_week, + data.parity, + ts.id, + teacher.id, + classroom.id, + lt.id, + data.lesson_format +FROM (VALUES + ('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'), + ('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'), + ('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'), + ('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн') +) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format) +JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key +JOIN time_slots ts ON ts.order_number = data.time_slot_order +JOIN users teacher ON teacher.username = data.teacher_username +JOIN classrooms classroom ON classroom.name = data.classroom_name +JOIN lesson_types lt ON lt.name = data.lesson_type_name; + +-- ========================================== +-- Временные таблицы старой модели. Будут удалены после очистки старого кода. -- ========================================== CREATE TABLE IF NOT EXISTS lessons ( id BIGSERIAL PRIMARY KEY, @@ -225,17 +445,6 @@ CREATE TABLE IF NOT EXISTS lessons ( time VARCHAR(255) NOT NULL ); -INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES - (2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'), - (1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'), - (2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'), - (1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'), - (2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'), - (1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30'); - --- =============================== --- Создание таблицы данных расписания (schedule_data) --- =============================== CREATE TABLE IF NOT EXISTS schedule_data ( id BIGSERIAL PRIMARY KEY, department_id BIGINT NOT NULL REFERENCES departments(id), @@ -249,18 +458,6 @@ CREATE TABLE IF NOT EXISTS schedule_data ( period VARCHAR(255) NOT NULL ); -INSERT INTO schedule_data (department_id, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) -VALUES (1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (3, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'), - (1, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'), - (1, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'), - (1, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025'); - -- ========================================== -- Функция обновления timestamp -- ========================================== @@ -281,10 +478,18 @@ CREATE TRIGGER update_users_updated_at -- Комментарии к таблицам и полям (для документации) -- ========================================== COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)'; -COMMENT ON TABLE lessons IS 'Основное расписание занятий'; +COMMENT ON TABLE lessons IS 'Временная таблица старой модели расписания до удаления старого кода'; COMMENT ON TABLE departments IS 'Кафедры'; COMMENT ON TABLE specialties IS 'Специальности'; -COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания'; +COMMENT ON TABLE schedule_data IS 'Временная таблица старой модели плановой нагрузки до удаления старого кода'; +COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; +COMMENT ON TABLE academic_years IS 'Учебные годы'; +COMMENT ON TABLE semesters IS 'Семестры учебного года'; +COMMENT ON TABLE holidays IS 'Праздники и неучебные даты'; +COMMENT ON TABLE academic_calendar_matrix IS 'Матрица учебного графика по курсу, специальности и неделе'; +COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; +COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; +COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры'; COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; @@ -324,6 +529,8 @@ COMMENT ON COLUMN student_groups.name IS 'Название группы'; COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов'; COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа'; COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; +COMMENT ON COLUMN student_groups.specialty_code IS 'ID специальности'; +COMMENT ON COLUMN student_groups.year_start_study IS 'Год начала обучения'; COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; COMMENT ON COLUMN subgroups.id IS 'ID подгруппы'; @@ -388,4 +595,4 @@ COMMENT ON COLUMN specialties.specialty_code IS 'Код специальност COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя'; COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета'; -COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; \ No newline at end of file +COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; diff --git a/docs/API.md b/docs/API.md index 675c31c..8ec374f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -25,7 +25,9 @@ "message": "OK", "token": "550e8400-e29b-41d4-a716-446655440000", "role": "ADMIN", - "redirect": "/admin/" + "redirect": "/admin/", + "departmentId": 1, + "userId": 1 } ``` @@ -36,7 +38,9 @@ "message": "Неверное имя пользователя или пароль", "token": null, "role": null, - "redirect": null + "redirect": null, + "departmentId": null, + "userId": null } ``` @@ -95,7 +99,145 @@ --- -## Расписание (Lessons) +## Динамическое расписание + +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат. + +### `GET /api/schedule` + +Получение расписания группы или преподавателя за период. + +**Параметры:** + +| Параметр | Обязателен | Описание | +|----------|------------|----------| +| `groupId` | Да, если нет `teacherId` | ID учебной группы | +| `teacherId` | Да, если нет `groupId` | ID преподавателя | +| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | +| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | + +Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. + +**Пример:** +```http +GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 +``` + +**Ответ:** +```json +[ + { + "scheduleRuleId": 10, + "scheduleRuleSlotId": 31, + "date": "2026-04-27", + "dayOfWeek": 1, + "dayName": "Понедельник", + "weekNumber": 13, + "parity": "ODD", + "timeSlotId": 3, + "timeSlotOrder": 3, + "startTime": "11:40:00", + "endTime": "13:10:00", + "subjectId": 1, + "subjectName": "Высшая математика", + "teacherId": 2, + "teacherName": "Петров Препод Петрович", + "classroomId": 1, + "classroomName": "101 Ленинская", + "lessonTypeId": 1, + "lessonTypeName": "Лекция", + "lessonFormat": "Очно", + "subgroupId": null, + "groupIds": [1], + "groupNames": ["ИВТ-21-1"], + "activityType": "THEORY", + "totalAcademicHours": 72, + "consumedAcademicHoursBeforeLesson": 24, + "remainingAcademicHoursAfterLesson": 46 + } +] +``` + +### `GET /api/admin/time-slots` + +Список временных слотов занятий. CRUD доступен по: + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/time-slots` | Список слотов | +| `POST` | `/api/admin/time-slots` | Создать слот | +| `PUT` | `/api/admin/time-slots/{id}` | Обновить слот | +| `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот | + +**Тело создания/обновления:** +```json +{ + "orderNumber": 1, + "startTime": "08:00:00", + "endTime": "09:30:00", + "durationMinutes": 90 +} +``` + +### Календарный график администратора + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/calendar/years` | Учебные годы с семестрами | +| `POST` | `/api/admin/calendar/years` | Создать учебный год | +| `PUT` | `/api/admin/calendar/years/{id}` | Обновить учебный год | +| `DELETE` | `/api/admin/calendar/years/{id}` | Удалить учебный год | +| `GET` | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | +| `POST` | `/api/admin/calendar/years/{academicYearId}/semesters` | Создать семестр | +| `PUT` | `/api/admin/calendar/semesters/{id}` | Обновить семестр | +| `GET` | `/api/admin/calendar/holidays?academicYearId=1` | Праздники учебного года | +| `POST` | `/api/admin/calendar/holidays` | Создать праздник | +| `PUT` | `/api/admin/calendar/holidays/{id}` | Обновить праздник | +| `DELETE` | `/api/admin/calendar/holidays/{id}` | Удалить праздник | +| `GET` | `/api/admin/calendar/matrix?semesterId=1&courseNumber=1&specialtyId=2` | Матрица учебного графика | +| `PUT` | `/api/admin/calendar/matrix` | Массовое сохранение матрицы | + +### `POST /api/admin/schedule-rules` + +Создание правила динамического расписания. + +```json +{ + "subjectId": 1, + "semesterId": 1, + "activeFromDate": "2026-02-01", + "totalAcademicHours": 72, + "groupIds": [1, 2], + "slots": [ + { + "dayOfWeek": 1, + "parity": "BOTH", + "timeSlotId": 3, + "subgroupId": null, + "teacherId": 2, + "classroomId": 1, + "lessonTypeId": 1, + "lessonFormat": "Очно" + } + ] +} +``` + +CRUD доступен по: + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` | +| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило | +| `POST` | `/api/admin/schedule-rules` | Создать правило | +| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | +| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило | + +--- + +## Расписание (Lessons, deprecated) + +Старые эндпоинты ещё физически есть в коде до удаления старых контроллеров и таблиц. Новый просмотр расписания использует `GET /api/schedule`, а новые seed-данные создаются сразу в динамической модели. ### `GET /api/users/lessons` diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 08cf64e..a87d978 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -41,7 +41,8 @@ ### Учебные группы (Student Groups) -- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (1–6) +- **Поля:** Название (уникальное), численность, форма обучения, кафедра, специальность, год начала обучения +- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) ### Аудитории (Classrooms) @@ -66,7 +67,33 @@ ## Логика расписания -### Сущность «Занятие» (Lesson) +### Динамическая модель расписания + +Основная модель расписания строится из правил, а не из отдельных статических пар. + +| Компонент | Назначение | +|-----------|------------| +| `academic_years` / `semesters` | Учебные годы и семестры. Неделя 1 считается от `semesters.start_date` | +| `holidays` | Даты, когда занятия не проводятся и часы не списываются | +| `academic_calendar_matrix` | Тип недели для курса и специальности: теория, сессия, каникулы, практика | +| `time_slots` | Настраиваемая сетка пар для тенанта | +| `schedule_rules` | Лимит часов дисциплины в семестре | +| `schedule_rule_groups` | Группы правила, включая потоковые лекции | +| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | + +Генератор `ScheduleGeneratorService` рендерит расписание по запросу: +1. Определяет семестр для каждой даты диапазона. +2. Вычисляет номер недели и чётность. +3. Проверяет праздники и матрицу учебного графика. +4. Загружает правила группы или преподавателя. +5. Симулирует уже проведённые занятия от `active_from_date`. +6. Останавливает вывод правила, когда достигнут `total_academic_hours`. + +Праздник считается пропуском: занятие не переносится и не списывает академические часы. + +### Временная старая сущность «Занятие» (Lesson) + +`lessons` пока физически остаётся в схеме до удаления старого кода, но базовая миграция больше не заполняет её тестовыми данными. Новые экраны просмотра используют `GET /api/schedule`. Каждая запись в расписании содержит: @@ -84,7 +111,7 @@ ### Временны́е слоты -Система использует 7 фиксированных слотов по 90 минут: +Сетка пар хранится в `time_slots` и настраивается для каждого тенанта. При миграции создаются базовые слоты: | № | Время | |---|-------| @@ -104,9 +131,9 @@ - **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа` - Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0 -### Данные к составлению расписания (Schedule Data) +### Временные старые данные к составлению расписания (Schedule Data) -Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания: +Таблица `schedule_data` пока физически остаётся в схеме до удаления старого кода. Новая базовая миграция создаёт `schedule_rules` и `schedule_rule_groups` напрямую, без ETL из `schedule_data`. | Поле | Описание | |------|----------| diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c025547..8e5d67e 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -51,7 +51,8 @@ erDiagram BIGINT group_size BIGINT education_form_id FK BIGINT department_id FK - INT course + BIGINT specialty_code FK + BIGINT year_start_study TIMESTAMP created_at } @@ -142,15 +143,82 @@ erDiagram VARCHAR semester_type VARCHAR period } + + time_slots { + BIGSERIAL id PK + INT order_number UK + TIME start_time + TIME end_time + INT duration_minutes + } + + academic_years { + BIGSERIAL id PK + VARCHAR title UK + DATE start_date + DATE end_date + } + + semesters { + BIGSERIAL id PK + BIGINT academic_year_id FK + VARCHAR semester_type + DATE start_date + DATE end_date + } + + holidays { + BIGSERIAL id PK + DATE date + BIGINT academic_year_id FK + VARCHAR description + } + + academic_calendar_matrix { + BIGSERIAL id PK + BIGINT semester_id FK + INT course_number + BIGINT specialty_id FK + INT week_number + VARCHAR activity_type + } + + schedule_rules { + BIGSERIAL id PK + BIGINT subject_id FK + BIGINT semester_id FK + DATE active_from_date + INT total_academic_hours + } + + schedule_rule_groups { + BIGINT schedule_rule_id FK,PK + BIGINT group_id FK,PK + } + + schedule_rule_slots { + BIGSERIAL id PK + BIGINT schedule_rule_id FK + INT day_of_week + VARCHAR parity + BIGINT time_slot_id FK + BIGINT subgroup_id FK + BIGINT teacher_id FK + BIGINT classroom_id FK + BIGINT lesson_type_id FK + VARCHAR lesson_format + } departments ||--o{ users : "department_id" departments ||--o{ student_groups : "department_id" departments ||--o{ subjects : "department_id" departments ||--o{ schedule_data : "department_id" education_forms ||--o{ student_groups : "education_form_id" + specialties ||--o{ student_groups : "specialty_code" student_groups ||--o{ subgroups : "group_id" student_groups ||--o{ lessons : "group_id" student_groups ||--o{ schedule_data : "group_id" + student_groups ||--o{ schedule_rule_groups : "group_id" users ||--o{ lessons : "teacher_id" users ||--o{ teacher_subjects : "user_id" users ||--o{ teacher_lesson_types : "user_id" @@ -159,11 +227,24 @@ erDiagram subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id" subjects ||--o{ schedule_data : "subjects_id" + subjects ||--o{ schedule_rules : "subject_id" lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" lesson_types ||--o{ schedule_data : "lesson_type_id" + lesson_types ||--o{ schedule_rule_slots : "lesson_type_id" classrooms ||--o{ lessons : "classroom_id" + classrooms ||--o{ schedule_rule_slots : "classroom_id" classrooms ||--o{ classroom_equipments : "classroom_id" equipments ||--o{ classroom_equipments : "equipment_id" + academic_years ||--o{ semesters : "academic_year_id" + academic_years ||--o{ holidays : "academic_year_id" + semesters ||--o{ academic_calendar_matrix : "semester_id" + semesters ||--o{ schedule_rules : "semester_id" + specialties ||--o{ academic_calendar_matrix : "specialty_id" + schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" + schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" + time_slots ||--o{ schedule_rule_slots : "time_slot_id" + subgroups ||--o{ schedule_rule_slots : "subgroup_id" + users ||--o{ schedule_rule_slots : "teacher_id" ``` --- @@ -220,7 +301,8 @@ erDiagram | `group_size` | BIGINT | Количество студентов | | `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `department_id` | BIGINT FK → departments | Кафедра | -| `course` | INT CHECK(1–6) | Курс | +| `specialty_code` | BIGINT FK → specialties | Специальность | +| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | #### `subgroups` — Подгруппы | Колонка | Тип | Описание | @@ -270,7 +352,10 @@ erDiagram ### Расписание -#### `lessons` — Основное расписание занятий +#### `lessons` — Временная таблица старой модели + +Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не заполняет её тестовыми данными. Целевой источник расписания — `schedule_rules` и `schedule_rule_slots`. + | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | @@ -309,7 +394,10 @@ erDiagram | `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | | `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия | -#### `schedule_data` — Данные к составлению расписания +#### `schedule_data` — Временная таблица старой модели нагрузки + +Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не использует её как источник seed-данных. + | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | @@ -324,6 +412,81 @@ erDiagram | `semester_type` | VARCHAR(255) | Весенний / Осенний | | `period` | VARCHAR(255) | Учебный год | +### Динамическое расписание + +#### `time_slots` — Временные слоты занятий +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `order_number` | INT UNIQUE | Номер пары в дне | +| `start_time` | TIME | Время начала | +| `end_time` | TIME | Время окончания | +| `duration_minutes` | INT | Длительность в минутах | + +#### `academic_years` — Учебные годы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `title` | VARCHAR(20) UNIQUE | Название, например `2025-2026` | +| `start_date` | DATE | Дата начала | +| `end_date` | DATE | Дата окончания | + +#### `semesters` — Семестры +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | +| `semester_type` | VARCHAR(20) | `autumn` или `spring` | +| `start_date` | DATE | Дата начала, от неё считается неделя 1 | +| `end_date` | DATE | Дата окончания | + +#### `holidays` — Праздники и исключения +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `date` | DATE | Неучебная дата | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | +| `description` | VARCHAR(255) | Описание | + +#### `academic_calendar_matrix` — Матрица учебного графика +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `semester_id` | BIGINT FK → semesters | Семестр | +| `course_number` | INT | Курс | +| `specialty_id` | BIGINT FK → specialties | Специальность | +| `week_number` | INT | Номер недели семестра | +| `activity_type` | VARCHAR(20) | `THEORY`, `EXAM`, `VACATION`, `PRACTICE` | + +#### `schedule_rules` — Правила расписания +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `subject_id` | BIGINT FK → subjects | Дисциплина | +| `semester_id` | BIGINT FK → semesters | Семестр | +| `active_from_date` | DATE | Дата начала действия правила | +| `total_academic_hours` | INT | Лимит академических часов | + +#### `schedule_rule_groups` — Группы правила +| Колонка | Тип | Описание | +|---------|-----|----------| +| `schedule_rule_id` | BIGINT PK, FK → schedule_rules | Правило | +| `group_id` | BIGINT PK, FK → student_groups | Группа | + +#### `schedule_rule_slots` — Слоты правила +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило | +| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник | +| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | +| `time_slot_id` | BIGINT FK → time_slots | Временной слот | +| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `classroom_id` | BIGINT FK → classrooms | Аудитория | +| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | +| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | + --- ## Flyway миграции @@ -340,7 +503,7 @@ erDiagram | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, динамическое расписание, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 2a224ca..391558d 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -7,7 +7,7 @@ | **Фреймворк** | Нет (Vanilla JavaScript) | | **Модульная система** | ES6 Modules (`import`/`export`) | | **Стили** | CSS (модульный подход) | -| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) | +| **Шрифт** | Inter на странице входа, системный шрифт в кабинетах расписания | | **Веб-сервер** | Apache httpd:alpine | --- @@ -70,10 +70,10 @@ frontend/ │ └── general.html # Общие настройки (заглушка) │ ├── teacher/ # 👩‍🏫 Интерфейс преподавателя -│ └── index.html # Просмотр расписания +│ └── index.html # Недельный просмотр динамического расписания преподавателя │ └── student/ # 🎓 Интерфейс студента - └── index.html # Просмотр расписания (read-only) + └── index.html # Недельный просмотр динамического расписания группы ``` --- @@ -106,7 +106,7 @@ frontend/ | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Расписание | `/api/users/lessons` | +| `schedule` | Старый админский экран расписания до удаления | `/api/users/lessons`; новые правила — `/api/admin/schedule-rules` | | `database` | Тенанты | `/api/database` | | `department` | Кафедры | `/api/departments` | | `departments-data` | Создание кафедры/специальности | `/api/departments` | @@ -166,6 +166,8 @@ export const api = { 3. При успехе сохраняет в `localStorage`: - `token` — UUID-токен - `role` — роль пользователя + - `departmentId` — кафедра пользователя + - `userId` — ID пользователя для личного расписания преподавателя 4. Перенаправляет на соответствующий интерфейс: - `ADMIN` → `/admin/` - `TEACHER` → `/teacher/` @@ -188,6 +190,32 @@ export function isAuthenticatedAsAdmin() { --- +## Кабинеты расписания + +### Преподаватель (`/teacher/`) + +Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`. + +Основные элементы: +- навигация по неделям: предыдущая, текущая, следующая; +- выбор даты через `input[type="date"]`; +- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; +- отображение дисциплины, времени, типа занятия, аудитории и всех групп правила. + +Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. + +### Студент (`/student/`) + +В текущей модели студент не связан с конкретной группой, поэтому страница использует выбор группы из `/api/groups`. + +Основные элементы: +- селект группы с сохранением выбора в `localStorage.studentGroupId`; +- недельная сетка по дням; +- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; +- отображение дисциплины, времени, преподавателя, аудитории, формата и типа занятия. + +--- + ## CSS-архитектура ### Модульный подход diff --git a/frontend/script.js b/frontend/script.js index 80bdf31..cf3632f 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -144,6 +144,7 @@ if (data.token) localStorage.setItem('token', data.token); if (data.role) localStorage.setItem('role', data.role); if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); + if (data.userId) localStorage.setItem('userId', data.userId); const redirect = data.redirect || '/'; setTimeout(() => { window.location.href = redirect; }, 400); diff --git a/frontend/student/index.html b/frontend/student/index.html old mode 100755 new mode 100644 index 131afe2..4ca5a2f --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -3,240 +3,498 @@ - Панель студента - + Расписание студента - -
-
-
-
- -
-
- - - - +
+
+
+

Расписание студента

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

Панель студента

-

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

- Выйти -
+
+ + Выйти +
+ - +
+ +
+ + + +
+ +
+ +
+
+ + + - - \ No newline at end of file + diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html old mode 100755 new mode 100644 index de0df15..43d82f4 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -1,263 +1,527 @@ - - Панель преподавателя - + Расписание преподавателя - -
-
-
-
- -
-
- - - - +
+
+
+

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

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

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

-

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

- Выйти -
+
+ + Выйти +
+ - +
+
+ + + +
+ +
+ +
+
+ + + - - \ No newline at end of file + From 96e9d8155fbd2ddee30a68a929c086c7ede4d4e5 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 29 Apr 2026 23:35:16 +0300 Subject: [PATCH 02/16] =?UTF-8?q?=D0=9C=D0=B8=D0=BB=D0=BB=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?,=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20"=D0=A0=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D0=B8=D0=B9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 26 +- .../app/controller/DepartmentController.java | 57 +- .../app/controller/LessonTypeController.java | 30 + .../app/controller/LessonsController.java | 509 ------ .../controller/ScheduleDataController.java | 283 ---- .../app/controller/SpecialityController.java | 60 +- .../magistr/app/dto/CreateLessonRequest.java | 89 - .../app/dto/CreateScheduleDataRequest.java | 96 -- .../com/magistr/app/dto/LessonResponse.java | 171 -- .../magistr/app/dto/LessonTypeResponse.java | 7 + .../com/magistr/app/dto/ScheduleResponse.java | 128 -- .../java/com/magistr/app/model/Lesson.java | 122 -- .../com/magistr/app/model/ScheduleData.java | 135 -- .../app/repository/LessonRepository.java | 14 - .../repository/ScheduleDataRepository.java | 24 - .../app/utils/DayAndWeekValidator.java | 30 - .../app/utils/SemesterTypeValidator.java | 26 - .../utils/TypeAndFormatLessonValidator.java | 30 - .../main/resources/db/migration/V1__init.sql | 51 - docs/API.md | 112 +- docs/BUSINESS_LOGIC.md | 46 +- docs/DATABASE.md | 73 - docs/DEVELOPMENT.md | 12 +- docs/FRONTEND.md | 19 +- docs/LOGGING.md | 2 +- frontend/admin/css/auditorium-workload.css | 2 +- frontend/admin/css/components.css | 100 +- frontend/admin/css/department.css | 344 ---- frontend/admin/css/modals.css | 357 ---- frontend/admin/index.html | 14 +- frontend/admin/js/main.js | 2 - .../admin/js/views/auditorium-workload.js | 2 +- frontend/admin/js/views/department.js | 456 ----- frontend/admin/js/views/departments-data.js | 133 +- frontend/admin/js/views/groups.js | 95 +- frontend/admin/js/views/schedule.js | 1482 ++++++++++------- frontend/admin/js/views/users.js | 431 +---- frontend/admin/views/department.html | 180 -- frontend/admin/views/departments-data.html | 50 +- frontend/admin/views/groups.html | 22 +- frontend/admin/views/schedule.html | 424 +++-- frontend/admin/views/users.html | 133 +- 42 files changed, 1718 insertions(+), 4661 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/controller/LessonTypeController.java delete mode 100755 backend/src/main/java/com/magistr/app/controller/LessonsController.java delete mode 100644 backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java delete mode 100755 backend/src/main/java/com/magistr/app/dto/CreateLessonRequest.java delete mode 100644 backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java delete mode 100755 backend/src/main/java/com/magistr/app/dto/LessonResponse.java create mode 100644 backend/src/main/java/com/magistr/app/dto/LessonTypeResponse.java delete mode 100644 backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java delete mode 100755 backend/src/main/java/com/magistr/app/model/Lesson.java delete mode 100644 backend/src/main/java/com/magistr/app/model/ScheduleData.java delete mode 100755 backend/src/main/java/com/magistr/app/repository/LessonRepository.java delete mode 100644 backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java delete mode 100755 backend/src/main/java/com/magistr/app/utils/DayAndWeekValidator.java delete mode 100644 backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java delete mode 100755 backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java delete mode 100644 frontend/admin/css/department.css delete mode 100644 frontend/admin/js/views/department.js mode change 100755 => 100644 frontend/admin/js/views/schedule.js mode change 100755 => 100644 frontend/admin/js/views/users.js delete mode 100644 frontend/admin/views/department.html mode change 100755 => 100644 frontend/admin/views/schedule.html diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 10dde97..d7f7256 100644 --- a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -6,7 +6,7 @@ Реализован первый рабочий срез динамического расписания. Новая модель теперь является базовой схемой проекта и создаётся сразу в `V1__init.sql`. -Старые таблицы `lessons` и `schedule_data` пока физически остаются в `V1__init.sql`, потому что связанный старый код будет удаляться отдельным шагом. Они больше не заполняются тестовыми данными и не являются источником для новой модели расписания. +Старые таблицы `lessons` и `schedule_data`, старые контроллеры, DTO, модели, репозитории и админские вызовы старых API удалены. Новая модель расписания является единственным базовым способом хранения и генерации расписания. ## Что добавлено @@ -78,7 +78,7 @@ | `AcademicDateService` | Календарная математика: семестр по дате, номер недели, чётность, праздник, курс группы, тип активности | | `ScheduleGeneratorService` | Генерация расписания группы или преподавателя на диапазон дат | -Старый `LessonsController` пока физически присутствует в коде и помечен как `@Deprecated`. Это технический хвост до удаления старых экранов и таблиц, а не целевой слой совместимости. +Старый ручной слой занятий удалён: в backend больше нет `LessonsController`, `ScheduleDataController`, сущностей `Lesson`/`ScheduleData`, их DTO, репозиториев и валидаторов старых строк расписания. ### API @@ -134,6 +134,8 @@ GET /api/schedule?teacherId={userId}&startDate=...&endDate=... Заглушки кабинетов заменены на базовые экраны просмотра расписания. +Админский экран `schedule` показывает текущие `schedule_rules` и сетку `time_slots` через новые admin API. + #### Кабинет студента Файл: `frontend/student/index.html`. @@ -268,8 +270,8 @@ docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclip | `schedule_rule_slots` | 4 | | `time_slots` | 7 | | `academic_calendar_matrix` | 132 | -| `lessons` | 0 | -| `schedule_data` | 0 | +| `lessons` | таблица отсутствует | +| `schedule_data` | таблица отсутствует | Также выполнен: @@ -285,27 +287,19 @@ git diff --check Ограничения: -- полноценный UI конструктора правил в админке ещё не реализован; -- визуальный редактор матрицы учебного графика в админке ещё не реализован; -- UI управления праздниками и учебными годами пока доступен только через API; - строгая проверка конфликтов преподавателей, аудиторий и групп при сохранении правил ещё не реализована; -- студент пока выбирает группу вручную, потому что в модели пользователя нет связи `student -> group`; -- старые таблицы и контроллеры `lessons` / `schedule_data` ещё физически есть, но больше не используются для seed-расписания. +- студент пока выбирает группу вручную, потому что в модели пользователя нет связи `student -> group`. ## Следующий этап Рекомендуемый следующий этап: -1. Добавить в админку вкладку настройки временных слотов. -2. Добавить UI учебных годов, семестров и праздников. -3. Добавить визуальную матрицу учебного графика. -4. Добавить конструктор правил расписания. -5. Реализовать backend-валидацию конфликтов: +1. Реализовать backend-валидацию конфликтов: - преподаватель не может вести разные занятия одновременно; - аудитория не может быть занята двумя разными занятиями одновременно; - группа не может быть на двух занятиях одновременно, кроме корректного деления по подгруппам. -6. Удалить старые экраны, контроллеры, DTO, модели и репозитории `lessons` / `schedule_data`. -7. Удалить старые таблицы `lessons` и `schedule_data` из `V1__init.sql`. +2. Добавить модель связи студента с учебной группой, чтобы кабинет студента не требовал ручного выбора группы. +3. Добавить отдельное управление подгруппами и подключить выбор `subgroupId` в конструкторе правил. ## Связанные документы diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java index 8adfc3b..3569f96 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentController.java @@ -92,7 +92,62 @@ public class DepartmentController { } } - @DeleteMapping("/id") + @PutMapping("/{id}") + public ResponseEntity updateDepartment(@PathVariable Long id, @RequestBody CreateDepartmentRequest request) { + logger.info("Получен запрос на обновление кафедры с ID: {}", id); + + Department department = departmentRepository.findById(id).orElse(null); + if (department == null) { + logger.info("Кафедра с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + + try { + if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()) { + String errorMessage = "Название кафедры обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) { + String errorMessage = "Код кафедры обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + String departmentName = request.getDepartmentName().trim(); + if (departmentRepository.findByDepartmentName(departmentName) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Кафедра с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Кафедра с таким кодом уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + department.setDepartmentName(departmentName); + department.setDepartmentCode(request.getDepartmentCode()); + departmentRepository.save(department); + + logger.info("Кафедра с ID - {} успешно обновлена", id); + return ResponseEntity.ok(new DepartmentResponse( + department.getId(), + department.getDepartmentName(), + department.getDepartmentCode() + )); + } catch (Exception e) { + logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage())); + } + } + + @DeleteMapping("/{id}") public ResponseEntity deleteDepartment(@PathVariable Long id) { logger.info("Получен запрос на удаление кафедры с ID: {}", id); if (!departmentRepository.existsById(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 new file mode 100644 index 0000000..af69050 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/LessonTypeController.java @@ -0,0 +1,30 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.LessonTypeResponse; +import com.magistr.app.model.LessonType; +import com.magistr.app.repository.LessonTypesRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Comparator; +import java.util.List; + +@RestController +@RequestMapping("/api/lesson-types") +public class LessonTypeController { + + private final LessonTypesRepository lessonTypesRepository; + + public LessonTypeController(LessonTypesRepository lessonTypesRepository) { + this.lessonTypesRepository = lessonTypesRepository; + } + + @GetMapping + public List getAllLessonTypes() { + return lessonTypesRepository.findAll().stream() + .sorted(Comparator.comparing(LessonType::getLessonType)) + .map(lessonType -> new LessonTypeResponse(lessonType.getId(), lessonType.getLessonType())) + .toList(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/LessonsController.java b/backend/src/main/java/com/magistr/app/controller/LessonsController.java deleted file mode 100755 index d0b4119..0000000 --- a/backend/src/main/java/com/magistr/app/controller/LessonsController.java +++ /dev/null @@ -1,509 +0,0 @@ -package com.magistr.app.controller; - -import com.magistr.app.dto.CreateLessonRequest; -import com.magistr.app.dto.LessonResponse; -import com.magistr.app.model.*; -import com.magistr.app.repository.*; -import com.magistr.app.utils.DayAndWeekValidator; -import com.magistr.app.utils.TypeAndFormatLessonValidator; -import org.slf4j.LoggerFactory; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.*; - -import org.slf4j.Logger; -import org.springframework.http.HttpStatus; - -@RestController -@RequestMapping("/api/users/lessons") -public class LessonsController { - - private static final Logger logger = LoggerFactory.getLogger(LessonsController.class); - - private final LessonRepository lessonRepository; - private final UserRepository teacherRepository; - private final GroupRepository groupRepository; - private final SubjectRepository subjectRepository; - private final EducationFormRepository educationFormRepository; - private final ClassroomRepository classroomRepository; - - public LessonsController(LessonRepository lessonRepository, UserRepository teacherRepository, GroupRepository groupRepository, SubjectRepository subjectRepository, EducationFormRepository educationForm, ClassroomRepository classroomRepository) { - this.lessonRepository = lessonRepository; - this.teacherRepository = teacherRepository; - this.groupRepository = groupRepository; - this.subjectRepository = subjectRepository; - this.educationFormRepository = educationForm; - this.classroomRepository = classroomRepository; - } - - //Создание нового занятия - @PostMapping("/create") - public ResponseEntity createLesson(@RequestBody CreateLessonRequest request) { - //Полное логирование входящего запроса - logger.info("Получен запрос на создание занятия: teacherId={}, groupId={}, subjectId={}, day={}, week={}, time={}", - request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getDay(), request.getWeek(), request.getTime()); - - //Проверка teacherId - if (request.getTeacherId() == null || request.getTeacherId() == 0) { - String errorMessage = "ID преподавателя обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка groupId - if (request.getGroupId() == null || request.getGroupId() == 0) { - String errorMessage = "ID группы обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка subjectId - if (request.getSubjectId() == null || request.getSubjectId() == 0) { - String errorMessage = "ID предмета обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка lessonFormat - if (request.getLessonFormat() == null || request.getLessonFormat().isBlank()) { - String errorMessage = "Выбор формата занятия обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())){ - String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage(); - logger.info("Ошибка валидации формата: '{}' - {}", request.getLessonFormat(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка typeLesson - if (request.getTypeLesson() == null || request.getTypeLesson().isBlank()) { - String errorMessage = "Выбор типа занятия обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())){ - String errorMessage = "Некорректный тип занятия. " + TypeAndFormatLessonValidator.getValidTypesMessage(); - logger.info("Ошибка валидации типа: '{}' - {}", request.getTypeLesson(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка classroomId - if (request.getClassroomId() == null || request.getClassroomId() == 0) { - String errorMessage = "ID аудитории обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка day - if (request.getDay() == null || request.getDay().isBlank()) { - String errorMessage = "Выбор дня обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!DayAndWeekValidator.isValidDay(request.getDay())){ - String errorMessage = "Некорректный день недели. " + DayAndWeekValidator.getValidDaysMessage(); - logger.info("Ошибка валидации дня: '{}' - {}", request.getDay(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка week - if (request.getWeek() == null || request.getWeek().isBlank()) { - String errorMessage = "Выбор недели обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!DayAndWeekValidator.isValidWeek(request.getWeek())){ - String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage(); - logger.info("Ошибка валидации недели: '{}' - {}", request.getWeek(), errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Проверка time - if (request.getTime() == null || request.getTime().isBlank()) { - String errorMessage = "Время обязательно"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - //Сохранение полученных данных и формирование ответа клиенту - try { - Lesson lesson = new Lesson(); - lesson.setTeacherId(request.getTeacherId()); - lesson.setSubjectId(request.getSubjectId()); - lesson.setGroupId(request.getGroupId()); - lesson.setLessonFormat(request.getLessonFormat()); - lesson.setTypeLesson(request.getTypeLesson()); - lesson.setClassroomId(request.getClassroomId()); - lesson.setDay(request.getDay()); - lesson.setWeek(request.getWeek()); - lesson.setTime(request.getTime()); - - Lesson savedLesson = lessonRepository.save(lesson); - - Map response = new LinkedHashMap<>(); - response.put("id", savedLesson.getId()); - response.put("teacherId", savedLesson.getTeacherId()); - response.put("groupId", savedLesson.getGroupId()); - response.put("subjectId", savedLesson.getSubjectId()); - response.put("formatLesson", savedLesson.getLessonFormat()); - response.put("typeLesson", savedLesson.getTypeLesson()); - response.put("classroomId", savedLesson.getClassroomId()); - response.put("day", savedLesson.getDay()); - response.put("week", savedLesson.getWeek()); - response.put("time", savedLesson.getTime()); - - logger.info("Занятие успешно создано с ID: {}", savedLesson.getId()); - - return ResponseEntity.ok(response); - } catch (Exception e) { - logger.error("Ошибка при сохранении занятия: {}", e.getMessage(),e); - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании занятия: " + e.getMessage())); - } - } - - //Запрос для получения всего списка занятий - @Deprecated - @GetMapping - public List getAllLessons() { - logger.info("Запрос на получение всех занятий"); - - try { - List lessons = lessonRepository.findAll(); - - List response = lessons.stream() - .map(lesson -> { - String teacherName = teacherRepository.findById(lesson.getTeacherId()) - .map(User::getUsername) - .orElse("Неизвестно"); - - StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null); - String groupName = groupRepository.findById(lesson.getGroupId()) - .map(StudentGroup::getName) - .orElse("Неизвестно"); - - String educationFormName = "Неизвестно"; - if(group != null && group.getEducationForm() != null) { - Long educationFormId = group.getEducationForm().getId(); - educationFormName = educationFormRepository.findById(educationFormId) - .map(EducationForm::getName) - .orElse("Неизвестно"); - } - - String subjectName = subjectRepository.findById(lesson.getSubjectId()) - .map(Subject::getName) - .orElse("Неизвестно"); - - String classroomName = classroomRepository.findById(lesson.getClassroomId()) - .map(Classroom::getName) - .orElse("Неизвестно"); - - return new LessonResponse( - lesson.getId(), - teacherName, - groupName, - classroomName, - educationFormName, - subjectName, - lesson.getTypeLesson(), - lesson.getLessonFormat(), - lesson.getDay(), - lesson.getWeek(), - lesson.getTime() - ); - }) - .toList(); - - logger.info("Получено {} занятий", lessons.size()); - return response; - } catch (Exception e) { - logger.error("Ошибка при получении списка всех занятий: {}", e.getMessage(), e); - throw e; - } - } - - //Запрос на получение всех занятий для конкретного преподавателя - @Deprecated - @GetMapping("/{teacherId}") - public ResponseEntity getLessonsById(@PathVariable Long teacherId) { - logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId); - try { - List lessons = lessonRepository.findByTeacherId(teacherId); - - if(lessons.isEmpty()) { - logger.info("У преподавателя с ID {} нет занятий", teacherId); - return ResponseEntity.ok(Map.of( - "message", "У преподавателя с ID " + teacherId +" нет занятий.", - "lessons", Collections.emptyList() - )); - } - - List lessonResponses = lessons.stream() - .map(lesson -> { - String teacherName = teacherRepository.findById(lesson.getTeacherId()) - .map(User::getUsername) - .orElse("Неизвестно"); - - StudentGroup group = groupRepository.findById(lesson.getGroupId()).orElse(null); - String groupName = groupRepository.findById(lesson.getGroupId()) - .map(StudentGroup::getName) - .orElse("Неизвестно"); - - String educationFormName = "Неизвестно"; - if(group != null && group.getEducationForm() != null) { - Long educationFormId = group.getEducationForm().getId(); - educationFormName = educationFormRepository.findById(educationFormId) - .map(EducationForm::getName) - .orElse("Неизвестно"); - } - - String subjectName = subjectRepository.findById(lesson.getSubjectId()) - .map(Subject::getName) - .orElse("Неизвестно"); - - String classroomName = classroomRepository.findById(lesson.getClassroomId()) - .map(Classroom::getName) - .orElse("Неизвестно"); - - return new LessonResponse( - lesson.getId(), - teacherName, - groupName, - classroomName, - educationFormName, - subjectName, - lesson.getTypeLesson(), - lesson.getLessonFormat(), - lesson.getDay(), - lesson.getWeek(), - lesson.getTime() - ); - }) - .toList(); - - logger.info("Найдено {} занятий для преподавателя с ID: {}", lessonResponses.size(), teacherId); - return ResponseEntity.ok(lessonResponses); - } catch (Exception e ){ - logger.error("Ошибка при получении занятий для преподавателя с ID {}: {}", teacherId, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Ошибка при поиске занятий: " + e.getMessage())); - } - } - - //Тестовый запрос на проверку доступности контроллера - @GetMapping("/ping") - public String ping() { - logger.debug("Получен ping запрос"); - String response = "pong"; - logger.debug("Ответ на ping: {}", response); - return response; - } - - //Удаление занятия по его ID - @DeleteMapping("/delete/{lessonId}") - public ResponseEntity deleteLessonById(@PathVariable Long lessonId){ - logger.info("Запрос на удаление занятия по ID: {}", lessonId); - if(!lessonRepository.existsById(lessonId)) { - return ResponseEntity.badRequest().body(Map.of("message", "Занятие не найдено")); - } - lessonRepository.deleteById(lessonId); - logger.info("Занятие с ID - {} успешно удалено", lessonId); - return ResponseEntity.ok(Map.of("message", "Занятие успешно удалено")); - - } - - //Обновление занятия по его ID - @PutMapping("/update/{lessonId}") - public ResponseEntity updateLessonById(@PathVariable Long lessonId, @RequestBody CreateLessonRequest request) { - logger.info("Получен запрос на обновление занятия с ID - {}", lessonId); - logger.info("Данные для обновления: teacherId={}, groupId={}, subjectId={}, lessonFormat={}, typeLesson={}, classroomId={}, day={}, week={}, time={}", - request.getTeacherId(), request.getGroupId(), request.getSubjectId(), request.getLessonFormat(), request.getTypeLesson(), request.getClassroomId(), - request.getDay(), request.getWeek(), request.getTime()); - - try { - //Проверка на наличие записи - Lesson existingLesson = lessonRepository.findById(lessonId).orElse(null); - - if(existingLesson == null) { - String errorMessage = "Занятие с ID " + lessonId + " не найдено"; - logger.info("Ошибка: {}", errorMessage); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", errorMessage)); - } - - boolean hasChanges = false; - Map changes = new LinkedHashMap<>(); - - //Проверка и обновление teacherId, если он передан и отличается - if(request.getTeacherId() != null) { - if(!request.getTeacherId().equals(existingLesson.getTeacherId())) { - if(request.getTeacherId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID преподавателя не может быть равен 0")); - } - existingLesson.setTeacherId(request.getTeacherId()); - changes.put("teacherId", request.getTeacherId()); - hasChanges = true; - } - } - - //Проверка и обновление groupId, если он передан и отличается - if(request.getGroupId() != null) { - if(!request.getGroupId().equals(existingLesson.getGroupId())) { - if(request.getGroupId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID группы не может быть равен 0")); - } - existingLesson.setGroupId(request.getGroupId()); - changes.put("groupId", request.getGroupId()); - hasChanges = true; - } - } - - //Проверка и обновление subjectId, если он передан и отличается - if(request.getSubjectId() != null) { - if(!request.getSubjectId().equals(existingLesson.getSubjectId())) { - if(request.getSubjectId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID дисциплины не может быть равен 0")); - } - existingLesson.setSubjectId(request.getSubjectId()); - changes.put("subjectId", request.getSubjectId()); - hasChanges = true; - } - } - - //Проверка и обновление lessonFormat, если он передан и отличается - if(request.getLessonFormat() != null) { - if(!request.getLessonFormat().equals(existingLesson.getLessonFormat())) { - if(request.getLessonFormat().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Формат занятия не может быть пустым")); - } - if(!TypeAndFormatLessonValidator.isValidFormat(request.getLessonFormat())) { - String errorMessage = "Некорректный формат занятий. " + TypeAndFormatLessonValidator.getValidFormatsMessage(); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - existingLesson.setLessonFormat(request.getLessonFormat()); - changes.put("lessonFormat", request.getLessonFormat()); - hasChanges = true; - } - } - - //Проверка и обновление typeLesson, если он передан и отличается - if(request.getTypeLesson() != null) { - if(!request.getTypeLesson().equals(existingLesson.getTypeLesson())) { - if(request.getTypeLesson().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Тип занятия не может быть пустым")); - } - if(!TypeAndFormatLessonValidator.isValidType(request.getTypeLesson())) { - String errorMessage = "Некорректный тип занятий. " + TypeAndFormatLessonValidator.getValidTypesMessage(); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - existingLesson.setLessonFormat(request.getTypeLesson()); - changes.put("typeLesson", request.getTypeLesson()); - hasChanges = true; - } - } - - //Проверка и обновление classroomId, если он передан и отличается - if(request.getClassroomId() != null) { - if(!request.getClassroomId().equals(existingLesson.getClassroomId())) { - if(request.getClassroomId() == 0) { - return ResponseEntity.badRequest() - .body(Map.of("message", "ID аудитории не можеть быть равен 0")); - } - existingLesson.setClassroomId(request.getClassroomId()); - changes.put("classroomId", request.getClassroomId()); - hasChanges = true; - } - } - - //Проверка и обновление day, если он передан и отличается - if(request.getDay() != null){ - if(!request.getDay().equals(existingLesson.getDay())) { - if(request.getDay().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Поле \"День\" не может быть пустым")); - } - if(!DayAndWeekValidator.isValidDay(request.getDay())) { - String errorMessage = "Некорректный день. " + DayAndWeekValidator.getValidDaysMessage(); - return ResponseEntity.badRequest() - .body(Map.of("message", errorMessage)); - } - existingLesson.setDay(request.getDay()); - changes.put("day", request.getDay()); - hasChanges = true; - } - } - - //Проверка и обновление week, если он передан и отличается - if(request.getWeek() != null) { - if(!request.getWeek().equals(existingLesson.getWeek())) { - if (request.getWeek().isBlank()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "Поле \"Неделя\" не может быть пустым")); - } - if (!DayAndWeekValidator.isValidWeek(request.getWeek())) { - String errorMessage = "Некорректная неделя. " + DayAndWeekValidator.getValidWeekMessage(); - return ResponseEntity.badRequest() - .body((Map.of("message", errorMessage))); - } - existingLesson.setWeek(request.getWeek()); - changes.put("week", request.getWeek()); - hasChanges = true; - } - } - - //Проверка и обновление time, если он передан и отличается - if(request.getTime() != null) { - if(!request.getTime().equals(existingLesson.getTime())) { - if(request.getTime().isBlank()){ - return ResponseEntity.badRequest() - .body(Map.of("message", "Поле \"Время\" не может быть пустым")); - } - existingLesson.setTime(request.getTime()); - changes.put("time", request.getTime()); - hasChanges = true; - } - } - - if(!hasChanges) { - logger.info("Обновление не требуется - все полня идентичны существующим для занятия с ID: {}", lessonId); - - Map response = buildResponse(existingLesson); - response.put("message", "Изменений не обнаружено"); - return ResponseEntity.ok(response); - } - - Lesson updatedLesson = lessonRepository.save(existingLesson); - - Map response = buildResponse(updatedLesson); - response.put("updatedFields", changes); - response.put("message", "Занятие успешно обновлено"); - - logger.info("Занятие с ID - {} успешно обновлено. Изменения: {}", lessonId, changes); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - logger.error("Ошибка при обновлении занятия с ID {}: {}", lessonId, e.getMessage(),e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при обновлении занятия: " + e.getMessage())); - } - } - - private Map buildResponse(Lesson lesson) { - Map response = new LinkedHashMap<>(); - response.put("id", lesson.getId()); - response.put("teacherId", lesson.getTeacherId()); - response.put("groupId", lesson.getGroupId()); - response.put("subjectId", lesson.getSubjectId()); - response.put("LessonFormat", lesson.getLessonFormat()); - response.put("typeLesson", lesson.getTypeLesson()); - response.put("classroomId", lesson.getClassroomId()); - response.put("day", lesson.getDay()); - response.put("week", lesson.getWeek()); - response.put("time", lesson.getTime()); - return response; - } -} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java deleted file mode 100644 index 65f2994..0000000 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleDataController.java +++ /dev/null @@ -1,283 +0,0 @@ -package com.magistr.app.controller; - -import com.magistr.app.dto.CreateScheduleDataRequest; -import com.magistr.app.dto.ScheduleResponse; -import com.magistr.app.model.*; -import com.magistr.app.repository.*; -import com.magistr.app.utils.CourseAndSemesterCalculator; -import com.magistr.app.utils.SemesterTypeValidator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/department/schedule") -public class ScheduleDataController { - - private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class); - - private final ScheduleDataRepository scheduleDataRepository; - private final GroupRepository groupRepository; - private final SpecialtiesRepository specialtiesRepository; - private final SubjectRepository subjectRepository; - private final LessonTypesRepository lessonTypesRepository; - private final UserRepository userRepository; - - public ScheduleDataController(ScheduleDataRepository scheduleDataRepository, GroupRepository groupRepository, SpecialtiesRepository specialtiesRepository, SubjectRepository subjectRepository, LessonTypesRepository lessonTypesRepository, UserRepository userRepository) { - this.scheduleDataRepository = scheduleDataRepository; - this.groupRepository = groupRepository; - this.specialtiesRepository = specialtiesRepository; - this.subjectRepository = subjectRepository; - this.lessonTypesRepository = lessonTypesRepository; - this.userRepository = userRepository; - } - - @GetMapping("/allList") - public List getAllScheduleDataList() { - logger.info("Получен запрос на получение списка данных расписаний"); - try { - List scheduleData = scheduleDataRepository.findAll(); - List response = scheduleData.stream() - .map(s -> new ScheduleData( - s.getId(), - s.getDepartmentId(), - s.getGroupId(), - s.getSubjectsId(), - s.getLessonTypeId(), - s.getNumberOfHours(), - s.getDivision(), - s.getTeacherId(), - s.getSemesterType(), - s.getPeriod() - )) - .toList(); - logger.info("Получено {} записей", response.size()); - return response; - } catch (Exception e) { - logger.error("Ошибка при получении списка данных расписаний: {}", e.getMessage(), e); - throw e; - } - } - - @GetMapping - public ResponseEntity getSingleScheduleData( - @RequestParam Long departmentId, - @RequestParam SemesterType semesterType, - @RequestParam String period - ) { - logger.info("Получен запрос на получение списка данных расписания по конкретным данным: departmentId = {}, semester = {}, period = {}", - departmentId, semesterType, period); - try { - List scheduleData = scheduleDataRepository.findByDepartmentIdAndSemesterTypeAndPeriod(departmentId, semesterType, period ); - - if(scheduleData.isEmpty()){ - logger.info("По параметрам: departmentId = {}, semester = {}, period = {} не найдено записей", departmentId, semesterType, period); - return ResponseEntity.ok(Map.of( - "message", "Записей не найдено" - )); - } - - List response = scheduleData.stream() - .map( s -> { - String groupName = groupRepository.findById(s.getGroupId()) - .map(StudentGroup::getName) - .orElse("Неизвестно"); - - int groupSemester = 0; - int groupCourse = 0; - String specialityCode = "Неизвестно"; - - StudentGroup group = groupRepository.findById(s.getGroupId()).orElse(null); - - if (group != null) { - groupCourse = CourseAndSemesterCalculator.getFutureCourse(group.getYearStartStudy(), period); - } - - if (group != null) { - groupSemester = CourseAndSemesterCalculator.getFutureSemester(group.getYearStartStudy(), period, semesterType); - } - - if (group != null) { - Long specialityId = group.getSpecialityCode(); - specialityCode = specialtiesRepository.findById(specialityId). - map(Speciality::getSpecialityCode) - .orElse("Неизвестно"); - } - - String subjectName = subjectRepository.findById(s.getSubjectsId()) - .map(Subject::getName) - .orElse("Неизвестно"); - - String lessonType = lessonTypesRepository.findById(s.getLessonTypeId()) - .map(LessonType::getLessonType) - .orElse("Неизвестно"); - - String teacherName = userRepository.findById(s.getTeacherId()) - .map(User::getFullName) - .orElse("Неизвестно"); - - String teacherjobTitle = userRepository.findById(s.getTeacherId()) - .map(User::getJobTitle) - .orElse("Неизвестно"); - - return new ScheduleResponse( - s.getId(), - s.getDepartmentId(), - specialityCode, - groupName, - groupCourse, - groupSemester, - subjectName, - lessonType, - s.getNumberOfHours(), - s.getDivision(), - teacherName, - teacherjobTitle, - s.getSemesterType(), - s.getPeriod()); - } - ) - .toList(); - logger.info("Получено {} записей для кафедры с ID - {}", response.size(), departmentId); - return ResponseEntity.ok(response); - } catch (Exception e) { - logger.error("Ошибка при получении списка данных расписаний для кафедры с ID - {}, semester - {}, period - {}: {}", departmentId, semesterType, period, e.getMessage(), e); - throw e; - } - } - - // Доделать проверки получаемых полей!!! - @PostMapping("/create") - public ResponseEntity createScheduleData(@RequestBody CreateScheduleDataRequest request) { - logger.info("Получен запрос на создание записи данных для расписаний: departmentId={}, groupId={}, subjectsId={}, lessonTypeId={}, numberOfHours={}, division={}, teacherId={}, semesterType={}, period={}", - request.getDepartmentId(), request.getGroupId(), request.getSubjectsId(), request.getLessonTypeId(), request.getNumberOfHours(), request.getDivision(), request.getTeacherId(), request.getSemesterType(), request.getPeriod()); - try { - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { - String errorMessage = "ID кафедры обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if(!scheduleDataRepository.existsById(request.getDepartmentId())) { - String errorMessage = "Кафедра не найдена"; - logger.info("Кафедра не найдена"); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getGroupId() == null || request.getGroupId() == 0) { - String errorMessage = "ID группы обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getSubjectsId() == null || request.getSubjectsId() == 0) { - String errorMessage = "ID дисциплины обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getLessonTypeId() == null || request.getLessonTypeId() == 0) { - String errorMessage = "ID типа занятия обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getNumberOfHours() == null) { - request.setNumberOfHours(0L); - } - - if (request.getTeacherId() == null || request.getTeacherId() == 0) { - String errorMessage = "ID преподавателя обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getSemesterType() == null) { - String errorMessage = "Семестр обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } else if (!SemesterTypeValidator.isValidTypeSemester(request.getSemesterType().toString())) { - String errorMessage = "Некорректный формат семестра. Допустимые форматы: " + SemesterTypeValidator.getValidTypes(); - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - if (request.getPeriod() == null || request.getPeriod().isBlank()) { - String errorMessage = "Период обязателен"; - logger.info("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - - boolean existsRecord = scheduleDataRepository.existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod( - request.getDepartmentId(), - request.getGroupId(), - request.getSubjectsId(), - request.getLessonTypeId(), - request.getNumberOfHours(), - request.getDivision(), - request.getTeacherId(), - request.getSemesterType(), - request.getPeriod() - ); - - if(existsRecord) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("message", "Такая запись уже существует")); - } - - ScheduleData scheduleData = new ScheduleData(); - scheduleData.setDepartmentId(request.getDepartmentId()); - scheduleData.setGroupId(request.getGroupId()); - scheduleData.setSubjectsId(request.getSubjectsId()); - scheduleData.setLessonTypeId(request.getLessonTypeId()); - scheduleData.setNumberOfHours(request.getNumberOfHours()); - scheduleData.setDivision(request.getDivision()); - scheduleData.setTeacherId(request.getTeacherId()); - scheduleData.setSemesterType(request.getSemesterType()); - scheduleData.setPeriod(request.getPeriod()); - - ScheduleData savedSchedule = scheduleDataRepository.save(scheduleData); - - Map response = new LinkedHashMap<>(); - response.put("id", savedSchedule.getId()); - response.put("departmentId", savedSchedule.getDepartmentId()); - response.put("groupId", savedSchedule.getGroupId()); - response.put("subjectId", savedSchedule.getSubjectsId()); - response.put("lessonTypeId", savedSchedule.getLessonTypeId()); - response.put("numberOfHours", savedSchedule.getNumberOfHours()); - response.put("isDivision", savedSchedule.getDivision()); - response.put("teacherId", savedSchedule.getTeacherId()); - response.put("semesterType", savedSchedule.getSemesterType()); - response.put("period", savedSchedule.getPeriod()); - - logger.info("Запись успешно создана с ID: {}", savedSchedule.getId()); - - return ResponseEntity.ok(response); - } catch (org.springframework.dao.DataIntegrityViolationException e) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("message", "Такая запись уже существует")); - } catch (Exception e) { - logger.error("Ошибка при создании записи: {}", e.getMessage(), e); - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Произошла ошибка при создании записи: " + e.getMessage())); - } - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteById(@PathVariable Long id) { - logger.info("Получен запрос на удаление записи с ID: {}", id); - if(!scheduleDataRepository.existsById(id)) { - logger.info("Запись с ID - {} не найдена", id); - return ResponseEntity.notFound().build(); - } - scheduleDataRepository.deleteById(id); - logger.info("Запись с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Запись удалена")); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java index 92db85a..ab05f3e 100644 --- a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java +++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java @@ -92,7 +92,63 @@ public class SpecialityController { } } - @DeleteMapping("/id") + @PutMapping("/{id}") + public ResponseEntity updateSpeciality(@PathVariable Long id, @RequestBody CreateSpecialityRequest request) { + logger.info("Получен запрос на обновление специальности с ID: {}", id); + + Speciality speciality = specialtiesRepository.findById(id).orElse(null); + if (speciality == null) { + logger.info("Специальность с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + + try { + if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) { + String errorMessage = "Название специальности обязательно"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) { + String errorMessage = "Код специальности обязателен"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + String specialityName = request.getSpecialityName().trim(); + String specialityCode = request.getSpecialityCode().trim(); + if (specialtiesRepository.findBySpecialityName(specialityName) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Специальность с таким названием уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + if (specialtiesRepository.findBySpecialityCode(specialityCode) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + String errorMessage = "Специальность с таким кодом уже существует"; + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + speciality.setSpecialityName(specialityName); + speciality.setSpecialityCode(specialityCode); + specialtiesRepository.save(speciality); + + logger.info("Специальность с ID - {} успешно обновлена", id); + return ResponseEntity.ok(new SpecialityResponse( + speciality.getId(), + speciality.getSpecialityName(), + speciality.getSpecialityCode() + )); + } catch (Exception e) { + logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage())); + } + } + + @DeleteMapping("/{id}") public ResponseEntity deleteSpeciality(@PathVariable Long id) { logger.info("Получен запрос на удаление специальности с ID: {}", id); if (!specialtiesRepository.existsById(id)) { @@ -101,6 +157,6 @@ public class SpecialityController { } specialtiesRepository.deleteById(id); logger.info("Специальность с ID - {} успешно удалена", id); - return ResponseEntity.ok(Map.of("message", "Специальнсть удалена")); + return ResponseEntity.ok(Map.of("message", "Специальность удалена")); } } diff --git a/backend/src/main/java/com/magistr/app/dto/CreateLessonRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateLessonRequest.java deleted file mode 100755 index 8276857..0000000 --- a/backend/src/main/java/com/magistr/app/dto/CreateLessonRequest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.magistr.app.dto; - -public class CreateLessonRequest { - - private Long teacherId; - private Long groupId; - private Long subjectId; - private String lessonFormat; - private String typeLesson; - private Long classroomId; - private String day; - private String week; - private String time; - - public CreateLessonRequest() { - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectId() { - return subjectId; - } - - public void setSubjectId(Long subjectId) { - this.subjectId = subjectId; - } - - public String getLessonFormat() { - return lessonFormat; - } - - public void setLessonFormat(String lessonFormat) { - this.lessonFormat = lessonFormat; - } - - public String getTypeLesson() { - return typeLesson; - } - - public void setTypeLesson(String typeLesson) { - this.typeLesson = typeLesson; - } - - public Long getClassroomId() { - return classroomId; - } - - public void setClassroomId(Long classroomId) { - this.classroomId = classroomId; - } - - public String getDay() { - return day; - } - - public void setDay(String day) { - this.day = day; - } - - public String getWeek() { - return week; - } - - public void setWeek(String week) { - this.week = week; - } - - public String getTime() { - return time; - } - - public void setTime(String time) { - this.time = time; - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java deleted file mode 100644 index 7160986..0000000 --- a/backend/src/main/java/com/magistr/app/dto/CreateScheduleDataRequest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.magistr.app.dto; - -import com.magistr.app.model.SemesterType; - -public class CreateScheduleDataRequest { - private Long id; - private Long departmentId; - private Long groupId; - private Long subjectsId; - private Long lessonTypeId; - private Long numberOfHours; - private Boolean division; - private Long teacherId; - private SemesterType semesterType; - private String period; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getDepartmentId() { - return departmentId; - } - - public void setDepartmentId(Long departmentId) { - this.departmentId = departmentId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectsId() { - return subjectsId; - } - - public void setSubjectsId(Long subjectsId) { - this.subjectsId = subjectsId; - } - - public Long getLessonTypeId() { - return lessonTypeId; - } - - public void setLessonTypeId(Long lessonTypeId) { - this.lessonTypeId = lessonTypeId; - } - - public Long getNumberOfHours() { - return numberOfHours; - } - - public void setNumberOfHours(Long numberOfHours) { - this.numberOfHours = numberOfHours; - } - - public Boolean getDivision() { - return division; - } - - public void setDivision(Boolean division) { - this.division = division; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public SemesterType getSemesterType() { - return semesterType; - } - - public void setSemesterType(SemesterType semesterType) { - this.semesterType = semesterType; - } - - public String getPeriod() { - return period; - } - - public void setPeriod(String period) { - this.period = period; - } -} diff --git a/backend/src/main/java/com/magistr/app/dto/LessonResponse.java b/backend/src/main/java/com/magistr/app/dto/LessonResponse.java deleted file mode 100755 index c1ba061..0000000 --- a/backend/src/main/java/com/magistr/app/dto/LessonResponse.java +++ /dev/null @@ -1,171 +0,0 @@ -package com.magistr.app.dto; - - -import com.fasterxml.jackson.annotation.JsonInclude; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class LessonResponse { - - private Long id; - private Long teacherId; - private String teacherName; - private Long groupId; - private String groupName; - private String educationFormName; - private Long subjectId; - private String subjectName; - private String lessonFormat; - private String typeLesson; - private Long classroomId; - private String classroomName; - private String day; - private String week; - private String time; - - public LessonResponse() { - } - - public LessonResponse(Long id, Long teacherId, Long groupId, Long subjectId, String day, String week, String time) { - this.id = id; - this.teacherId = teacherId; - this.groupId = groupId; - this.subjectId = subjectId; - this.day = day; - this.week = week; - this.time = time; - } - - public LessonResponse(Long id, String teacherName, String groupName, String classroomName, String educationFormName, String subjectName, String typeLesson, String lessonFormat, String day, String week, String time) { - this.id = id; - this.teacherName = teacherName; - this.groupName = groupName; - this.classroomName = classroomName; - this.educationFormName = educationFormName; - this.subjectName = subjectName; - this.typeLesson = typeLesson; - this.lessonFormat = lessonFormat; - this.day = day; - this.week = week; - this.time = time; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public String getTeacherName() { - return teacherName; - } - - public void setTeacherName(String teacherName) { - this.teacherName = teacherName; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public String getGroupName() { - return groupName; - } - - public void setGroupName(String groupName) { - this.groupName = groupName; - } - - public String getTypeLesson() { - return typeLesson; - } - - public void setTypeLesson(String typeLesson) { - this.typeLesson = typeLesson; - } - - public String getLessonFormat() { - return lessonFormat; - } - - public void setLessonFormat(String lessonFormat) { - this.lessonFormat = lessonFormat; - } - - public Long getClassroomId() { - return classroomId; - } - - public void setClassroomId(Long classroomId) { - this.classroomId = classroomId; - } - - public String getClassroomName() { - return classroomName; - } - - public void setClassroomName(String classroomName) { - this.classroomName = classroomName; - } - - public String getEducationFormName() { - return educationFormName; - } - - public void setEducationFormName(String educationFormName) { - this.educationFormName = educationFormName; - } - - public Long getSubjectId() { - return subjectId; - } - - public void setSubjectId(Long subjectId) { - this.subjectId = subjectId; - } - - public String getSubjectName() { - return subjectName; - } - - public void setSubjectName(String subjectName) { - this.subjectName = subjectName; - } - - public String getDay() { - return day; - } - - public void setDay(String day) { - this.day = day; - } - - public String getWeek() { - return week; - } - - public void setWeek(String week) { - this.week = week; - } - - public String getTime() { - return time; - } - - public void setTime(String time) { - this.time = time; - } -} diff --git a/backend/src/main/java/com/magistr/app/dto/LessonTypeResponse.java b/backend/src/main/java/com/magistr/app/dto/LessonTypeResponse.java new file mode 100644 index 0000000..8e44247 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/LessonTypeResponse.java @@ -0,0 +1,7 @@ +package com.magistr.app.dto; + +public record LessonTypeResponse( + Long id, + String name +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java b/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java deleted file mode 100644 index 36b8f28..0000000 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.magistr.app.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.magistr.app.model.SemesterType; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class ScheduleResponse { - private Long id; - private String specialityCode; - private Long departmentId; - private Long groupId; - private String groupName; - private Integer groupCourse; - private Integer groupSemester; - private Long subjectsId; - private String subjectName; - private Long lessonTypeId; - private String lessonType; - private Long numberOfHours; - private Boolean division; - private Long teacherId; - private String teacherName; - private String teacherJobTitle; - private SemesterType semesterType; - private String period; - - public ScheduleResponse(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, String lessonType, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) { - this.id = id; - this.departmentId = departmentId; - this.groupId = groupId; - this.subjectsId = subjectsId; - this.lessonTypeId = lessonTypeId; - this.numberOfHours = numberOfHours; - this.division = division; - this.teacherId = teacherId; - this.semesterType = semesterType; - this.period = period; - } - - public ScheduleResponse(Long id, Long departmentId, String specialityCode, String groupName, Integer groupCourse, Integer groupSemester, String subjectName, String lessonType, Long numberOfHours, Boolean division, String teacherName, String teacherJobTitle, SemesterType semesterType, String period) { - this.id = id; - this.departmentId = departmentId; - this.specialityCode = specialityCode; - this.groupName = groupName; - this.groupCourse = groupCourse; - this.groupSemester = groupSemester; - this.subjectName = subjectName; - this.lessonType = lessonType; - this.numberOfHours = numberOfHours; - this.division = division; - this.teacherName = teacherName; - this.teacherJobTitle = teacherJobTitle; - this.semesterType = semesterType; - this.period = period; - } - - public Long getId() { - return id; - } - - public String getSpecialityCode() { - return specialityCode; - } - - public Long getDepartmentId() { - return departmentId; - } - - public Long getGroupId() { - return groupId; - } - - public String getGroupName() { - return groupName; - } - - public Integer getGroupCourse() { - return groupCourse; - } - - public Integer getGroupSemester() { - return groupSemester; - } - - public Long getSubjectsId() { - return subjectsId; - } - - public String getSubjectName() { - return subjectName; - } - - public Long getLessonTypeId() { - return lessonTypeId; - } - - public String getLessonType() { - return lessonType; - } - - public Long getNumberOfHours() { - return numberOfHours; - } - - public Boolean getDivision() { - return division; - } - - public Long getTeacherId() { - return teacherId; - } - - public String getTeacherName() { - return teacherName; - } - - public String getTeacherJobTitle() { - return teacherJobTitle; - } - - public SemesterType getSemesterType() { - return semesterType; - } - - public String getPeriod() { - return period; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/Lesson.java b/backend/src/main/java/com/magistr/app/model/Lesson.java deleted file mode 100755 index b28d27b..0000000 --- a/backend/src/main/java/com/magistr/app/model/Lesson.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name = "lessons") -public class Lesson { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "teacher_id", nullable = false) - private Long teacherId; - - @Column(name = "group_id", nullable = false) - private Long groupId; - - @Column(name = "subject_id", nullable = false) - private Long subjectId; - - @Column(name = "lesson_format", nullable = false, length = 255) - private String lessonFormat; - - @Column(name = "type_lesson", nullable = false, length = 255) - private String typeLesson; - - @Column(name = "classroom_id", nullable = false) - private Long classroomId; - - @Column(name = "day", nullable = false, length = 255) - private String day; - - @Column(name = "week", nullable = false, length = 255) - private String week; - - @Column(name = "time", nullable = false, length = 255) - private String time; - - public Lesson() { - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectId() { - return subjectId; - } - - public void setSubjectId(Long subjectId) { - this.subjectId = subjectId; - } - - public String getLessonFormat() { - return lessonFormat; - } - - public void setLessonFormat(String lessonFormat) { - this.lessonFormat = lessonFormat; - } - - public String getTypeLesson() { - return typeLesson; - } - - public void setTypeLesson(String typeLesson) { - this.typeLesson = typeLesson; - } - - public Long getClassroomId() { - return classroomId; - } - - public void setClassroomId(Long classroomId) { - this.classroomId = classroomId; - } - - public String getDay() { - return day; - } - - public void setDay(String day) { - this.day = day; - } - - public String getWeek() { - return week; - } - - public void setWeek(String week) { - this.week = week; - } - - public String getTime() { - return time; - } - - public void setTime(String time) { - this.time = time; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleData.java b/backend/src/main/java/com/magistr/app/model/ScheduleData.java deleted file mode 100644 index bdd485f..0000000 --- a/backend/src/main/java/com/magistr/app/model/ScheduleData.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name="schedule_data") -public class ScheduleData { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name="department_id", nullable = false) - private Long departmentId; - - @Column(name="group_id", nullable = false) - private Long groupId; - - @Column(name="subjects_id", nullable = false) - private Long subjectsId; - - @Column(name="lesson_type_id", nullable = false) - private Long lessonTypeId; - - @Column(name="number_of_hours", nullable = false) - private Long numberOfHours; - - @Column(name="is_division", nullable = false) - private Boolean division; - - @Column(name="teacher_id", nullable = false) - private Long teacherId; - - @Enumerated(EnumType.STRING) - @Column(name="semester_type", nullable = false) - private SemesterType semesterType; - - @Column(name="period", nullable = false) - private String period; - - public ScheduleData() {} - - public ScheduleData(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) { - this.id = id; - this.departmentId = departmentId; - this.groupId = groupId; - this.subjectsId = subjectsId; - this.lessonTypeId = lessonTypeId; - this.numberOfHours = numberOfHours; - this.division = division; - this.teacherId = teacherId; - this.semesterType = semesterType; - this.period = period; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getDepartmentId() { - return departmentId; - } - - public void setDepartmentId(Long departmentId) { - this.departmentId = departmentId; - } - - public Long getGroupId() { - return groupId; - } - - public void setGroupId(Long groupId) { - this.groupId = groupId; - } - - public Long getSubjectsId() { - return subjectsId; - } - - public void setSubjectsId(Long subjectsId) { - this.subjectsId = subjectsId; - } - - public Long getLessonTypeId() { - return lessonTypeId; - } - - public void setLessonTypeId(Long lessonTypeId) { - this.lessonTypeId = lessonTypeId; - } - - public Long getNumberOfHours() { - return numberOfHours; - } - - public void setNumberOfHours(Long numberOfHours) { - this.numberOfHours = numberOfHours; - } - - public Boolean getDivision() { - return division; - } - - public void setDivision(Boolean division) { - this.division = division; - } - - public Long getTeacherId() { - return teacherId; - } - - public void setTeacherId(Long teacherId) { - this.teacherId = teacherId; - } - - public SemesterType getSemesterType() { - return semesterType; - } - - public void setSemesterType(SemesterType semesterType) { - this.semesterType = semesterType; - } - - public String getPeriod() { - return period; - } - - public void setPeriod(String period) { - this.period = period; - } -} diff --git a/backend/src/main/java/com/magistr/app/repository/LessonRepository.java b/backend/src/main/java/com/magistr/app/repository/LessonRepository.java deleted file mode 100755 index f02e9b5..0000000 --- a/backend/src/main/java/com/magistr/app/repository/LessonRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.Lesson; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface LessonRepository extends JpaRepository { - - Optional findBySubjectId(Long subjectId); - - List findByTeacherId(Long teacherId); -} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java deleted file mode 100644 index e9c0318..0000000 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleDataRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.ScheduleData; -import com.magistr.app.model.SemesterType; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface ScheduleDataRepository extends JpaRepository { - - List findByDepartmentIdAndSemesterTypeAndPeriod(Long departmentId, SemesterType semesterType, String period); - - boolean existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod( - Long departmentId, - Long groupId, - Long subjectsId, - Long lessonTypeId, - Long numberOfHours, - Boolean division, - Long teacherId, - SemesterType semesterType, - String period - ); -} diff --git a/backend/src/main/java/com/magistr/app/utils/DayAndWeekValidator.java b/backend/src/main/java/com/magistr/app/utils/DayAndWeekValidator.java deleted file mode 100755 index b4461f9..0000000 --- a/backend/src/main/java/com/magistr/app/utils/DayAndWeekValidator.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.magistr.app.utils; - -import java.util.Set; - -public class DayAndWeekValidator { - - private static final Set VALID_DAYS = Set.of( - "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота" - ); - - private static final Set VALID_WEEKS = Set.of( - "Верхняя", "Нижняя", "Обе" - ); - - public static boolean isValidDay(String day) { - return day != null && VALID_DAYS.contains(day); - } - - public static boolean isValidWeek(String week) { - return week != null && VALID_WEEKS.contains(week); - } - - public static String getValidDaysMessage() { - return "Допустимые дни: " + String.join(", ", VALID_DAYS); - } - - public static String getValidWeekMessage() { - return "Допустимы для выбора: " + String.join(", ", VALID_WEEKS); - } -} diff --git a/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java b/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java deleted file mode 100644 index f4b53d7..0000000 --- a/backend/src/main/java/com/magistr/app/utils/SemesterTypeValidator.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.magistr.app.utils; - -import com.magistr.app.model.SemesterType; - -import java.util.Arrays; - -public class SemesterTypeValidator { - - public static boolean isValidTypeSemester(String semesterType) { - if (semesterType == null) { - return false; - } - try { - SemesterType.valueOf(semesterType); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - - public static String getValidTypes() { - return String.join(", ", Arrays.stream(SemesterType.values()) - .map(Enum::name) - .toArray(String[]::new)); - } -} diff --git a/backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java b/backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java deleted file mode 100755 index 21e3146..0000000 --- a/backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.magistr.app.utils; - -import java.util.Set; - -public class TypeAndFormatLessonValidator { - - private static final Set VALID_TYPES = Set.of( - "Лекция", "Лабораторная работа", "Практическая работа" - ); - - private static final Set VALID_FORMATS = Set.of( - "Онлайн", "Очно" - ); - - public static boolean isValidType(String type) { - return type != null && VALID_TYPES.contains(type); - } - - public static boolean isValidFormat(String format) { - return format != null && VALID_FORMATS.contains(format); - } - - public static String getValidTypesMessage() { - return "Допустимые типы: " + String.join(", ", VALID_TYPES); - } - - public static String getValidFormatsMessage() { - return "Допустимые форматы: " + String.join(", ", VALID_FORMATS); - } -} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 2eb31e9..de034a4 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -429,35 +429,6 @@ JOIN users teacher ON teacher.username = data.teacher_username JOIN classrooms classroom ON classroom.name = data.classroom_name JOIN lesson_types lt ON lt.name = data.lesson_type_name; --- ========================================== --- Временные таблицы старой модели. Будут удалены после очистки старого кода. --- ========================================== -CREATE TABLE IF NOT EXISTS lessons ( - id BIGSERIAL PRIMARY KEY, - teacher_id BIGINT NOT NULL REFERENCES users(id), - group_id BIGINT NOT NULL REFERENCES student_groups(id), - subject_id BIGINT NOT NULL REFERENCES subjects(id), - lesson_format VARCHAR(255) NOT NULL, - type_lesson VARCHAR(255) NOT NULL, - classroom_id BIGINT NOT NULL REFERENCES classrooms(id), - day VARCHAR(255) NOT NULL, - week VARCHAR(255) NOT NULL, - time VARCHAR(255) NOT NULL -); - -CREATE TABLE IF NOT EXISTS schedule_data ( - id BIGSERIAL PRIMARY KEY, - department_id BIGINT NOT NULL REFERENCES departments(id), - group_id BIGINT NOT NULL REFERENCES student_groups(id), - subjects_id BIGINT NOT NULL REFERENCES subjects(id), - lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), - number_of_hours INT NOT NULL, - is_division BOOLEAN NOT NULL DEFAULT FALSE, - teacher_id BIGINT NOT NULL REFERENCES users(id), - semester_type VARCHAR(255) NOT NULL, - period VARCHAR(255) NOT NULL -); - -- ========================================== -- Функция обновления timestamp -- ========================================== @@ -478,10 +449,8 @@ CREATE TRIGGER update_users_updated_at -- Комментарии к таблицам и полям (для документации) -- ========================================== COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)'; -COMMENT ON TABLE lessons IS 'Временная таблица старой модели расписания до удаления старого кода'; COMMENT ON TABLE departments IS 'Кафедры'; COMMENT ON TABLE specialties IS 'Специальности'; -COMMENT ON TABLE schedule_data IS 'Временная таблица старой модели плановой нагрузки до удаления старого кода'; COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; COMMENT ON TABLE academic_years IS 'Учебные годы'; COMMENT ON TABLE semesters IS 'Семестры учебного года'; @@ -490,15 +459,6 @@ COMMENT ON TABLE academic_calendar_matrix IS 'Матрица учебного г COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; -COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры'; -COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; -COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; -COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия'; -COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов'; -COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным'; -COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя'; -COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)'; -COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)'; COMMENT ON TABLE education_forms IS 'Формы обучения'; COMMENT ON TABLE subgroups IS 'Подгруппы'; @@ -574,17 +534,6 @@ COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета'; COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя'; COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания'; -COMMENT ON COLUMN lessons.id IS 'ID урока'; -COMMENT ON COLUMN lessons.teacher_id IS 'Идентификатор преподавателя, который проводит урок'; -COMMENT ON COLUMN lessons.group_id IS 'ID группы, в которой проходит урок'; -COMMENT ON COLUMN lessons.subject_id IS 'ID предмета, который преподается'; -COMMENT ON COLUMN lessons.lesson_format IS 'Формат урока'; -COMMENT ON COLUMN lessons.type_lesson IS 'Тип урока'; -COMMENT ON COLUMN lessons.classroom_id IS 'ID аудитории, в которой проходит урок'; -COMMENT ON COLUMN lessons.day IS 'День недели, в который проходит урок'; -COMMENT ON COLUMN lessons.week IS 'Номер недели, в которой проходит урок'; -COMMENT ON COLUMN lessons.time IS 'Время урока'; - COMMENT ON COLUMN departments.id IS 'ID кафедры'; COMMENT ON COLUMN departments.name IS 'Название кафедры'; COMMENT ON COLUMN departments.code IS 'Код кафедры'; diff --git a/docs/API.md b/docs/API.md index 8ec374f..a7f633b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -233,110 +233,56 @@ CRUD доступен по: | `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | | `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило | ---- +### `GET /api/lesson-types` -## Расписание (Lessons, deprecated) +Справочник типов занятий для конструктора правил расписания. -Старые эндпоинты ещё физически есть в коде до удаления старых контроллеров и таблиц. Новый просмотр расписания использует `GET /api/schedule`, а новые seed-данные создаются сразу в динамической модели. - -### `GET /api/users/lessons` - -Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория). - -**Ответ:** +**Ответ:** ```json [ - { - "id": 1, - "teacherName": "Тестовый преподаватель", - "groupName": "ИВТ-21-1", - "classroomName": "101 Ленинская", - "educationFormName": "Бакалавриат", - "subjectName": "Высшая математика", - "typeLesson": "Лекция", - "lessonFormat": "Очно", - "day": "Понедельник", - "week": "Верхняя", - "time": "11:40 - 13:10" - } + { "id": 1, "name": "Лекция" }, + { "id": 2, "name": "Практика" } ] ``` -### `GET /api/users/lessons/{teacherId}` +--- -Занятия конкретного преподавателя. +## Кафедры и специальности -### `POST /api/users/lessons/create` +### Кафедры -Создание занятия. +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/departments` | Список кафедр | +| `POST` | `/api/departments` | Создать кафедру | +| `PUT` | `/api/departments/{id}` | Обновить кафедру | +| `DELETE` | `/api/departments/{id}` | Удалить кафедру | -**Тело запроса:** +**Тело создания/обновления:** ```json { - "teacherId": 2, - "groupId": 1, - "subjectId": 1, - "lessonFormat": "Очно", - "typeLesson": "Лекция", - "classroomId": 1, - "day": "Понедельник", - "week": "Верхняя", - "time": "11:40 - 13:10" + "departmentName": "Кафедра ИБ", + "departmentCode": 1 } ``` -**Валидация:** +### Специальности -| Поле | Правило | -|------|---------| -| `teacherId` | Обязателен, ≠ 0 | -| `groupId` | Обязателен, ≠ 0 | -| `subjectId` | Обязателен, ≠ 0 | -| `lessonFormat` | `Очно` или `Онлайн` | -| `typeLesson` | `Лекция`, `Практическая работа`, `Лабораторная работа` | -| `classroomId` | Обязателен, ≠ 0 | -| `day` | Пн–Сб (на русском) | -| `week` | `Верхняя`, `Нижняя`, `Обе` | -| `time` | Обязателен | +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/specialties` | Список специальностей | +| `POST` | `/api/specialties` | Создать специальность | +| `PUT` | `/api/specialties/{id}` | Обновить специальность | +| `DELETE` | `/api/specialties/{id}` | Удалить специальность | - -### `PUT /api/users/lessons/update/{lessonId}` - -Обновление занятия. Поддерживает partial update — передаются только изменённые поля. - -**Тело ответа:** +**Тело создания/обновления:** ```json { - "id": 5, - "teacherId": 1, - "groupId": 1, - "subjectId": 2, - "LessonFormat": "Онлайн", - "typeLesson": "Практическая работа", - "classroomId": 3, - "day": "Понедельник", - "week": "Верхняя", - "time": "9:40 - 11:10", - "updatedFields": { - "teacherId": 1, - "subjectId": 2, - "lessonFormat": "Онлайн", - "classroomId": 3, - "day": "Понедельник", - "time": "9:40 - 11:10" - }, - "message": "Занятие успешно обновлено" + "specialityName": "Программная инженерия", + "specialityCode": "09.03.04" } ``` -### `DELETE /api/users/lessons/delete/{lessonId}` - -Удаление занятия. - -### `GET /api/users/lessons/ping` - -Проверка доступности контроллера. Возвращает строку `pong`. - --- ## Группы @@ -375,11 +321,13 @@ CRUD доступен по: "groupSize": 12, "educationFormId": 1, "departmentId": 1, - "course": 2, + "yearStartStudy": 2026, "specialityCode": 1 } ``` +`specialityCode` исторически содержит ID записи из `/api/specialties`; текущий курс вычисляется из `yearStartStudy`. + ### `DELETE /api/groups/{id}` Удаление группы. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index a87d978..da1e6ea 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -91,24 +91,6 @@ Праздник считается пропуском: занятие не переносится и не списывает академические часы. -### Временная старая сущность «Занятие» (Lesson) - -`lessons` пока физически остаётся в схеме до удаления старого кода, но базовая миграция больше не заполняет её тестовыми данными. Новые экраны просмотра используют `GET /api/schedule`. - -Каждая запись в расписании содержит: - -| Поле | Описание | Пример | -|------|----------|--------| -| `teacher_id` | Преподаватель | 2 | -| `group_id` | Учебная группа | 1 | -| `subject_id` | Дисциплина | 3 | -| `lesson_format` | Формат проведения | `Очно`, `Онлайн` | -| `type_lesson` | Тип занятия | `Лекция`, `Практическая работа`, `Лабораторная работа` | -| `classroom_id` | Аудитория | 1 | -| `day` | День недели | `Понедельник` ... `Суббота` | -| `week` | Чётность недели | `Верхняя`, `Нижняя`, `Обе` | -| `time` | Временной слот | `8:00 - 9:30` | - ### Временны́е слоты Сетка пар хранится в `time_slots` и настраивается для каждого тенанта. При миграции создаются базовые слоты: @@ -123,30 +105,12 @@ | 6 | 16:40 – 18:10 | | 7 | 18:30 – 20:00 | -### Валидация при создании/обновлении +### Валидация правил расписания -- **Дни:** только `Понедельник` – `Суббота` (`DayAndWeekValidator`) -- **Недели:** только `Верхняя`, `Нижняя`, `Обе` -- **Формат:** только `Очно`, `Онлайн` (`TypeAndFormatLessonValidator`) -- **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа` -- Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0 - -### Временные старые данные к составлению расписания (Schedule Data) - -Таблица `schedule_data` пока физически остаётся в схеме до удаления старого кода. Новая базовая миграция создаёт `schedule_rules` и `schedule_rule_groups` напрямую, без ETL из `schedule_data`. - -| Поле | Описание | -|------|----------| -| `department_id` | Кафедра | -| `semester` | Номер семестра | -| `group_id` | Учебная группа | -| `subjects_id` | Дисциплина | -| `lesson_type_id` | Тип занятия | -| `number_of_hours` | Количество часов | -| `is_division` | Деление на подгруппы | -| `teacher_id` | Преподаватель | -| `semester_type` | Тип семестра (Весенний / Осенний) | -| `period` | Учебный год (напр. `2024/2025`) | +- **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа. +- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. +- **Связанные сущности:** временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. +- **Формат:** `lessonFormat` обязателен и хранится в слоте правила. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 8e5d67e..5dd0cd8 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -117,33 +117,6 @@ erDiagram BIGINT lesson_type_id FK,PK } - lessons { - BIGSERIAL id PK - BIGINT teacher_id FK - BIGINT group_id FK - BIGINT subject_id FK - VARCHAR lesson_format - VARCHAR type_lesson - BIGINT classroom_id FK - VARCHAR day - VARCHAR week - VARCHAR time - } - - schedule_data { - BIGSERIAL id PK - BIGINT department_id FK - INT semester - BIGINT group_id FK - BIGINT subjects_id FK - BIGINT lesson_type_id FK - INT number_of_hours - BOOLEAN is_division - BIGINT teacher_id FK - VARCHAR semester_type - VARCHAR period - } - time_slots { BIGSERIAL id PK INT order_number UK @@ -212,26 +185,17 @@ erDiagram departments ||--o{ users : "department_id" departments ||--o{ student_groups : "department_id" departments ||--o{ subjects : "department_id" - departments ||--o{ schedule_data : "department_id" education_forms ||--o{ student_groups : "education_form_id" specialties ||--o{ student_groups : "specialty_code" student_groups ||--o{ subgroups : "group_id" - student_groups ||--o{ lessons : "group_id" - student_groups ||--o{ schedule_data : "group_id" student_groups ||--o{ schedule_rule_groups : "group_id" - users ||--o{ lessons : "teacher_id" users ||--o{ teacher_subjects : "user_id" users ||--o{ teacher_lesson_types : "user_id" - users ||--o{ schedule_data : "teacher_id" - subjects ||--o{ lessons : "subject_id" subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id" - subjects ||--o{ schedule_data : "subjects_id" subjects ||--o{ schedule_rules : "subject_id" lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" - lesson_types ||--o{ schedule_data : "lesson_type_id" lesson_types ||--o{ schedule_rule_slots : "lesson_type_id" - classrooms ||--o{ lessons : "classroom_id" classrooms ||--o{ schedule_rule_slots : "classroom_id" classrooms ||--o{ classroom_equipments : "classroom_id" equipments ||--o{ classroom_equipments : "equipment_id" @@ -352,23 +316,6 @@ erDiagram ### Расписание -#### `lessons` — Временная таблица старой модели - -Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не заполняет её тестовыми данными. Целевой источник расписания — `schedule_rules` и `schedule_rule_slots`. - -| Колонка | Тип | Описание | -|---------|-----|----------| -| `id` | BIGSERIAL PK | ID | -| `teacher_id` | BIGINT FK → users | Преподаватель | -| `group_id` | BIGINT FK → student_groups | Группа | -| `subject_id` | BIGINT FK → subjects | Дисциплина | -| `lesson_format` | VARCHAR(255) | `Очно` / `Онлайн` | -| `type_lesson` | VARCHAR(255) | `Лекция` / `Практическая работа` / `Лабораторная работа` | -| `classroom_id` | BIGINT FK → classrooms | Аудитория | -| `day` | VARCHAR(255) | День недели | -| `week` | VARCHAR(255) | `Верхняя` / `Нижняя` / `Обе` | -| `time` | VARCHAR(255) | Временной слот | - #### `lesson_types` — Типы занятий (справочник) | Колонка | Тип | Описание | |---------|-----|----------| @@ -394,26 +341,6 @@ erDiagram | `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | | `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия | -#### `schedule_data` — Временная таблица старой модели нагрузки - -Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не использует её как источник seed-данных. - -| Колонка | Тип | Описание | -|---------|-----|----------| -| `id` | BIGSERIAL PK | ID | -| `department_id` | BIGINT FK → departments | Кафедра | -| `semester` | INT | Номер семестра | -| `group_id` | BIGINT FK → student_groups | Группа | -| `subjects_id` | BIGINT FK → subjects | Дисциплина | -| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | -| `number_of_hours` | INT | Количество часов | -| `is_division` | BOOLEAN | Деление на подгруппы | -| `teacher_id` | BIGINT FK → users | Преподаватель | -| `semester_type` | VARCHAR(255) | Весенний / Осенний | -| `period` | VARCHAR(255) | Учебный год | - -### Динамическое расписание - #### `time_slots` — Временные слоты занятий | Колонка | Тип | Описание | |---------|-----|----------| diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a0ba3a2..f7727e9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -50,8 +50,8 @@ docker compose up -d # Пересоздаёт с нуля | Категория | Стиль | Пример | |-----------|-------|--------| -| Классы | PascalCase | `LessonsController`, `LessonResponse` | -| Методы и переменные | camelCase | `getAllLessons()`, `teacherId` | +| Классы | PascalCase | `ScheduleController`, `RenderedLessonDto` | +| Методы и переменные | camelCase | `buildScheduleForTeacher()`, `teacherId` | | Константы | UPPER_SNAKE_CASE | `ROLE_REDIRECTS` | | Пакеты | lowercase | `com.magistr.app.controller` | @@ -79,7 +79,7 @@ logger.error("Ошибка при сохранении: {}", e.getMessage(), e); #### Валидация -- Для сложных правил — отдельные классы-валидаторы (`DayAndWeekValidator`, `TypeAndFormatLessonValidator`) +- Для сложных правил — отдельные сервисы или приватные методы в контроллере с понятным сообщением об ошибке - Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()` #### Импорты @@ -257,7 +257,7 @@ com.magistr.app/ │ └── ConfigMapUpdater.java # Обновление K8s ConfigMap ├── controller/ # REST-контроллеры │ ├── AuthController.java -│ ├── LessonsController.java +│ ├── ScheduleController.java │ ├── ClassroomController.java │ ├── DatabaseController.java │ ├── UserController.java @@ -269,7 +269,5 @@ com.magistr.app/ ├── dto/ # Data Transfer Objects ├── model/ # JPA-сущности ├── repository/ # Spring Data JPA -└── utils/ # Валидаторы - ├── DayAndWeekValidator.java - └── TypeAndFormatLessonValidator.java +└── utils/ # Общие утилиты ``` diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 391558d..c71916b 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -29,7 +29,6 @@ frontend/ │ │ ├── layout.css # Раскладка (sidebar, topbar, content) │ │ ├── components.css # Кнопки, таблицы, карточки, формы │ │ ├── modals.css # Модальные окна -│ │ ├── department.css # Стили кафедры │ │ └── departments-data.css # Стили создания кафедры/специальности │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация @@ -43,9 +42,8 @@ frontend/ │ │ ├── subjects.js # Управление дисциплинами │ │ ├── equipments.js # Управление оборудованием │ │ ├── edu-forms.js # Формы обучения -│ │ ├── schedule.js # Расписание занятий +│ │ ├── schedule.js # Конструктор правил, сетки пар и учебного графика │ │ ├── database.js # Управление тенантами -│ │ ├── department.js # Кафедры │ │ └── departments-data.js # Создание кафедры/специальности │ ├── views/ # HTML-шаблоны представлений │ │ ├── users.html @@ -56,7 +54,6 @@ frontend/ │ │ ├── edu-forms.html │ │ ├── schedule.html │ │ ├── database.html -│ │ ├── department.html │ │ └── departments-data.html │ │ │ └── settings/ # ⚙️ Страница настроек (отдельный SPA) @@ -106,10 +103,15 @@ frontend/ | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Старый админский экран расписания до удаления | `/api/users/lessons`; новые правила — `/api/admin/schedule-rules` | +| `schedule` | Конструктор правил динамического расписания, сетка пар, учебные годы, семестры, праздники и матрица графика | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar`, `/api/lesson-types` | | `database` | Тенанты | `/api/database` | -| `department` | Кафедры | `/api/departments` | -| `departments-data` | Создание кафедры/специальности | `/api/departments` | +| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | + +### Особенности админских вкладок + +- Вкладка `groups` загружает кафедры и специальности из справочников и при создании группы отправляет `yearStartStudy`; поле `specialityCode` содержит ID выбранной специальности. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. +- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. ### Страница настроек (`/admin/settings/`) @@ -226,8 +228,7 @@ export function isAuthenticatedAsAdmin() { 2. **`layout.css`** — Sidebar, topbar, content area, dropdown настроек, responsive 3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы, theme-toggle 4. **`modals.css`** — Модальные окна -5. **`department.css`** — Стили страницы кафедр -6. **`departments-data.css`** — Стили создания кафедры/специальности +5. **`departments-data.css`** — Стили создания кафедры/специальности ### Темизация diff --git a/docs/LOGGING.md b/docs/LOGGING.md index 41ad688..f26455a 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -94,7 +94,7 @@ MDC.remove("tenant.id"); | `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции | | `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s | | `DataInitializer` | INFO | Инициализация БД при старте | -| `LessonsController` | INFO, DEBUG, ERROR | CRUD-операции с занятиями, валидация | +| `ScheduleController` | INFO, ERROR | Запросы динамического расписания и ошибки генерации | ### Паттерн использования diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index c43f42d..8ea95ae 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -95,7 +95,7 @@ color: var(--text-secondary); } -/* Lesson Cards inside grid cells */ +/* Карточки занятий внутри ячеек сетки */ .lesson-card { background: var(--bg-card); border: 1px solid var(--bg-card-border); diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 3dbb57e..c0cb7b0 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -104,6 +104,104 @@ opacity: 0.5; } +.schedule-section-label { + display: block; + font-size: 0.78rem; + font-weight: 500; + color: var(--text-secondary); + margin: 1rem 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.schedule-checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.35rem; + padding: 0.5rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.schedule-slots-panel { + margin-top: 1rem; +} + +.schedule-slots-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.schedule-slot-row { + display: grid; + grid-template-columns: repeat(7, minmax(130px, 1fr)) auto; + gap: 0.75rem; + align-items: end; + padding: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.schedule-slot-summary, +.schedule-semester-line { + margin-bottom: 0.35rem; +} + +.schedule-semester-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.schedule-inline-table { + margin-top: 1rem; +} + +.matrix-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + margin-top: 1rem; +} + +.matrix-week { + display: grid; + gap: 0.45rem; + padding: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.matrix-week > span { + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.muted { + color: var(--text-secondary); +} + +@media (max-width: 1180px) { + .schedule-slot-row { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } +} + +@media (max-width: 640px) { + .schedule-slot-row { + grid-template-columns: 1fr; + } +} + /* Hide Number Arrows */ input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { @@ -1032,4 +1130,4 @@ tbody tr:hover { position: fixed; top: 1.25rem; right: 1.25rem; -} \ No newline at end of file +} diff --git a/frontend/admin/css/department.css b/frontend/admin/css/department.css deleted file mode 100644 index 88b83c9..0000000 --- a/frontend/admin/css/department.css +++ /dev/null @@ -1,344 +0,0 @@ -/* ===== Оверлей для модалок создания записей (к/ф) ===== */ -.cs-overlay { - display: none; - position: fixed; - inset: 0; - z-index: 1000; - background: rgba(0, 0, 0, 0.55); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); -} - -.cs-overlay.open { - display: block; -} - -.cs-overlay-scroll { - width: 100%; - height: 100%; - overflow-y: auto; - padding: 2rem 1rem; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -/* Общие стили для обеих модалок */ -.cs-modal { - width: 100%; - max-width: 1100px; - position: relative; - animation: csModalAppear 0.25s ease-out; -} - -/* Модалка 1 (форма) всегда поверх модалки 2 (таблицы), - чтобы выпадающие списки не уходили под таблицу */ -.cs-modal-form { - z-index: 2; -} - -.cs-modal-table { - z-index: 1; -} - -@keyframes csModalAppear { - from { opacity: 0; transform: translateY(-12px); } - to { opacity: 1; transform: translateY(0); } -} - -.cs-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.cs-modal-header h2 { - margin: 0; -} - -/* Кнопка закрытия */ -.btn-close-panel { - background: none; - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - font-size: 1.3rem; - line-height: 1; - padding: 0.25rem 0.6rem; - color: var(--text-secondary); - cursor: pointer; - transition: color var(--transition), background var(--transition), border-color var(--transition); -} - -.btn-close-panel:hover { - color: var(--error); - background: rgba(239, 68, 68, 0.1); - border-color: var(--error); -} - -.wrap{ - max-width: 900px; - margin: 0 auto; - background: var(--bg-card); - border: 1px solid var(--bg-card-border); - border-radius: 12px; - overflow: hidden; - box-shadow: 0 6px 20px rgba(0,0,0,.06); -} - -.header{ - padding: 14px 16px; - border-bottom: 1px solid var(--bg-card-border); - font-weight: 700; - color: var(--text-primary); -} - -details.table-item{ - border-top: 1px solid var(--bg-card-border); -} -details.table-item:first-of-type{ border-top:none; } - -summary{ - list-style: none; - cursor: pointer; - user-select: none; - padding: 12px 16px; - display: flex; - align-items: center; - gap: 10px; -} -summary::-webkit-details-marker{ display:none; } - -.chev{ - width: 28px; - height: 28px; - border: 1px solid var(--bg-card-border); - border-radius: 10px; - display: grid; - place-items: center; - flex: 0 0 auto; - - color: var(--text-secondary); - background: var(--bg-input); - - transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease; -} - -.chev-icon{ - width: 16px; - height: 16px; - display: block; -} - -summary:hover .chev{ - background: var(--bg-hover); - border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border)); - color: var(--text-primary); -} - -details[open] .chev{ - transform: rotate(180deg); - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border)); - background: color-mix(in srgb, var(--accent) 10%, var(--bg-input)); -} - -.meta{ color: var(--text-secondary); font-size: 12px; } - -.content{ padding: 0 16px 16px 16px; } - -.wrap table{ - width: 100%; - border-collapse: collapse; - border: 1px solid var(--bg-card-border); - border-radius: 10px; - overflow: hidden; - background: var(--bg-card); -} - -.wrap thead th{ - text-align: left; - font-size: 13px; - color: var(--text-secondary); - background: var(--bg-input); - border-bottom: 1px solid var(--bg-card-border); - padding: 10px 12px; -} - -.wrap tbody td{ - padding: 10px 12px; - border-bottom: 1px solid var(--bg-card-border); - font-size: 14px; - color: var(--text-primary); -} - -.wrap tbody tr:hover{ background: var(--bg-hover); } - -.title-multiline{ - display: flex; - flex-direction: column; - gap: 2px; - line-height: 1.2; -} - -.title-multiline .title-main{ - font-weight: 700; - color: var(--text-primary); -} - -.title-multiline .title-sub{ - font-weight: 500; - font-size: 12px; - color: var(--text-secondary); -} - -.title-multiline b{ - font-weight: 700; - color: var(--text-primary); -} - -/* summary = 3 колонки: [chev] [title] [meta] */ -details.table-item > summary{ - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 12px; - align-items: start; /* важно: всё прижимаем к верху */ - padding: 12px 16px; -} - -/* чтобы текст нормально переносился и не растягивал мету */ -details.table-item > summary .title{ - min-width: 0; /* важно для grid, иначе может распирать */ -} - -/* "2 записи" всегда справа и сверху, аккуратно */ -details.table-item > summary .meta{ - justify-self: end; - align-self: start; - white-space: nowrap; - padding-top: 4px; /* чуть опустить относительно первой строки */ - font-size: 12px; - color: var(--text-secondary); -} - -/* стрелка тоже сверху */ -details.table-item > summary .chev{ - align-self: start; - margin-top: 2px; -} - -.records-search{ - width: min(360px, 60vw); - padding: 0.45rem 0.7rem; - background: var(--bg-input); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: 0.9rem; - outline: none; - transition: border-color .2s ease, box-shadow .2s ease, background .2s ease; -} - -.records-search::placeholder{ color: var(--text-placeholder); } - -.records-search:focus{ - background: var(--bg-input-focus); - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); -} -/* Таблица внутри раскрывающегося блока */ -details.table-item .content table{ - width: 100%; - border-collapse: separate; /* нужно для красивых линий */ - border-spacing: 0; - border: 1px solid var(--bg-card-border); - border-radius: 12px; - overflow: hidden; - background: var(--bg-card); -} - -/* Шапка */ -details.table-item .content thead th{ - position: sticky; /* опционально: шапка прилипает при скролле */ - top: 0; - z-index: 1; - - background: var(--bg-input); - color: var(--text-secondary); - border-bottom: 1px solid var(--bg-card-border); -} - -/* Ячейки: одинаковые отступы */ -details.table-item .content th, -details.table-item .content td{ - padding: 0.75rem 0.85rem; - vertical-align: top; -} - -/* Вертикальные разделители между колонками */ -details.table-item .content th:not(:last-child), -details.table-item .content td:not(:last-child){ - border-right: 1px solid var(--bg-card-border); -} - -/* Горизонтальные разделители между строками */ -details.table-item .content tbody td{ - border-bottom: 1px solid var(--bg-card-border); - color: var(--text-primary); -} - -/* У последней строки нет нижней линии */ -details.table-item .content tbody tr:last-child td{ - border-bottom: none; -} - -/* "Зебра" для читабельности */ -details.table-item .content tbody tr:nth-child(even){ - background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover)); -} - -/* Ховер по строке */ -details.table-item .content tbody tr:hover{ - background: var(--bg-hover); -} - -/* (Опционально) Чтобы длинный текст не ломал ширину */ -details.table-item .content td{ - word-break: break-word; -} - -/* (Опционально) если таблица широкая — пусть скроллится горизонтально */ -details.table-item .content{ - overflow-x: auto; -} - -/* ===== Контейнер занятий преподавателя в модалках ===== */ -.cs-modal-table .lessons-container { - max-height: 50vh; - overflow-y: auto; - padding-right: 0.5rem; - scrollbar-width: thin; - scrollbar-color: rgba(99, 102, 241, 0.55) rgba(255, 255, 255, 0.06); -} - -.cs-modal-table .lessons-container::-webkit-scrollbar { - width: 10px; -} - -.cs-modal-table .lessons-container::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.06); - border-radius: 10px; -} - -.cs-modal-table .lessons-container::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.55); - border-radius: 10px; - border: 2px solid rgba(0, 0, 0, 0); - background-clip: padding-box; -} - -.cs-modal-table .lessons-container::-webkit-scrollbar-thumb:hover { - background: rgba(99, 102, 241, 0.75); -} - \ No newline at end of file diff --git a/frontend/admin/css/modals.css b/frontend/admin/css/modals.css index e4be11b..6235575 100644 --- a/frontend/admin/css/modals.css +++ b/frontend/admin/css/modals.css @@ -59,360 +59,3 @@ .modal-close:hover { color: var(--error); } - -/* ===== Кнопки ===== */ -.btn-add-lesson { - padding: 0.35rem 0.7rem; - background: rgba(16, 185, 129, 0.1); - border: 1px solid rgba(16, 185, 129, 0.2); - border-radius: var(--radius-sm); - color: var(--success); - font-family: inherit; - font-size: 0.8rem; - cursor: pointer; - transition: background var(--transition), transform var(--transition); - position: relative; - overflow: hidden; -} - -.btn-add-lesson:hover { - background: rgba(16, 185, 129, 0.2); - transform: scale(1.05); -} - -.btn-view-lessons { - padding: 0.35rem 0.7rem; - background: rgba(99, 102, 241, 0.1); - border: 1px solid rgba(99, 102, 241, 0.2); - border-radius: var(--radius-sm); - color: var(--accent); - font-family: inherit; - font-size: 0.8rem; - cursor: pointer; - transition: all var(--transition); - white-space: nowrap; -} - -.btn-view-lessons:hover { - background: rgba(99, 102, 241, 0.2); - transform: translateY(-1px); -} - -/* ===== Кнопки-переключатели (неделя) ===== */ -.btn-checkbox { - display: inline-block; - cursor: pointer; -} - -.btn-checkbox input { - position: absolute; - opacity: 0; - width: 0; - height: 0; -} - -.checkbox-btn { - display: inline-block; - padding: 0.5rem 1rem; - background: var(--bg-secondary); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - color: var(--text-primary); - transition: all var(--transition); - user-select: none; -} - -.btn-checkbox input:checked + .checkbox-btn { - background: var(--success, #10b981); - border-color: var(--success, #10b981); - color: #fff; -} - -/* =========================================================== - ===== 2-е модальное окно (View Lessons) — ОСНОВНЫЕ ПРАВКИ ===== - Требования: - - слева - - ~30% ширины - - сверху начинается СРАЗУ под 1-й модалкой - - высота = весь остаток до низа экрана - - визуально "ниже" 1-й модалки (и по z-index тоже ниже) - =========================================================== */ - -#modal-view-lessons.modal-overlay { - background: transparent !important; - backdrop-filter: none !important; - pointer-events: none; - z-index: 999; /* ниже чем 1-е (1000) */ -} - -/* В открытом состоянии: прижать влево и опустить вниз на высоту "шапки" */ -#modal-view-lessons.modal-overlay.open { - justify-content: flex-start; - align-items: flex-start; - - padding-left: 1rem; - padding-right: 1rem; - - /* ключевое: высота 1-й модалки приходит из JS через --add-lesson-height */ - padding-top: var(--add-lesson-height, 0px); -} - -/* Панель 2-й модалки */ -#modal-view-lessons .view-lessons-modal { - width: 30vw !important; - max-width: 30vw !important; - min-width: 320px; - - pointer-events: auto; - - background: var(--bg-primary); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-md); - padding: 2rem; - - position: relative; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); - - margin: 0; - - /* отключаем "пружинку" от .modal-content */ - transform: none; - - /* ключевое: занимает остаток по высоте */ - height: calc(100vh - var(--add-lesson-height, 0px)); - max-height: calc(100vh - var(--add-lesson-height, 0px)); - - /* чтобы скролл был внутри, а не у всей модалки */ - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* Header во 2-й модалке */ -#modal-veiw-lessons .modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding-right: 2rem; - flex: 0 0 auto; -} - -#modal-view-lessons .modal-header h2 { - margin: 0; - font-size: 1.3rem; - color: var(--text-primary); -} - -/* Контейнер занятий: растягивается и скроллится */ -#modal-view-lessons .lessons-container { - flex: 1 1 auto; - overflow-y: auto; - - /* перебиваем старое ограничение */ - max-height: none; - - padding-right: 0.5rem; -} - -/* ===== Карточки занятий ===== */ -.lesson-card { - background: var(--bg-card); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); - padding: 1.2rem; - margin-bottom: 1rem; - transition: all 0.2s ease; -} - -.lesson-card:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); - border-color: var(--accent); -} - -.lesson-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.8rem; - padding-bottom: 0.5rem; - border-bottom: 1px dashed var(--bg-card-border); -} - -.lesson-group { - font-weight: 700; - color: var(--accent); - font-size: 1rem; - background: rgba(99, 102, 241, 0.1); - padding: 0.3rem 0.8rem; - border-radius: 20px; -} - -.lesson-time { - color: var(--text-secondary); - font-size: 0.9rem; - display: flex; - align-items: center; - gap: 0.3rem; -} - -.lesson-time::before { - content: "🕒"; - font-size: 0.9rem; - opacity: 0.7; -} - -.lesson-card-body { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.lesson-subject { - font-weight: 600; - color: var(--text-primary); - font-size: 1.1rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.lesson-subject::before { - content: "📚"; - font-size: 1rem; - opacity: 0.7; -} - -.lesson-details { - display: flex; - flex-wrap: wrap; - gap: 0.8rem; - margin-top: 0.5rem; -} - -.lesson-detail-item { - background: var(--bg-input); - padding: 0.3rem 0.8rem; - border-radius: 15px; - font-size: 0.85rem; - color: var(--text-secondary); - border: 1px solid var(--bg-card-border); -} - -/* День недели как разделитель */ -.lesson-day-divider { - margin: 1.5rem 0 1rem 0; - font-weight: 700; - color: var(--accent); - font-size: 1.1rem; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 2px solid var(--accent-glow); - padding-bottom: 0.3rem; -} - -.lesson-day-divider:first-of-type { - margin-top: 0; -} - -/* Загрузка/пусто */ -.loading-lessons, -.no-lessons { - text-align: center; - color: var(--text-secondary); - padding: 3rem; - font-size: 1rem; - background: var(--bg-card); - border-radius: var(--radius-sm); -} - -/* Светлая тема */ -[data-theme="light"] .lesson-card { - background: #fff; - border-color: rgba(0, 0, 0, 0.1); -} - -[data-theme="light"] .lesson-group { - background: rgba(99, 102, 241, 0.05); -} - -/* ===== Адаптивность ===== */ -@media (max-width: 1200px) { - #modal-view-lessons .view-lessons-modal { - width: 40vw !important; - max-width: 40vw !important; - } -} - -@media (max-width: 768px) { - /* На мобилке делаем поведение более "обычным" */ - #modal-view-lessons.modal-overlay.open { - padding-top: 1rem; - justify-content: center; - align-items: flex-start; - } - - #modal-view-lessons .view-lessons-modal { - width: 90vw !important; - max-width: 90vw !important; - min-width: 0; - - /* чтобы занимало почти весь экран */ - height: calc(100vh - 2rem); - max-height: calc(100vh - 2rem); - } - - .lesson-card-header { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } -} - -/* ===== Скролл во 2-й модалке ===== */ -#modal-view-lessons .lessons-container { - scrollbar-width: thin; /* Firefox */ - scrollbar-color: rgba(99, 102, 241, 0.55) rgba(255, 255, 255, 0.06); /* thumb track */ -} - -/* WebKit (Chrome/Edge/Safari) */ -#modal-view-lessons .lessons-container::-webkit-scrollbar { - width: 10px; -} - -#modal-view-lessons .lessons-container::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.06); - border-radius: 10px; -} - -#modal-view-lessons .lessons-container::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.55); /* под accent */ - border-radius: 10px; - border: 2px solid rgba(0, 0, 0, 0); /* чтобы выглядел “тоньше” */ - background-clip: padding-box; -} - -#modal-view-lessons .lessons-container::-webkit-scrollbar-thumb:hover { - background: rgba(99, 102, 241, 0.75); -} - -/* Общий блюр/затемнение за модалками */ -#modal-backdrop{ - position: fixed; - inset: 0; - background: rgba(0,0,0,0.55); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - - opacity: 0; - pointer-events: none; - transition: opacity var(--transition); - z-index: 998; /* ниже модалок: 999 и 1000 */ -} - -#modal-backdrop.open{ - opacity: 1; - pointer-events: auto; -} - diff --git a/frontend/admin/index.html b/frontend/admin/index.html index d499948..3881f85 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -14,7 +14,6 @@ - @@ -54,17 +53,6 @@ Пользователи - - - - - - - - - Кафедра - @@ -204,4 +192,4 @@ - \ No newline at end of file + diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 7fa36c4..596d309 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -26,7 +26,6 @@ import { initClassrooms } from './views/classrooms.js'; import { initSubjects } from './views/subjects.js'; import {initSchedule} from "./views/schedule.js"; import {initDatabase} from "./views/database.js"; -import {initDepartment} from "./views/department.js"; import {initDepartmentsData} from "./views/departments-data.js"; import {initAuditoriumWorkload} from "./views/auditorium-workload.js"; @@ -41,7 +40,6 @@ const ROUTES = { schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule }, 'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload }, database: { title: 'База данных', file: 'views/database.html', init: initDatabase }, - department: { title: 'Кафедры', file: 'views/department.html', init: initDepartment }, 'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData }, }; diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js index f7c7629..bd3dc44 100644 --- a/frontend/admin/js/views/auditorium-workload.js +++ b/frontend/admin/js/views/auditorium-workload.js @@ -90,7 +90,7 @@ function renderMockGrid() { ]; // Mock schedule data mapped by room and time - // Key: "roomId_timeSlotId", Value: Lesson object + // Ключ: "roomId_timeSlotId", значение: объект занятия const mockSchedule = { "201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." }, "201_9:40-11:10": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." }, diff --git a/frontend/admin/js/views/department.js b/frontend/admin/js/views/department.js deleted file mode 100644 index f1011c7..0000000 --- a/frontend/admin/js/views/department.js +++ /dev/null @@ -1,456 +0,0 @@ -import { api } from '../api.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; - -// Ключ для хранения данных в sessionStorage -const STORAGE_KEY = 'department_schedule_blocks'; - -export async function initDepartment() { - const form = document.getElementById('department-schedule-form'); - const departmentSelect = document.getElementById('filter-department'); - const container = document.getElementById('schedule-blocks-container'); - - let departments = []; - - // Загрузка кафедр - try { - departments = await api.get('/api/departments'); - departmentSelect.innerHTML = '' + - departments.map(d => ``).join(''); - } catch (e) { - departmentSelect.innerHTML = ''; - } - - // ===== Восстанавливаем ранее загруженные таблицы из sessionStorage ===== - restoreScheduleBlocks(); - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('schedule-form-alert'); - - const departmentId = departmentSelect.value; - const period = document.getElementById('filter-period').value; - const semesterType = document.querySelector('input[name="semesterType"]:checked')?.value; - - if (!departmentId || !period || !semesterType) { - showAlert('schedule-form-alert', 'Заполните все поля', 'error'); - return; - } - - const deptName = departmentSelect.options[departmentSelect.selectedIndex].text; - - try { - const params = new URLSearchParams({ departmentId, semesterType, period }); - const data = await api.get(`/api/department/schedule?${params.toString()}`); - - const semesterName = semesterType === 'spring' ? 'весенний' : (semesterType === 'autumn' ? 'осенний' : semesterType); - const periodName = period.replace('-', '/'); - - renderScheduleBlock(deptName, semesterName, periodName, data, departmentId, semesterType, period); - - // НЕ сбрасываем форму — фильтры остаются заполненными (fix #3) - - } catch (err) { - showAlert('schedule-form-alert', err.message || 'Ошибка загрузки данных', 'error'); - } - }); - - // ===== Уникальный ключ для таблицы по параметрам ===== - function blockKey(departmentId, semesterType, period) { - return `${departmentId}_${semesterType}_${period}`; - } - - // ===== Рендер блока таблицы (с дедупликацией — fix #6) ===== - function renderScheduleBlock(deptName, semester, period, schedule, departmentId, semesterType, rawPeriod) { - const key = blockKey(departmentId, semesterType, rawPeriod); - - // Удаляем ранее загруженный блок с тем же ключом - const existing = container.querySelector(`[data-block-key="${key}"]`); - if (existing) existing.remove(); - - const details = document.createElement('details'); - details.className = 'table-item'; - details.open = true; - details.setAttribute('data-block-key', key); - details.innerHTML = ` - - -
- Данные к составлению расписания - Кафедра: ${escapeHtml(deptName)} - Семестр: ${escapeHtml(semester)} - Уч. год: ${escapeHtml(period)} -
-
${Array.isArray(schedule) ? schedule.length : 0} записей
- -
- - - - - - - - - - - - - - - ${renderRows(schedule)} - -
СпециальностьКурс/семестрГруппаДисциплинаВид занятийЧасов в неделюДеление на подгруппыПреподаватель
-
- `; - - container.prepend(details); - - // Сохраняем в sessionStorage - saveScheduleBlock(key, { deptName, semester, period, schedule, departmentId, semesterType, rawPeriod }); - } - - function renderRows(schedule) { - if (!Array.isArray(schedule) || schedule.length === 0) { - return 'Нет данных'; - } - return schedule.map(r => ` - - ${escapeHtml(r.specialityCode || '-')} - ${(() => { - const course = r.groupCourse || '-'; - const semester = r.semester || '-'; - if (course === '-' && semester === '-') return '-'; - return `${course} | ${semester}`; - })()} - ${escapeHtml(r.groupName || '-')} - ${escapeHtml(r.subjectName || '-')} - ${escapeHtml(r.lessonType || '-')} - ${escapeHtml(r.numberOfHours || '-')} - ${r.division === true ? '✓' : ''} - ${(() => { - const jobTitle = r.teacherJobTitle || '-'; - const teacherName = r.teacherName || '-'; - if (jobTitle === '-' && teacherName === '-') return '-'; - return `${jobTitle}, ${teacherName}`; - })()} - - `).join(''); - } - - // ===== Persistence: sessionStorage (fix #4) ===== - function saveScheduleBlock(key, blockData) { - try { - const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}'); - stored[key] = blockData; - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); - } catch (e) { - console.warn('Ошибка сохранения в sessionStorage:', e); - } - } - - function restoreScheduleBlocks() { - try { - const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}'); - const keys = Object.keys(stored); - if (keys.length === 0) return; - - keys.forEach(key => { - const b = stored[key]; - renderScheduleBlock(b.deptName, b.semester, b.period, b.schedule, b.departmentId, b.semesterType, b.rawPeriod); - }); - } catch (e) { - console.warn('Ошибка восстановления из sessionStorage:', e); - } - } - - // ========================================================= - // ЛОГИКА ДЛЯ ФУНКЦИОНАЛА "СОЗДАТЬ ЗАПИСЬ (К/Ф)" - // Два модальных окна поверх всего контента в одном оверлее - // ========================================================= - const btnCreateSchedule = document.getElementById('btn-create-schedule'); - const csOverlay = document.getElementById('cs-overlay'); - - const modalCreateSchedule = document.getElementById('modal-create-schedule'); - const modalCreateScheduleClose = document.getElementById('modal-create-schedule-close'); - const formCreateSchedule = document.getElementById('create-schedule-form'); - - const modalViewSchedules = document.getElementById('modal-view-schedules'); - const btnSaveSchedules = document.getElementById('btn-save-schedules'); - const preparedSchedulesTbody = document.getElementById('prepared-schedules-tbody'); - - const csGroupSelect = document.getElementById('cs-group'); - const csSubjectSelect = document.getElementById('cs-subject'); - const csTeacherSelect = document.getElementById('cs-teacher'); - const csDepartmentIdInput = document.getElementById('cs-department-id'); - - let preparedSchedules = []; - let csGroups = []; - let csSubjects = []; - let csTeachers = []; - - const SEMESTER_LABELS = { autumn: 'Осенний', spring: 'Весенний' }; - const LESSON_TYPE_LABELS = { 1: 'Лекция', 2: 'Практическая работа', 3: 'Лабораторная работа' }; - - const localDepartmentId = localStorage.getItem('departmentId'); - - // ===== Загрузка справочников ===== - async function loadDictionariesForSchedule() { - try { - csGroups = await api.get('/api/groups'); - csGroupSelect.innerHTML = '' + - csGroups.map(g => ``).join(''); - - csSubjects = await api.get('/api/subjects'); - csSubjectSelect.innerHTML = '' + - csSubjects.map(s => ``).join(''); - - // Загрузка преподавателей: сначала по кафедре, при ошибке — все преподаватели - csTeachers = []; - if (localDepartmentId) { - try { - csTeachers = await api.get(`/api/users/teachers/${localDepartmentId}`); - } catch (e) { - console.warn('Не удалось загрузить преподавателей для кафедры, загружаем всех:', e); - } - } - // Фолбэк: загружаем всех преподавателей - if (!Array.isArray(csTeachers) || csTeachers.length === 0) { - try { - csTeachers = await api.get('/api/users/teachers'); - } catch (e2) { - console.error('Ошибка загрузки всех преподавателей:', e2); - } - } - if (Array.isArray(csTeachers) && csTeachers.length > 0) { - csTeacherSelect.innerHTML = '' + - csTeachers.map(t => ``).join(''); - } else { - csTeacherSelect.innerHTML = ''; - } - } catch (e) { - console.error('Ошибка загрузки справочников:', e); - } - } - - loadDictionariesForSchedule(); - - // ===== Открытие / Закрытие оверлея ===== - function openOverlay() { - csOverlay.classList.add('open'); - document.body.style.overflow = 'hidden'; - } - - function closeOverlay() { - csOverlay.classList.remove('open'); - document.body.style.overflow = ''; - hideAlert('create-schedule-alert'); - hideAlert('save-schedules-alert'); - } - - function updateTableVisibility() { - modalViewSchedules.style.display = preparedSchedules.length > 0 ? '' : 'none'; - } - - // ===== Кнопка «Создать запись» ===== - btnCreateSchedule.addEventListener('click', () => { - if (localDepartmentId) { - csDepartmentIdInput.value = localDepartmentId; - } else { - showAlert('schedule-form-alert', 'Требуется перезайти (отсутствует ID кафедры)', 'error'); - return; - } - openOverlay(); - }); - - // ===== Закрытие ===== - modalCreateScheduleClose.addEventListener('click', closeOverlay); - - csOverlay.addEventListener('click', (e) => { - if (e.target === csOverlay || e.target.classList.contains('cs-overlay-scroll')) { - closeOverlay(); - } - }); - - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && csOverlay.classList.contains('open')) { - closeOverlay(); - } - }); - - // ===== Рендер таблицы подготовленных записей ===== - function renderPreparedSchedules() { - if (preparedSchedules.length === 0) { - preparedSchedulesTbody.innerHTML = 'Нет записей'; - return; - } - preparedSchedulesTbody.innerHTML = preparedSchedules.map((s, index) => { - const groupName = csGroups.find(g => g.id == s.groupId)?.name || s.groupId; - const subjectName = csSubjects.find(sub => sub.id == s.subjectsId)?.name || s.subjectsId; - const teacherName = csTeachers.find(t => t.id == s.teacherId)?.fullName - || csTeachers.find(t => t.id == s.teacherId)?.username || s.teacherId; - const lessonTypeName = LESSON_TYPE_LABELS[s.lessonTypeId] || 'Неизвестно'; - const semLabel = SEMESTER_LABELS[s.semesterType] || s.semesterType; - const periodDisplay = s.period.replace('-', '/'); - const divText = s.isDivision ? '✓' : ''; - const hasError = !!s._errorMsg; - const rowStyle = hasError ? ' style="background: rgba(239, 68, 68, 0.08);"' : ''; - let row = ` - - ${escapeHtml(periodDisplay)} - ${escapeHtml(semLabel)} - ${escapeHtml(String(groupName))} - ${escapeHtml(String(subjectName))} - ${escapeHtml(lessonTypeName)} - ${s.numberOfHours} - ${divText} - ${escapeHtml(String(teacherName))} - - `; - if (hasError) { - row += ` - - ⚠ ${escapeHtml(s._errorMsg)} - - `; - } - return row; - }).join(''); - } - - // ===== Удаление строки из таблицы ===== - preparedSchedulesTbody.addEventListener('click', (e) => { - if (e.target.classList.contains('btn-delete')) { - const idx = parseInt(e.target.getAttribute('data-index'), 10); - preparedSchedules.splice(idx, 1); - renderPreparedSchedules(); - updateTableVisibility(); - } - }); - - // ===== Очистка полей формы (частичная) ===== - function clearFormFields() { - document.getElementById('cs-hours').value = ''; - document.getElementById('cs-division').checked = false; - } - - // ===== Добавление записи в список ===== - formCreateSchedule.addEventListener('submit', (e) => { - e.preventDefault(); - hideAlert('create-schedule-alert'); - - const depId = csDepartmentIdInput.value; - const period = document.getElementById('cs-period').value; - const semesterType = document.querySelector('input[name="csSemesterType"]:checked')?.value; - const groupId = csGroupSelect.value; - const subjectId = csSubjectSelect.value; - const lessonTypeId = document.getElementById('cs-lesson-type').value; - const hours = document.getElementById('cs-hours').value; - const isDivision = document.getElementById('cs-division').checked; - const teacherId = csTeacherSelect.value; - - if (!period || !semesterType || !groupId || !subjectId || !lessonTypeId || !hours || !teacherId) { - showAlert('create-schedule-alert', 'Заполните все обязательные поля', 'error'); - return; - } - - const newRecord = { - departmentId: Number(depId), - groupId: Number(groupId), - subjectsId: Number(subjectId), - lessonTypeId: Number(lessonTypeId), - numberOfHours: Number(hours), - isDivision: isDivision, - teacherId: Number(teacherId), - semesterType: semesterType, - period: period - }; - - // Проверка на дубликат - const isDuplicate = preparedSchedules.some(s => - s.period === newRecord.period && - s.semesterType === newRecord.semesterType && - s.groupId === newRecord.groupId && - s.subjectsId === newRecord.subjectsId && - s.lessonTypeId === newRecord.lessonTypeId && - s.numberOfHours === newRecord.numberOfHours && - s.isDivision === newRecord.isDivision && - s.teacherId === newRecord.teacherId - ); - - if (isDuplicate) { - showAlert('create-schedule-alert', 'Такая запись уже есть в списке', 'error'); - return; - } - - preparedSchedules.push(newRecord); - - clearFormFields(); - - showAlert('create-schedule-alert', 'Запись добавлена ✓', 'success'); - setTimeout(() => hideAlert('create-schedule-alert'), 4000); // fix #1: 4 секунды - - renderPreparedSchedules(); - updateTableVisibility(); - }); - - // ===== Сохранение в БД ===== - btnSaveSchedules.addEventListener('click', async () => { - if (preparedSchedules.length === 0) { - showAlert('save-schedules-alert', 'Нет записей для сохранения', 'error'); - return; - } - - btnSaveSchedules.disabled = true; - btnSaveSchedules.textContent = 'Сохранение...'; - hideAlert('save-schedules-alert'); - - let errors = 0; - let saved = 0; - const failedRecords = []; - - for (const record of preparedSchedules) { - try { - await api.post('/api/department/schedule/create', record); - saved++; - } catch (err) { - console.error('Ошибка сохранения записи:', err); - errors++; - const isDuplicate = err.status === 409 || - (err.message && err.message.toLowerCase().includes('уже существует')); - failedRecords.push({ - ...record, - _errorMsg: isDuplicate - ? 'Такая запись уже есть в базе данных' - : (err.message || 'Ошибка сохранения') - }); - } - } - - btnSaveSchedules.disabled = false; - btnSaveSchedules.textContent = 'Сохранить в БД'; - - if (errors === 0) { - showAlert('save-schedules-alert', `Все записи (${saved}) успешно сохранены!`, 'success'); - preparedSchedules = []; - renderPreparedSchedules(); - updateTableVisibility(); - setTimeout(closeOverlay, 2000); - } else { - preparedSchedules = failedRecords; - renderPreparedSchedules(); - if (saved > 0) { - showAlert('save-schedules-alert', - `Сохранено: ${saved}. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error'); - } else { - showAlert('save-schedules-alert', - `Не удалось сохранить. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error'); - } - } - }); - -} \ No newline at end of file diff --git a/frontend/admin/js/views/departments-data.js b/frontend/admin/js/views/departments-data.js index 489a188..2e2e64f 100644 --- a/frontend/admin/js/views/departments-data.js +++ b/frontend/admin/js/views/departments-data.js @@ -7,6 +7,12 @@ export async function initDepartmentsData() { const createDeptForm = document.getElementById('create-department-form'); const createSpecForm = document.getElementById('create-specialty-form'); + const editDeptForm = document.getElementById('edit-department-form'); + const editSpecForm = document.getElementById('edit-specialty-form'); + const editDeptModal = document.getElementById('modal-edit-department'); + const editSpecModal = document.getElementById('modal-edit-specialty'); + const editDeptClose = document.getElementById('modal-edit-department-close'); + const editSpecClose = document.getElementById('modal-edit-specialty-close'); let departments = []; let specialties = []; @@ -17,7 +23,7 @@ export async function initDepartmentsData() { departments = await api.get('/api/departments'); renderDepartments(); } catch (e) { - deptTbody.innerHTML = '-'; + deptTbody.innerHTML = '-'; } // Load Specialties @@ -25,13 +31,13 @@ export async function initDepartmentsData() { specialties = await api.get('/api/specialties'); renderSpecialties(); } catch (e) { - specTbody.innerHTML = '-'; + specTbody.innerHTML = '-'; } } function renderDepartments() { if (!departments || !departments.length) { - deptTbody.innerHTML = '-'; + deptTbody.innerHTML = '-'; return; } deptTbody.innerHTML = departments.map(d => ` @@ -39,13 +45,17 @@ export async function initDepartmentsData() { ${d.id} ${escapeHtml(d.departmentName || d.name)} ${escapeHtml(String(d.departmentCode || d.code))} + + + + `).join(''); } function renderSpecialties() { if (!specialties || !specialties.length) { - specTbody.innerHTML = '-'; + specTbody.innerHTML = '-'; return; } specTbody.innerHTML = specialties.map(s => ` @@ -53,6 +63,10 @@ export async function initDepartmentsData() { ${s.id} ${escapeHtml(s.specialityName || s.name)} ${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)} + + + + `).join(''); } @@ -78,6 +92,34 @@ export async function initDepartmentsData() { } }); + deptTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-department'); + const deleteBtn = e.target.closest('.btn-delete-department'); + + if (editBtn) { + const department = departments.find(item => item.id == editBtn.dataset.id); + if (!department) return; + + document.getElementById('edit-dept-id').value = department.id; + document.getElementById('edit-dept-name').value = department.departmentName || department.name || ''; + document.getElementById('edit-dept-code').value = department.departmentCode || department.code || ''; + hideAlert('edit-dept-alert'); + editDeptModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить кафедру?')) return; + try { + await api.delete('/api/departments/' + deleteBtn.dataset.id); + showAlert('create-dept-alert', 'Кафедра удалена', 'success'); + await loadData(); + } catch (error) { + showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error'); + } + } + }); + createSpecForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-spec-alert'); @@ -99,5 +141,86 @@ export async function initDepartmentsData() { } }); - loadData(); + specTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-specialty'); + const deleteBtn = e.target.closest('.btn-delete-specialty'); + + if (editBtn) { + const speciality = specialties.find(item => item.id == editBtn.dataset.id); + if (!speciality) return; + + document.getElementById('edit-spec-id').value = speciality.id; + document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || ''; + document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || ''; + hideAlert('edit-spec-alert'); + editSpecModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить специальность?')) return; + try { + await api.delete('/api/specialties/' + deleteBtn.dataset.id); + showAlert('create-spec-alert', 'Специальность удалена', 'success'); + await loadData(); + } catch (error) { + showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error'); + } + } + }); + + editDeptForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-dept-alert'); + const id = document.getElementById('edit-dept-id').value; + const name = document.getElementById('edit-dept-name').value.trim(); + const code = document.getElementById('edit-dept-code').value.trim(); + + if (!name || !code) { + showAlert('edit-dept-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) }); + editDeptModal.classList.remove('open'); + showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success'); + await loadData(); + } catch (error) { + showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error'); + } + }); + + editSpecForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-spec-alert'); + const id = document.getElementById('edit-spec-id').value; + const name = document.getElementById('edit-spec-name').value.trim(); + const code = document.getElementById('edit-spec-code').value.trim(); + + if (!name || !code) { + showAlert('edit-spec-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code }); + editSpecModal.classList.remove('open'); + showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success'); + await loadData(); + } catch (error) { + showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error'); + } + }); + + editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open')); + editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open')); + editDeptModal.addEventListener('click', (e) => { + if (e.target === editDeptModal) editDeptModal.classList.remove('open'); + }); + editSpecModal.addEventListener('click', (e) => { + if (e.target === editSpecModal) editSpecModal.classList.remove('open'); + }); + + await loadData(); } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index ec57a24..a8a65d8 100755 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -6,15 +6,25 @@ export async function initGroups() { const groupsTbody = document.getElementById('groups-tbody'); const createGroupForm = document.getElementById('create-group-form'); const newGroupEfSelect = document.getElementById('new-group-ef'); + const newGroupDepartmentSelect = document.getElementById('new-group-department'); + const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); const filterEfSelect = document.getElementById('filter-ef'); let allGroups = []; let educationForms = []; + let departments = []; + let specialties = []; async function loadInitialData() { try { - educationForms = await fetchEducationForms(); + [educationForms, departments, specialties] = await Promise.all([ + fetchEducationForms(), + api.get('/api/departments'), + api.get('/api/specialties') + ]); populateEfSelects(educationForms); + populateDepartmentSelect(departments); + populateSpecialitySelect(specialties); await loadGroups(); } catch (e) { groupsTbody.innerHTML = 'Ошибка загрузки данных'; @@ -49,6 +59,7 @@ export async function initGroups() { if (currentVal && forms.find(f => f.id == currentVal)) { newGroupEfSelect.value = currentVal; } + syncSelects(newGroupEfSelect); // Filter select const currentFilter = filterEfSelect.value; @@ -57,6 +68,59 @@ export async function initGroups() { `` ).join(''); if (currentFilter) filterEfSelect.value = currentFilter; + syncSelects(filterEfSelect); + } + + function populateDepartmentSelect(items) { + const currentVal = newGroupDepartmentSelect.value; + if (!items || !items.length) { + newGroupDepartmentSelect.innerHTML = ''; + return; + } + + newGroupDepartmentSelect.innerHTML = '' + + items.map(department => { + const name = department.departmentName || department.name || 'Без названия'; + const code = department.departmentCode || department.code || department.id; + return ``; + }).join(''); + if (currentVal && items.find(department => department.id == currentVal)) { + newGroupDepartmentSelect.value = currentVal; + } + syncSelects(newGroupDepartmentSelect); + } + + function populateSpecialitySelect(items) { + const currentVal = newGroupSpecialitySelect.value; + if (!items || !items.length) { + newGroupSpecialitySelect.innerHTML = ''; + return; + } + + newGroupSpecialitySelect.innerHTML = '' + + items.map(speciality => { + const name = speciality.specialityName || speciality.name || 'Без названия'; + const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; + return ``; + }).join(''); + if (currentVal && items.find(speciality => speciality.id == currentVal)) { + newGroupSpecialitySelect.value = currentVal; + } + syncSelects(newGroupSpecialitySelect); + } + + function departmentLabel(departmentId) { + const department = departments.find(item => item.id == departmentId); + if (!department) return departmentId || '-'; + return department.departmentName || department.name || departmentId; + } + + function specialityLabel(specialityId) { + const speciality = specialties.find(item => item.id == specialityId); + if (!speciality) return specialityId || '-'; + const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; + const name = speciality.specialityName || speciality.name || ''; + return name ? `${code} — ${name}` : code; } function renderGroups(groups) { @@ -70,9 +134,9 @@ export async function initGroups() { ${escapeHtml(g.name)} ${escapeHtml(g.groupSize)} ${escapeHtml(g.educationFormName)} - ${g.departmentId || '-'} + ${escapeHtml(departmentLabel(g.departmentId))} ${g.course || '-'} - ${escapeHtml(g.specialityCode || '-')} + ${escapeHtml(specialityLabel(g.specialityCode))} `).join(''); } @@ -83,16 +147,16 @@ export async function initGroups() { const name = document.getElementById('new-group-name').value.trim(); const groupSize = document.getElementById('new-group-size').value; const educationFormId = newGroupEfSelect.value; - const departmentId = document.getElementById('new-group-department').value; - const course = document.getElementById('new-group-course').value; - const specialityCode = document.getElementById('new-group-speciality-code').value.trim(); + const departmentId = newGroupDepartmentSelect.value; + const yearStartStudy = document.getElementById('new-group-yearStartStudy').value; + const specialityCode = newGroupSpecialitySelect.value; if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; } if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; } if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; } - if (!departmentId) { showAlert('create-group-alert', 'Введите ID кафедры', 'error'); return; } - if (!course) { showAlert('create-group-alert', 'Введите курс', 'error'); return; } - if (!specialityCode) { showAlert('create-group-alert', 'Введите код специальности', 'error'); return; } + if (!departmentId) { showAlert('create-group-alert', 'Выберите кафедру', 'error'); return; } + if (!yearStartStudy) { showAlert('create-group-alert', 'Введите год начала обучения', 'error'); return; } + if (!specialityCode) { showAlert('create-group-alert', 'Выберите специальность', 'error'); return; } try { const data = await api.post('/api/groups', { @@ -100,11 +164,12 @@ export async function initGroups() { groupSize: Number(groupSize), educationFormId: Number(educationFormId), departmentId: Number(departmentId), - course: Number(course), - specialityCode: specialityCode + yearStartStudy: Number(yearStartStudy), + specialityCode: Number(specialityCode) }); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); createGroupForm.reset(); + syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect); loadGroups(); } catch (e) { showAlert('create-group-alert', e.message || 'Ошибка создания', 'error'); @@ -123,5 +188,11 @@ export async function initGroups() { } }); - loadInitialData(); + await loadInitialData(); + + function syncSelects(...selects) { + selects.filter(Boolean).forEach(select => { + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js old mode 100755 new mode 100644 index 57ca394..3c867ab --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -1,684 +1,904 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; +const DAY_OPTIONS = [ + { value: 1, label: 'Понедельник' }, + { value: 2, label: 'Вторник' }, + { value: 3, label: 'Среда' }, + { value: 4, label: 'Четверг' }, + { value: 5, label: 'Пятница' }, + { value: 6, label: 'Суббота' }, + { value: 7, label: 'Воскресенье' } +]; + +const PARITY_LABELS = { + BOTH: 'каждую неделю', + ODD: 'нечётная', + EVEN: 'чётная' +}; + +const SEMESTER_LABELS = { + autumn: 'осенний', + spring: 'весенний' +}; + +const ACTIVITY_LABELS = { + THEORY: 'Теория', + EXAM: 'Экзамены', + VACATION: 'Каникулы', + PRACTICE: 'Практика' +}; + export async function initSchedule() { - const tbody = document.getElementById('schedule-tbody'); - const table = document.getElementById('schedule-table'); + const ruleForm = document.getElementById('schedule-rule-form'); + const ruleFormTitle = document.getElementById('schedule-rule-form-title'); + const ruleIdInput = document.getElementById('schedule-rule-id'); + const ruleSubjectSelect = document.getElementById('schedule-rule-subject'); + const ruleSemesterSelect = document.getElementById('schedule-rule-semester'); + const ruleActiveFromInput = document.getElementById('schedule-rule-active-from'); + const ruleHoursInput = document.getElementById('schedule-rule-hours'); + const ruleGroupsContainer = document.getElementById('schedule-rule-groups'); + const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); + const ruleResetButton = document.getElementById('schedule-rule-reset'); + const slotAddButton = document.getElementById('schedule-slot-add'); + const rulesTbody = document.getElementById('schedule-tbody'); + const refreshRulesButton = document.getElementById('schedule-refresh'); - let lessonsData = []; - let sortKey = null; - let sortDir = 'asc'; + const timeSlotForm = document.getElementById('time-slot-form'); + const timeSlotFormTitle = document.getElementById('time-slot-form-title'); + const timeSlotIdInput = document.getElementById('time-slot-id'); + const timeSlotOrderInput = document.getElementById('time-slot-order'); + const timeSlotStartInput = document.getElementById('time-slot-start'); + const timeSlotEndInput = document.getElementById('time-slot-end'); + const timeSlotDurationInput = document.getElementById('time-slot-duration'); + const timeSlotResetButton = document.getElementById('time-slot-reset'); + const timeSlotsTbody = document.getElementById('time-slots-tbody'); + const refreshSlotsButton = document.getElementById('time-slots-refresh'); - // Активные фильтры: { teacher: Set, group: Set, subject: Set, day: Set } - const activeFilters = {}; + const academicYearForm = document.getElementById('academic-year-form'); + const academicYearFormTitle = document.getElementById('academic-year-form-title'); + const academicYearIdInput = document.getElementById('academic-year-id'); + const academicYearTitleInput = document.getElementById('academic-year-title'); + const academicYearStartInput = document.getElementById('academic-year-start'); + const academicYearEndInput = document.getElementById('academic-year-end'); + const academicYearResetButton = document.getElementById('academic-year-reset'); + const academicYearsTbody = document.getElementById('academic-years-tbody'); + const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); - // Маппинг дней недели для корректной сортировки - const dayOrder = { - 'понедельник': 1, 'вторник': 2, 'среда': 3, - 'четверг': 4, 'пятница': 5, 'суббота': 6, 'воскресенье': 7 - }; + const semesterForm = document.getElementById('semester-form'); + const semesterFormTitle = document.getElementById('semester-form-title'); + const semesterIdInput = document.getElementById('semester-id'); + const semesterYearSelect = document.getElementById('semester-year'); + const semesterTypeSelect = document.getElementById('semester-type'); + const semesterStartInput = document.getElementById('semester-start'); + const semesterEndInput = document.getElementById('semester-end'); + const semesterResetButton = document.getElementById('semester-reset'); - // ===================== Фильтрация ===================== + const holidayForm = document.getElementById('holiday-form'); + const holidayIdInput = document.getElementById('holiday-id'); + const holidayYearSelect = document.getElementById('holiday-year'); + const holidayDateInput = document.getElementById('holiday-date'); + const holidayDescriptionInput = document.getElementById('holiday-description'); + const holidayResetButton = document.getElementById('holiday-reset'); + const holidaysTbody = document.getElementById('holidays-tbody'); - function getDisplayValue(lesson, key) { - switch (key) { - case 'teacher': - return lesson.teacher?.username || lesson.teacherName || '—'; - case 'group': - return lesson.group?.name || lesson.groupName || '—'; - case 'subject': - return lesson.subject?.name || lesson.subjectName || '—'; - case 'day': - return lesson.day || '—'; - case 'educationForm': - return lesson.educationForm?.name || lesson.educationFormName || '—'; - default: - return ''; - } + const matrixSemesterSelect = document.getElementById('matrix-semester'); + const matrixCourseInput = document.getElementById('matrix-course'); + const matrixSpecialtySelect = document.getElementById('matrix-specialty'); + const matrixLoadButton = document.getElementById('matrix-load'); + const matrixSaveButton = document.getElementById('matrix-save'); + const matrixGrid = document.getElementById('matrix-grid'); + + let rules = []; + let subjects = []; + let groups = []; + let teachers = []; + let classrooms = []; + let lessonTypes = []; + let timeSlots = []; + let academicYears = []; + let semesters = []; + let specialties = []; + let holidays = []; + + bindEvents(); + + try { + await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]); + renderRuleGroups([]); + renderRuleSlots([{}]); + await Promise.all([loadRules(), loadHolidays()]); + } catch (error) { + showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); } - function getUniqueValues(key) { - const vals = new Set(); - lessonsData.forEach(lesson => { - vals.add(getDisplayValue(lesson, key)); + function bindEvents() { + refreshRulesButton?.addEventListener('click', loadRules); + refreshSlotsButton?.addEventListener('click', loadTimeSlots); + academicYearsRefreshButton?.addEventListener('click', loadYears); + ruleResetButton?.addEventListener('click', resetRuleForm); + slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); + timeSlotResetButton?.addEventListener('click', resetTimeSlotForm); + academicYearResetButton?.addEventListener('click', resetAcademicYearForm); + semesterResetButton?.addEventListener('click', resetSemesterForm); + holidayResetButton?.addEventListener('click', resetHolidayForm); + holidayYearSelect?.addEventListener('change', loadHolidays); + matrixLoadButton?.addEventListener('click', loadMatrix); + matrixSaveButton?.addEventListener('click', saveMatrix); + + ruleSlotsContainer.addEventListener('click', (event) => { + const removeButton = event.target.closest('.schedule-slot-remove'); + if (!removeButton) return; + const slots = readRuleSlots(false); + slots.splice(Number(removeButton.dataset.index), 1); + renderRuleSlots(slots.length ? slots : [{}]); }); - if (key === 'day') { - return [...vals].sort((a, b) => (dayOrder[a.toLowerCase()] ?? 99) - (dayOrder[b.toLowerCase()] ?? 99)); - } - return [...vals].sort((a, b) => a.localeCompare(b, 'ru')); + + ruleForm.addEventListener('submit', saveRule); + timeSlotForm.addEventListener('submit', saveTimeSlot); + academicYearForm.addEventListener('submit', saveAcademicYear); + semesterForm.addEventListener('submit', saveSemester); + holidayForm.addEventListener('submit', saveHoliday); + + rulesTbody.addEventListener('click', handleRuleTableClick); + timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick); + academicYearsTbody.addEventListener('click', handleAcademicYearTableClick); + holidaysTbody.addEventListener('click', handleHolidayTableClick); } - function applyFilters(lessons) { - return lessons.filter(lesson => { - for (const key of Object.keys(activeFilters)) { - const filterSet = activeFilters[key]; - if (filterSet && filterSet.size > 0) { - const val = getDisplayValue(lesson, key); - if (!filterSet.has(val)) return false; - } - } - return true; - }); + async function loadBaseLists() { + [subjects, groups, teachers, classrooms, lessonTypes, specialties] = await Promise.all([ + api.get('/api/subjects'), + api.get('/api/groups'), + api.get('/api/users/teachers'), + api.get('/api/classrooms'), + api.get('/api/lesson-types'), + api.get('/api/specialties') + ]); + + populateSubjectSelects(); + populateSpecialtySelects(); } - // ===================== Попап фильтра ===================== - - let currentPopup = null; - - function closePopup() { - if (currentPopup) { - currentPopup.remove(); - currentPopup = null; - } - document.removeEventListener('click', onDocumentClick, true); - } - - function onDocumentClick(e) { - if (currentPopup && !currentPopup.contains(e.target)) { - if (!e.target.closest('.filter-icon')) { - closePopup(); - } - } - } - - function openFilterPopup(th, filterKey) { - if (currentPopup && currentPopup.dataset.filterKey === filterKey) { - closePopup(); - return; - } - closePopup(); - - const uniqueValues = getUniqueValues(filterKey); - const currentFilter = activeFilters[filterKey]; - - const popup = document.createElement('div'); - popup.className = 'filter-popup'; - popup.dataset.filterKey = filterKey; - - const searchInput = document.createElement('input'); - searchInput.type = 'text'; - searchInput.className = 'filter-search'; - searchInput.placeholder = 'Поиск...'; - popup.appendChild(searchInput); - - const btnRow = document.createElement('div'); - btnRow.className = 'filter-btn-row'; - - const btnAll = document.createElement('button'); - btnAll.className = 'filter-btn-action'; - btnAll.textContent = 'Все'; - btnAll.addEventListener('click', (e) => { - e.stopPropagation(); - checkboxes.forEach(cb => { cb.checked = true; }); - }); - - const btnNone = document.createElement('button'); - btnNone.className = 'filter-btn-action'; - btnNone.textContent = 'Сбросить'; - btnNone.addEventListener('click', (e) => { - e.stopPropagation(); - checkboxes.forEach(cb => { cb.checked = false; }); - }); - - btnRow.appendChild(btnAll); - btnRow.appendChild(btnNone); - popup.appendChild(btnRow); - - const listWrap = document.createElement('div'); - listWrap.className = 'filter-list'; - - const checkboxes = []; - - uniqueValues.forEach(val => { - const label = document.createElement('label'); - label.className = 'filter-item'; - - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.value = val; - cb.checked = currentFilter ? currentFilter.has(val) : true; - - const span = document.createElement('span'); - span.textContent = val; - - label.appendChild(cb); - label.appendChild(span); - listWrap.appendChild(label); - checkboxes.push(cb); - }); - - popup.appendChild(listWrap); - - const btnApply = document.createElement('button'); - btnApply.className = 'filter-btn-apply'; - btnApply.textContent = 'Применить'; - btnApply.addEventListener('click', (e) => { - e.stopPropagation(); - const selected = new Set(); - checkboxes.forEach(cb => { - if (cb.checked) selected.add(cb.value); - }); - - if (selected.size === uniqueValues.length) { - delete activeFilters[filterKey]; - th.classList.remove('filter-active'); - } else { - activeFilters[filterKey] = selected; - th.classList.add('filter-active'); - } - - closePopup(); - renderSchedule(lessonsData); - }); - popup.appendChild(btnApply); - - searchInput.addEventListener('input', () => { - const query = searchInput.value.toLowerCase(); - listWrap.querySelectorAll('.filter-item').forEach(item => { - const text = item.querySelector('span').textContent.toLowerCase(); - item.style.display = text.includes(query) ? '' : 'none'; - }); - }); - - popup.addEventListener('click', (e) => e.stopPropagation()); - searchInput.addEventListener('click', (e) => e.stopPropagation()); - - th.style.position = 'relative'; - th.appendChild(popup); - currentPopup = popup; - - setTimeout(() => searchInput.focus(), 50); - - setTimeout(() => { - document.addEventListener('click', onDocumentClick, true); - }, 10); - } - - table.querySelectorAll('thead th.filterable').forEach(th => { - th.addEventListener('click', (e) => { - if (e.target.closest('.filter-popup')) return; - const filterKey = th.dataset.filterKey; - openFilterPopup(th, filterKey); - }); - }); - - // ===================== Сортировка ===================== - - function getSortValue(lesson, key) { - switch (key) { - case 'id': - return lesson.id ?? 0; - case 'teacher': - return (lesson.teacher?.username || lesson.teacherName || '').toLowerCase(); - case 'group': - return (lesson.group?.name || lesson.groupName || '').toLowerCase(); - case 'classroomName': - return (lesson.classroomName?.name || lesson.classroomName || '').toLowerCase(); - case 'educationForm': - return (lesson.educationForm?.name || lesson.educationFormName || '').toLowerCase(); - case 'subject': - return (lesson.subject?.name || lesson.subjectName || '').toLowerCase(); - case 'lessonFormat': - return (lesson.lessonFormat?.name || lesson.lessonFormat || '').toLowerCase(); - case 'typeLesson': - return (lesson.typeLesson?.name || lesson.typeLesson || '').toLowerCase(); - case 'day': { - const d = (lesson.day || '').toLowerCase(); - return dayOrder[d] ?? 99; - } - case 'week': - return (lesson.week || '').toLowerCase(); - case 'time': { - const d = (lesson.day || '').toLowerCase(); - const dayNum = dayOrder[d] ?? 99; - const t = lesson.time || '99:99'; - return String(dayNum).padStart(2, '0') + '_' + t; - } - default: - return ''; - } - } - - function sortLessons(lessons) { - if (!sortKey) return lessons; - - return [...lessons].sort((a, b) => { - let va = getSortValue(a, sortKey); - let vb = getSortValue(b, sortKey); - - if (typeof va === 'number' && typeof vb === 'number') { - return sortDir === 'asc' ? va - vb : vb - va; - } - - va = String(va); - vb = String(vb); - const cmp = va.localeCompare(vb, 'ru'); - return sortDir === 'asc' ? cmp : -cmp; - }); - } - - function updateSortHeaders() { - table.querySelectorAll('thead th.sortable').forEach(th => { - th.classList.remove('sort-asc', 'sort-desc', 'sort-active'); - if (th.dataset.sortKey === sortKey) { - th.classList.add('sort-active', sortDir === 'asc' ? 'sort-asc' : 'sort-desc'); - } - }); - } - - table.querySelectorAll('thead th.sortable').forEach(th => { - th.addEventListener('click', (e) => { - if (e.target.closest('.filter-icon') || e.target.closest('.filter-popup')) return; - - const key = th.dataset.sortKey; - if (sortKey === key) { - if (sortDir === 'asc') { - sortDir = 'desc'; - } else { - sortKey = null; - sortDir = 'asc'; - } - } else { - sortKey = key; - sortDir = 'asc'; - } - updateSortHeaders(); - renderSchedule(lessonsData); - }); - }); - - // ===================== Загрузка и рендер таблицы ===================== - - async function loadSchedule() { + async function loadRules() { + rulesTbody.innerHTML = 'Загрузка...'; try { - const lessons = await api.get('/api/users/lessons'); - lessonsData = lessons; - renderSchedule(lessons); - } catch (e) { - tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(e.message)}`; + rules = await api.get('/api/admin/schedule-rules'); + renderRules(); + } catch (error) { + rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } - function renderSchedule(lessons) { - if (!lessons || !lessons.length) { - tbody.innerHTML = 'Нет занятий'; + function renderRules() { + if (!rules || !rules.length) { + rulesTbody.innerHTML = 'Правила расписания не созданы'; return; } - const filtered = applyFilters(lessons); + rulesTbody.innerHTML = rules.map(rule => ` + + ${rule.id} + ${escapeHtml(rule.subjectName || '-')} + ${escapeHtml(formatSemester(rule))} + ${escapeHtml((rule.groupNames || []).join(', ') || '-')} + ${escapeHtml(rule.activeFromDate || '-')} + ${escapeHtml(String(rule.totalAcademicHours ?? '-'))} + ${renderRuleSlotsSummary(rule.slots)} + + + + + + `).join(''); + } - if (!filtered.length) { - tbody.innerHTML = 'Нет занятий по выбранным фильтрам'; - return; - } + function renderRuleSlotsSummary(slots) { + if (!slots || !slots.length) return 'Нет слотов'; - const sorted = sortLessons(filtered); - - tbody.innerHTML = sorted.map(lesson => { - const teacherName = lesson.teacher?.username || lesson.teacherName || '—'; - const groupName = lesson.group?.name || lesson.groupName || '—'; - const classroomName = lesson.classroom?.name || lesson.classroomName || '—'; - const educationForm = lesson.educationForm?.name || lesson.educationFormName || '-'; - const subjectName = lesson.subject?.name || lesson.subjectName || '—'; - const lessonFormat = lesson.lessonFormat?.name || lesson.lessonFormat || '—'; - const typeLesson = lesson.typeLesson?.name || lesson.typeLesson || '—'; - const day = lesson.day || '—'; - const week = lesson.week || '—'; - const time = lesson.time || '—'; - - return ` - ${escapeHtml(lesson.id)} - ${escapeHtml(teacherName)} - ${escapeHtml(groupName)} - ${escapeHtml(classroomName)} - ${escapeHtml(educationForm)} - ${escapeHtml(subjectName)} - ${escapeHtml(lessonFormat)} - ${escapeHtml(typeLesson)} - ${escapeHtml(day)} - ${escapeHtml(week)} - ${escapeHtml(time)} - `; + return slots.map(slot => { + const parity = PARITY_LABELS[slot.parity] || slot.parity || '-'; + const parts = [ + slot.dayName, + slot.timeSlotLabel, + parity, + slot.teacherName, + slot.classroomName, + slot.lessonTypeName, + slot.lessonFormat + ].filter(Boolean); + return `
${escapeHtml(parts.join(' · '))}
`; }).join(''); } - // ===================== Модалки добавления занятия ===================== + async function handleRuleTableClick(event) { + const editButton = event.target.closest('.btn-edit-rule'); + const deleteButton = event.target.closest('.btn-delete-rule'); - const overlay = document.getElementById('sch-overlay'); - const modalForm = document.getElementById('sch-modal-form'); - const modalLessons = document.getElementById('sch-modal-lessons'); - const btnAddLesson = document.getElementById('sch-btn-add-lesson'); - const btnClose = document.getElementById('sch-modal-close'); - const addForm = document.getElementById('sch-add-lesson-form'); - - const schTeacherSelect = document.getElementById('sch-teacher'); - const schGroupSelect = document.getElementById('sch-group'); - const schDisciplineSelect = document.getElementById('sch-discipline'); - const schClassroomSelect = document.getElementById('sch-classroom'); - const schDaySelect = document.getElementById('sch-day'); - const schTimeSelect = document.getElementById('sch-time'); - const schTypeSelect = document.getElementById('sch-type'); - const schWeekUpper = document.getElementById('sch-week-upper'); - const schWeekLower = document.getElementById('sch-week-lower'); - const schFormatOffline = document.getElementById('sch-format-offline'); - - const schTeacherName = document.getElementById('sch-teacher-name'); - const schLessonsContainer = document.getElementById('sch-lessons-container'); - - let groups = []; - let subjects = []; - let classrooms = []; - let teachers = []; - - const weekdaysTimes = [ - "8:00-9:30", "9:40-11:10", "11:40-13:10", - "13:20-14:50", "15:00-16:30", "16:50-18:20", "18:30-19:00" - ]; - const saturdayTimes = [ - "8:20-9:50", "10:00-11:30", "11:40-13:10", "13:20-14:50" - ]; - - // ===== Загрузка справочников ===== - async function loadGroups() { - try { - groups = await api.get('/api/groups'); - schGroupSelect.innerHTML = '' + - groups.map(g => { - let text = escapeHtml(g.name); - if (g.groupSize) text += ` (числ: ${g.groupSize} чел.)`; - return ``; - }).join(''); - } catch (e) { console.error('Ошибка загрузки групп:', e); } - } - - async function loadSubjects() { - try { - subjects = await api.get('/api/subjects'); - schDisciplineSelect.innerHTML = '' + - subjects.map(s => ``).join(''); - } catch (e) { console.error('Ошибка загрузки дисциплин:', e); } - } - - async function loadClassrooms() { - try { - classrooms = await api.get('/api/classrooms'); - renderClassroomOptions(); - } catch (e) { console.error('Ошибка загрузки аудиторий:', e); } - } - - async function loadTeachers() { - try { - teachers = await api.get('/api/users/teachers'); - schTeacherSelect.innerHTML = '' + - teachers.map(t => ``).join(''); - } catch (e) { console.error('Ошибка загрузки преподавателей:', e); } - } - - function renderClassroomOptions() { - if (!classrooms || classrooms.length === 0) { - schClassroomSelect.innerHTML = ''; + if (editButton) { + const rule = rules.find(item => item.id == editButton.dataset.id) + || await api.get('/api/admin/schedule-rules/' + editButton.dataset.id); + fillRuleForm(rule); return; } - const selectedGroupId = schGroupSelect.value; - const selectedGroup = groups?.find(g => g.id == selectedGroupId); - const groupSize = selectedGroup?.groupSize || 0; - schClassroomSelect.innerHTML = '' + - classrooms.map(c => { - let text = escapeHtml(c.name); - if (c.capacity) text += ` (вместимость: ${c.capacity} чел.)`; - if (c.isAvailable === false) { - text += ` ❌ Занята`; - } else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) { - text += ` ⚠️ Недостаточно места`; - } - return ``; - }).join(''); - } - - schGroupSelect.addEventListener('change', () => renderClassroomOptions()); - - function updateTimeOptions(dayValue) { - let times = []; - if (dayValue === "Суббота") { - times = saturdayTimes; - } else if (dayValue && dayValue !== '') { - times = weekdaysTimes; - } else { - schTimeSelect.innerHTML = ''; - schTimeSelect.disabled = true; - return; - } - schTimeSelect.innerHTML = '' + - times.map(t => ``).join(''); - schTimeSelect.disabled = false; - } - - schDaySelect.addEventListener('change', function () { - updateTimeOptions(this.value); - }); - - // ===== Автозаполнение преподавателя из фильтра ===== - function getFilteredTeacherId() { - const teacherFilter = activeFilters['teacher']; - if (teacherFilter && teacherFilter.size === 1) { - const teacherName = [...teacherFilter][0]; - // Сопоставляем по username, fullName и их комбинациям - const match = teachers.find(t => - t.username === teacherName || - t.fullName === teacherName || - (t.fullName || t.username) === teacherName - ); - return match ? String(match.id) : ''; - } - return ''; - } - - // ===== Загрузка занятий преподавателя ===== - async function loadTeacherLessons(teacherId) { - const teacher = teachers.find(t => t.id == teacherId); - const name = teacher ? (teacher.fullName || teacher.username) : ''; - schTeacherName.textContent = name - ? `Занятия преподавателя: ${name}` - : 'Занятия преподавателя'; - - modalLessons.style.display = ''; - schLessonsContainer.innerHTML = '
Загрузка занятий...
'; - - try { - const lessons = await api.get(`/api/users/lessons/${teacherId}`); - - if (!lessons || !Array.isArray(lessons) || lessons.length === 0) { - schLessonsContainer.innerHTML = '
У преподавателя пока нет занятий
'; - return; + if (deleteButton) { + if (!confirm('Удалить правило расписания?')) return; + try { + await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id); + showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); + await loadRules(); + } catch (error) { + showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error'); } - - const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']; - const lessonsByDay = {}; - lessons.forEach(l => { - if (!lessonsByDay[l.day]) lessonsByDay[l.day] = []; - lessonsByDay[l.day].push(l); - }); - Object.keys(lessonsByDay).forEach(day => { - lessonsByDay[day].sort((a, b) => a.time.localeCompare(b.time)); - }); - - let html = ''; - daysOrder.forEach(day => { - if (!lessonsByDay[day]) return; - html += `
${day}
`; - lessonsByDay[day].forEach(lesson => { - html += ` -
-
- ${escapeHtml(lesson.groupName)} - ${escapeHtml(lesson.time)} -
-
-
${escapeHtml(lesson.subjectName)}
-
- ${escapeHtml(lesson.typeLesson)} - ${escapeHtml(lesson.lessonFormat)} - ${escapeHtml(lesson.week)} - ${escapeHtml(lesson.classroomName)} -
-
-
`; - }); - }); - - schLessonsContainer.innerHTML = html; - } catch (e) { - schLessonsContainer.innerHTML = `
Ошибка загрузки: ${escapeHtml(e.message)}
`; } } - // ===== При смене преподавателя — подгрузить его занятия ===== - schTeacherSelect.addEventListener('change', function () { - const teacherId = this.value; - if (teacherId) { - loadTeacherLessons(teacherId); - } else { - modalLessons.style.display = 'none'; - schLessonsContainer.innerHTML = '
Выберите преподавателя для просмотра занятий
'; - } - }); + async function saveRule(event) { + event.preventDefault(); + hideAlert('schedule-rule-alert'); - // ===== Открытие / закрытие оверлея ===== - function openOverlay() { - // Автозаполнение преподавателя из фильтра таблицы - const autoTeacherId = getFilteredTeacherId(); - if (autoTeacherId) { - schTeacherSelect.value = autoTeacherId; - loadTeacherLessons(autoTeacherId); + try { + const payload = buildRulePayload(); + const id = ruleIdInput.value; + const saved = id + ? await api.put('/api/admin/schedule-rules/' + id, payload) + : await api.post('/api/admin/schedule-rules', payload); + showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success'); + resetRuleForm(); + await loadRules(); + } catch (error) { + showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error'); } - - overlay.classList.add('open'); } - function closeOverlay() { - overlay.classList.remove('open'); - resetForm(); + function buildRulePayload() { + const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) + .map(input => Number(input.value)); + const slots = readRuleSlots(true); + + if (!ruleSubjectSelect.value) { + throw new Error('Выберите дисциплину'); + } + if (!ruleSemesterSelect.value) { + throw new Error('Выберите семестр'); + } + if (!ruleActiveFromInput.value) { + throw new Error('Укажите дату начала правила'); + } + if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) { + throw new Error('Укажите количество академических часов'); + } + if (!groupIds.length) { + throw new Error('Выберите хотя бы одну группу'); + } + if (!slots.length) { + throw new Error('Добавьте хотя бы один слот занятия'); + } + + return { + id: ruleIdInput.value ? Number(ruleIdInput.value) : null, + subjectId: Number(ruleSubjectSelect.value), + semesterId: Number(ruleSemesterSelect.value), + activeFromDate: ruleActiveFromInput.value, + totalAcademicHours: Number(ruleHoursInput.value), + groupIds, + slots + }; } - function resetForm() { - addForm.reset(); - schTeacherSelect.value = ''; - schGroupSelect.value = ''; - schDisciplineSelect.value = ''; - schClassroomSelect.value = ''; - schDaySelect.value = ''; - schTypeSelect.value = ''; - schTimeSelect.innerHTML = ''; - schTimeSelect.disabled = true; - if (schWeekUpper) schWeekUpper.checked = false; - if (schWeekLower) schWeekLower.checked = false; - if (schFormatOffline) schFormatOffline.checked = true; - modalLessons.style.display = 'none'; - schLessonsContainer.innerHTML = '
Выберите преподавателя для просмотра занятий
'; - hideAlert('sch-add-alert'); + function fillRuleForm(rule) { + ruleFormTitle.textContent = 'Редактирование правила расписания'; + ruleIdInput.value = rule.id; + ruleSubjectSelect.value = rule.subjectId || ''; + ruleSemesterSelect.value = rule.semesterId || ''; + ruleActiveFromInput.value = rule.activeFromDate || ''; + ruleHoursInput.value = rule.totalAcademicHours || ''; + syncSelects(ruleSubjectSelect, ruleSemesterSelect); + renderRuleGroups(rule.groupIds || []); + renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]); + hideAlert('schedule-rule-alert'); + ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); } - btnAddLesson.addEventListener('click', openOverlay); - btnClose.addEventListener('click', closeOverlay); + function resetRuleForm() { + ruleForm.reset(); + ruleFormTitle.textContent = 'Новое правило расписания'; + ruleIdInput.value = ''; + renderRuleGroups([]); + renderRuleSlots([{}]); + syncSelects(ruleSubjectSelect, ruleSemesterSelect); + hideAlert('schedule-rule-alert'); + } - // Закрытие по клику на оверлей (мимо модалок) - overlay.addEventListener('click', (e) => { - if (e.target === overlay || e.target.classList.contains('cs-overlay-scroll')) { - closeOverlay(); - } - }); - - // Закрытие по Escape - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && overlay.classList.contains('open')) { - closeOverlay(); - } - }); - - // ===== Отправка формы ===== - addForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('sch-add-alert'); - - const teacherId = schTeacherSelect.value; - const groupId = schGroupSelect.value; - const subjectId = schDisciplineSelect.value; - const classroomId = schClassroomSelect.value; - const lessonType = schTypeSelect.value; - const dayOfWeek = schDaySelect.value; - const timeSlot = schTimeSelect.value; - const lessonFormat = document.querySelector('input[name="schLessonFormat"]:checked')?.value; - - if (!teacherId) { showAlert('sch-add-alert', 'Выберите преподавателя', 'error'); return; } - if (!groupId) { showAlert('sch-add-alert', 'Выберите группу', 'error'); return; } - if (!subjectId) { showAlert('sch-add-alert', 'Выберите дисциплину', 'error'); return; } - if (!classroomId) { showAlert('sch-add-alert', 'Выберите аудиторию', 'error'); return; } - if (!dayOfWeek) { showAlert('sch-add-alert', 'Выберите день недели', 'error'); return; } - if (!timeSlot) { showAlert('sch-add-alert', 'Выберите время', 'error'); return; } - - const weekUpperChecked = schWeekUpper?.checked || false; - const weekLowerChecked = schWeekLower?.checked || false; - - if (!weekUpperChecked && !weekLowerChecked) { - showAlert('sch-add-alert', 'Не выбран тип недели', 'error'); + function renderRuleGroups(selectedIds) { + if (!groups.length) { + ruleGroupsContainer.innerHTML = '
Нет групп
'; return; } - let weekType = null; - if (weekUpperChecked && weekLowerChecked) weekType = 'Обе'; - else if (weekUpperChecked) weekType = 'Верхняя'; - else if (weekLowerChecked) weekType = 'Нижняя'; + const selected = new Set((selectedIds || []).map(String)); + ruleGroupsContainer.innerHTML = groups.map(group => ` + + `).join(''); + } + + function renderRuleSlots(slots) { + ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ `).join(''); + } + + function readRuleSlots(validate) { + const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); + return rows.map((row, index) => { + const slot = { + id: null, + dayOfWeek: Number(row.querySelector('.slot-day').value), + parity: row.querySelector('.slot-parity').value, + timeSlotId: Number(row.querySelector('.slot-time').value), + subgroupId: null, + teacherId: Number(row.querySelector('.slot-teacher').value), + classroomId: Number(row.querySelector('.slot-classroom').value), + lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), + lessonFormat: row.querySelector('.slot-format').value + }; + + if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId + || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) { + throw new Error(`Заполните слот ${index + 1}`); + } + return slot; + }); + } + + async function loadTimeSlots() { + timeSlotsTbody.innerHTML = 'Загрузка...'; + try { + timeSlots = await api.get('/api/admin/time-slots'); + renderTimeSlots(); + } catch (error) { + timeSlotsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderTimeSlots() { + if (!timeSlots || !timeSlots.length) { + timeSlotsTbody.innerHTML = 'Сетка пар не настроена'; + return; + } + + timeSlotsTbody.innerHTML = timeSlots.map(slot => ` + + ${slot.orderNumber} + ${escapeHtml(trimTime(slot.startTime))} + ${escapeHtml(trimTime(slot.endTime))} + ${escapeHtml(String(slot.durationMinutes ?? '-'))} + + + + + + `).join(''); + } + + async function saveTimeSlot(event) { + event.preventDefault(); + hideAlert('time-slot-alert'); + + const payload = { + id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null, + orderNumber: Number(timeSlotOrderInput.value), + startTime: timeSlotStartInput.value, + endTime: timeSlotEndInput.value, + durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null + }; try { - await api.post('/api/users/lessons/create', { - teacherId: parseInt(teacherId), - groupId: parseInt(groupId), - subjectId: parseInt(subjectId), - classroomId: parseInt(classroomId), - typeLesson: lessonType, - lessonFormat: lessonFormat, - day: dayOfWeek, - week: weekType, - time: timeSlot - }); - - showAlert('sch-add-alert', 'Занятие добавлено ✓', 'success'); - - // Очистить все поля кроме преподавателя (для массового добавления) - schGroupSelect.selectedIndex = 0; - schDisciplineSelect.selectedIndex = 0; - schClassroomSelect.selectedIndex = 0; - schTypeSelect.selectedIndex = 0; - schDaySelect.selectedIndex = 0; - schTimeSelect.innerHTML = ''; - schTimeSelect.disabled = true; - schWeekUpper.checked = false; - schWeekLower.checked = false; - document.querySelector('input[name="schLessonFormat"][value="Очно"]').checked = true; - - // Обновить занятия преподавателя в модалке 2 - if (teacherId) { - await loadTeacherLessons(teacherId); + if (!payload.orderNumber || !payload.startTime || !payload.endTime) { + throw new Error('Заполните номер пары, начало и окончание'); } - - // Обновить основную таблицу - await loadSchedule(); - - setTimeout(() => { - hideAlert('sch-add-alert'); - }, 4000); - } catch (err) { - showAlert('sch-add-alert', err.message || 'Ошибка добавления занятия', 'error'); + const id = timeSlotIdInput.value; + await (id + ? api.put('/api/admin/time-slots/' + id, payload) + : api.post('/api/admin/time-slots', payload)); + showAlert('time-slot-alert', 'Временной слот сохранён', 'success'); + resetTimeSlotForm(); + await loadTimeSlots(); + renderRuleSlots(readRuleSlots(false)); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error'); } - }); + } - // ===================== Инициализация ===================== - await Promise.all([ - loadSchedule(), - loadGroups(), - loadSubjects(), - loadClassrooms(), - loadTeachers() - ]); -} \ No newline at end of file + async function handleTimeSlotTableClick(event) { + const editButton = event.target.closest('.btn-edit-time-slot'); + const deleteButton = event.target.closest('.btn-delete-time-slot'); + + if (editButton) { + const slot = timeSlots.find(item => item.id == editButton.dataset.id); + if (!slot) return; + timeSlotFormTitle.textContent = 'Редактирование временного слота'; + timeSlotIdInput.value = slot.id; + timeSlotOrderInput.value = slot.orderNumber || ''; + timeSlotStartInput.value = trimTime(slot.startTime); + timeSlotEndInput.value = trimTime(slot.endTime); + timeSlotDurationInput.value = slot.durationMinutes || ''; + hideAlert('time-slot-alert'); + timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteButton) { + if (!confirm('Удалить временной слот?')) return; + try { + await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id); + showAlert('time-slot-alert', 'Временной слот удалён', 'success'); + await loadTimeSlots(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error'); + } + } + } + + function resetTimeSlotForm() { + timeSlotForm.reset(); + timeSlotFormTitle.textContent = 'Новый временной слот'; + timeSlotIdInput.value = ''; + hideAlert('time-slot-alert'); + } + + async function loadYears() { + academicYearsTbody.innerHTML = 'Загрузка...'; + try { + academicYears = await api.get('/api/admin/calendar/years'); + semesters = academicYears.flatMap(year => year.semesters || []); + renderAcademicYears(); + populateYearSelects(); + populateSemesterSelects(); + } catch (error) { + academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderAcademicYears() { + if (!academicYears || !academicYears.length) { + academicYearsTbody.innerHTML = 'Учебные годы не созданы'; + return; + } + + academicYearsTbody.innerHTML = academicYears.map(year => ` + + ${escapeHtml(year.title)} + ${escapeHtml(year.startDate)} — ${escapeHtml(year.endDate)} + ${renderSemesterList(year.semesters || [])} + + + + + + `).join(''); + } + + function renderSemesterList(items) { + if (!items.length) return 'Нет семестров'; + return items.map(semester => ` +
+ ${escapeHtml(semesterLabel(semester))} + +
+ `).join(''); + } + + async function saveAcademicYear(event) { + event.preventDefault(); + hideAlert('academic-year-alert'); + + const payload = { + title: academicYearTitleInput.value.trim(), + startDate: academicYearStartInput.value, + endDate: academicYearEndInput.value + }; + + try { + if (!payload.title || !payload.startDate || !payload.endDate) { + throw new Error('Заполните название и даты учебного года'); + } + const id = academicYearIdInput.value; + await (id + ? api.put('/api/admin/calendar/years/' + id, payload) + : api.post('/api/admin/calendar/years', payload)); + showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); + resetAcademicYearForm(); + await loadYears(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); + } + } + + async function handleAcademicYearTableClick(event) { + const editYearButton = event.target.closest('.btn-edit-year'); + const deleteYearButton = event.target.closest('.btn-delete-year'); + const editSemesterButton = event.target.closest('.btn-edit-semester'); + + if (editYearButton) { + const year = academicYears.find(item => item.id == editYearButton.dataset.id); + if (!year) return; + academicYearFormTitle.textContent = 'Редактирование учебного года'; + academicYearIdInput.value = year.id; + academicYearTitleInput.value = year.title || ''; + academicYearStartInput.value = year.startDate || ''; + academicYearEndInput.value = year.endDate || ''; + hideAlert('academic-year-alert'); + academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (editSemesterButton) { + const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); + if (!semester) return; + semesterFormTitle.textContent = 'Редактирование семестра'; + semesterIdInput.value = semester.id; + semesterYearSelect.value = semester.academicYearId || ''; + semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); + semesterStartInput.value = semester.startDate || ''; + semesterEndInput.value = semester.endDate || ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteYearButton) { + if (!confirm('Удалить учебный год вместе с семестрами?')) return; + try { + await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); + showAlert('academic-year-alert', 'Учебный год удалён', 'success'); + await loadYears(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); + } + } + } + + function resetAcademicYearForm() { + academicYearForm.reset(); + academicYearFormTitle.textContent = 'Учебный год'; + academicYearIdInput.value = ''; + hideAlert('academic-year-alert'); + } + + async function saveSemester(event) { + event.preventDefault(); + hideAlert('semester-alert'); + + const yearId = semesterYearSelect.value; + const payload = { + semesterType: semesterTypeSelect.value, + startDate: semesterStartInput.value, + endDate: semesterEndInput.value + }; + + try { + if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { + throw new Error('Заполните учебный год, тип и даты семестра'); + } + const id = semesterIdInput.value; + await (id + ? api.put('/api/admin/calendar/semesters/' + id, payload) + : api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload)); + showAlert('semester-alert', 'Семестр сохранён', 'success'); + resetSemesterForm(); + await loadYears(); + } catch (error) { + showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error'); + } + } + + function resetSemesterForm() { + semesterForm.reset(); + semesterFormTitle.textContent = 'Семестр'; + semesterIdInput.value = ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + } + + async function saveHoliday(event) { + event.preventDefault(); + hideAlert('holiday-alert'); + + const payload = { + id: holidayIdInput.value ? Number(holidayIdInput.value) : null, + academicYearId: Number(holidayYearSelect.value), + date: holidayDateInput.value, + description: holidayDescriptionInput.value.trim() + }; + + try { + if (!payload.academicYearId || !payload.date) { + throw new Error('Выберите учебный год и дату'); + } + const id = holidayIdInput.value; + await (id + ? api.put('/api/admin/calendar/holidays/' + id, payload) + : api.post('/api/admin/calendar/holidays', payload)); + showAlert('holiday-alert', 'Дата сохранена', 'success'); + resetHolidayForm(false); + await loadHolidays(); + } catch (error) { + showAlert('holiday-alert', error.message || 'Ошибка сохранения даты', 'error'); + } + } + + async function loadHolidays() { + const yearId = holidayYearSelect.value; + if (!yearId) { + holidaysTbody.innerHTML = 'Выберите учебный год'; + return; + } + + holidaysTbody.innerHTML = 'Загрузка...'; + try { + holidays = await api.get('/api/admin/calendar/holidays?academicYearId=' + encodeURIComponent(yearId)); + renderHolidays(); + } catch (error) { + holidaysTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderHolidays() { + if (!holidays || !holidays.length) { + holidaysTbody.innerHTML = 'Исключения не добавлены'; + return; + } + + holidaysTbody.innerHTML = holidays.map(holiday => ` + + ${escapeHtml(holiday.date)} + ${escapeHtml(holiday.description || '-')} + + + + + + `).join(''); + } + + async function handleHolidayTableClick(event) { + const editButton = event.target.closest('.btn-edit-holiday'); + const deleteButton = event.target.closest('.btn-delete-holiday'); + + if (editButton) { + const holiday = holidays.find(item => item.id == editButton.dataset.id); + if (!holiday) return; + holidayIdInput.value = holiday.id; + holidayYearSelect.value = holiday.academicYearId || holidayYearSelect.value; + holidayDateInput.value = holiday.date || ''; + holidayDescriptionInput.value = holiday.description || ''; + syncSelects(holidayYearSelect); + hideAlert('holiday-alert'); + holidayForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteButton) { + if (!confirm('Удалить дату-исключение?')) return; + try { + await api.delete('/api/admin/calendar/holidays/' + deleteButton.dataset.id); + showAlert('holiday-alert', 'Дата удалена', 'success'); + await loadHolidays(); + } catch (error) { + showAlert('holiday-alert', error.message || 'Ошибка удаления даты', 'error'); + } + } + } + + function resetHolidayForm(clearYear = true) { + const yearId = holidayYearSelect.value; + holidayForm.reset(); + if (!clearYear) holidayYearSelect.value = yearId; + holidayIdInput.value = ''; + syncSelects(holidayYearSelect); + hideAlert('holiday-alert'); + } + + async function loadMatrix() { + hideAlert('matrix-alert'); + const semesterId = matrixSemesterSelect.value; + const courseNumber = Number(matrixCourseInput.value); + const specialtyId = matrixSpecialtySelect.value; + + if (!semesterId || !courseNumber || !specialtyId) { + showAlert('matrix-alert', 'Выберите семестр, курс и специальность', 'error'); + return; + } + + try { + const params = new URLSearchParams({ semesterId, courseNumber: String(courseNumber), specialtyId }); + const rows = await api.get('/api/admin/calendar/matrix?' + params.toString()); + renderMatrix(rows && rows.length ? rows : buildDefaultMatrixRows(semesterId, courseNumber, specialtyId)); + showAlert('matrix-alert', rows && rows.length ? 'Матрица загружена' : 'Матрица не найдена, подготовлена новая сетка', 'success'); + } catch (error) { + showAlert('matrix-alert', error.message || 'Ошибка загрузки матрицы', 'error'); + } + } + + async function saveMatrix() { + hideAlert('matrix-alert'); + const rows = Array.from(matrixGrid.querySelectorAll('.matrix-week')).map(item => ({ + id: item.dataset.id ? Number(item.dataset.id) : null, + semesterId: Number(item.dataset.semesterId), + courseNumber: Number(item.dataset.courseNumber), + specialtyId: Number(item.dataset.specialtyId), + weekNumber: Number(item.dataset.weekNumber), + activityType: item.querySelector('select').value + })); + + if (!rows.length) { + showAlert('matrix-alert', 'Сначала загрузите матрицу', 'error'); + return; + } + + try { + await api.put('/api/admin/calendar/matrix', rows); + showAlert('matrix-alert', 'Матрица учебного графика сохранена', 'success'); + await loadMatrix(); + } catch (error) { + showAlert('matrix-alert', error.message || 'Ошибка сохранения матрицы', 'error'); + } + } + + function renderMatrix(rows) { + matrixGrid.innerHTML = rows.map(row => ` +
+ ${row.weekNumber} + +
+ `).join(''); + } + + function buildDefaultMatrixRows(semesterId, courseNumber, specialtyId) { + return Array.from({ length: 22 }, (_, index) => ({ + id: null, + semesterId: Number(semesterId), + courseNumber, + specialtyId: Number(specialtyId), + weekNumber: index + 1, + activityType: 'THEORY' + })); + } + + function populateSubjectSelects() { + ruleSubjectSelect.innerHTML = '' + + subjects.map(subject => ``).join(''); + } + + function populateYearSelects() { + const yearOptions = '' + + academicYears.map(year => ``).join(''); + semesterYearSelect.innerHTML = yearOptions; + holidayYearSelect.innerHTML = yearOptions; + + if (!holidayYearSelect.value && academicYears.length) { + holidayYearSelect.value = String(academicYears[0].id); + } + syncSelects(semesterYearSelect, holidayYearSelect); + } + + function populateSemesterSelects() { + const semesterOptions = '' + + semesters.map(semester => ``).join(''); + ruleSemesterSelect.innerHTML = semesterOptions; + matrixSemesterSelect.innerHTML = semesterOptions; + syncSelects(ruleSemesterSelect, matrixSemesterSelect); + } + + function populateSpecialtySelects() { + matrixSpecialtySelect.innerHTML = '' + + specialties.map(speciality => ``).join(''); + syncSelects(matrixSpecialtySelect); + } + + function options(items, selectedValue) { + const selected = selectedValue == null ? '' : String(selectedValue); + return '' + items.map(item => { + const value = String(item.value); + return ``; + }).join(''); + } + + function activityOptions() { + return Object.entries(ACTIVITY_LABELS).map(([value, label]) => ({ value, label })); + } + + function toTimeSlotOption(slot) { + return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; + } + + function toTeacherOption(teacher) { + return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` }; + } + + function toClassroomOption(classroom) { + const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : ''; + return { value: classroom.id, label: `${classroom.name}${capacity}` }; + } + + function toLessonTypeOption(lessonType) { + return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; + } + + function semesterLabel(semester) { + const type = normalizeSemesterType(semester.semesterType); + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} — ${semester.endDate})`; + } + + function formatSemester(rule) { + const type = normalizeSemesterType(rule.semesterType); + return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`; + } + + function normalizeSemesterType(type) { + return String(type || '').toLowerCase(); + } + + function specialityLabel(speciality) { + const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; + const name = speciality.specialityName || speciality.name || ''; + return name ? `${code} — ${name}` : String(code); + } + + function trimTime(value) { + if (!value) return ''; + return String(value).slice(0, 5); + } + + function syncSelects(...selects) { + selects.filter(Boolean).forEach(select => { + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } +} diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js old mode 100755 new mode 100644 index 2189442..2008ca3 --- a/frontend/admin/js/views/users.js +++ b/frontend/admin/js/views/users.js @@ -8,173 +8,20 @@ export async function initUsers() { const usersTbody = document.getElementById('users-tbody'); const createForm = document.getElementById('create-form'); - // ===== Оверлей (cs-overlay) ===== - const usersOverlay = document.getElementById('users-overlay'); - - // ===== 1-е модальное окно: Добавить занятие ===== - const modalAddLesson = document.getElementById('modal-add-lesson'); - const modalAddLessonClose = document.getElementById('modal-add-lesson-close'); - const addLessonForm = document.getElementById('add-lesson-form'); - - const lessonGroupSelect = document.getElementById('lesson-group'); - const lessonDisciplineSelect = document.getElementById('lesson-discipline'); - const lessonClassroomSelect = document.getElementById('lesson-classroom'); - const lessonTypeSelect = document.getElementById('lesson-type'); - const lessonOnlineFormat = document.getElementById('format-online'); - const lessonOfflineFormat = document.getElementById('format-offline'); - const lessonUserId = document.getElementById('lesson-user-id'); - const lessonDaySelect = document.getElementById('lesson-day'); - const weekUpper = document.getElementById('week-upper'); - const weekLower = document.getElementById('week-lower'); - const lessonTimeSelect = document.getElementById('lesson-time'); - - // ===== 2-е модальное окно: Просмотр занятий ===== - const modalViewLessons = document.getElementById('modal-view-lessons'); - const lessonsContainer = document.getElementById('lessons-container'); - const modalTeacherName = document.getElementById('modal-teacher-name'); - - let currentLessonsTeacherId = null; - let currentLessonsTeacherName = ''; - // ===== Данные ===== - let groups = []; - let subjects = []; - let classrooms = []; - - const weekdaysTimes = [ - "8:00-9:30", - "9:40-11:10", - "11:40-13:10", - "13:20-14:50", - "15:00-16:30", - "16:50-18:20", - "18:30-19:00" - ]; - - const saturdayTimes = [ - "8:20-9:50", - "10:00-11:30", - "11:40-13:10", - "13:20-14:50" - ]; - - // ========================================================= - // Загрузка справочников - // ========================================================= - async function loadGroups() { - try { - groups = await api.get('/api/groups'); - renderGroupOptions(); - } catch (e) { - console.error('Ошибка загрузки групп:', e); - } - } - - async function loadSubjects() { - try { - subjects = await api.get('/api/subjects'); - renderSubjectOptions(); - } catch (e) { - console.error('Ошибка загрузки дисциплин:', e); - } - } - - async function loadClassrooms() { - try { - classrooms = await api.get('/api/classrooms'); - renderClassroomsOptions(); - } catch (e) { - console.error('Ошибка загрузки аудиторий:', e); - } - } - - function renderGroupOptions() { - if (!groups || groups.length === 0) { - lessonGroupSelect.innerHTML = ''; - return; - } - - lessonGroupSelect.innerHTML = - '' + - groups.map(g => { - let optionText = escapeHtml(g.name); - if (g.groupSize) optionText += ` (численность: ${g.groupSize} чел.)`; - return ``; - }).join(''); - } - - function renderSubjectOptions() { - lessonDisciplineSelect.innerHTML = - '' + - subjects.map(s => ``).join(''); - } - - function renderClassroomsOptions() { - if (!classrooms || classrooms.length === 0) { - lessonClassroomSelect.innerHTML = ''; - return; - } - - const selectedGroupId = lessonGroupSelect.value; - const selectedGroup = groups?.find(g => g.id == selectedGroupId); - const groupSize = selectedGroup?.groupSize || 0; - - lessonClassroomSelect.innerHTML = - '' + - classrooms.map(c => { - let optionText = escapeHtml(c.name); - - if (c.capacity) optionText += ` (вместимость: ${c.capacity} чел.)`; - - if (c.isAvailable === false) { - optionText += ` ❌ Занята`; - } else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) { - optionText += ` ⚠️ Недостаточно места`; - } - - return ``; - }).join(''); - } - - lessonGroupSelect.addEventListener('change', function () { - renderClassroomsOptions(); - requestAnimationFrame(() => syncAddLessonHeight()); - }); - - function updateTimeOptions(dayValue) { - let times = []; - if (dayValue === "Суббота") { - times = saturdayTimes; - } else if (dayValue && dayValue !== '') { - times = weekdaysTimes; - } else { - lessonTimeSelect.innerHTML = ''; - lessonTimeSelect.disabled = true; - return; - } - - lessonTimeSelect.innerHTML = - '' + - times.map(t => ``).join(''); - lessonTimeSelect.disabled = false; - } - - // ========================================================= - // Пользователи - // ========================================================= async function loadUsers() { try { const users = await api.get('/api/users'); renderUsers(users); } catch (e) { usersTbody.innerHTML = - 'Ошибка загрузки: ' + + 'Ошибка загрузки: ' + escapeHtml(e.message) + ''; } } function renderUsers(users) { if (!users || !users.length) { - usersTbody.innerHTML = 'Нет пользователей'; + usersTbody.innerHTML = 'Нет пользователей'; return; } @@ -184,148 +31,15 @@ export async function initUsers() { ${escapeHtml(u.username)} ${escapeHtml(u.fullName || '-')} ${escapeHtml(u.jobTitle || '-')} - ${u.departmentName || '-'} + ${escapeHtml(u.departmentName || '-')} ${ROLE_LABELS[u.role] || escapeHtml(u.role)} - - - `).join(''); } - // ===== Открытие / закрытие оверлея ===== - function openOverlay() { - if (usersOverlay) usersOverlay.classList.add('open'); - } - function closeOverlay() { - if (usersOverlay) usersOverlay.classList.remove('open'); - if (modalViewLessons) modalViewLessons.style.display = 'none'; - resetLessonForm(); - } - - // ========================================================= - // 1-я модалка: добавление занятия - // ========================================================= - function resetLessonForm() { - addLessonForm.reset(); - lessonUserId.value = ''; - - if (weekUpper) weekUpper.checked = false; - if (weekLower) weekLower.checked = false; - - if (lessonOfflineFormat) lessonOfflineFormat.checked = true; - if (lessonOnlineFormat) lessonOnlineFormat.checked = false; - - lessonTimeSelect.innerHTML = ''; - lessonTimeSelect.disabled = true; - - hideAlert('add-lesson-alert'); - } - - function openAddLessonModal(userId) { - lessonUserId.value = userId; - - lessonDaySelect.value = ''; - updateTimeOptions(''); - - openOverlay(); - } - - addLessonForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('add-lesson-alert'); - - const userId = lessonUserId.value; - const groupId = lessonGroupSelect.value; - const subjectId = lessonDisciplineSelect.value; - const classroomId = lessonClassroomSelect.value; - const lessonType = lessonTypeSelect.value; - const dayOfWeek = lessonDaySelect.value; - const timeSlot = lessonTimeSelect.value; - - const lessonFormat = document.querySelector('input[name="lessonFormat"]:checked')?.value; - - if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); return; } - if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); return; } - if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); return; } - if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); return; } - if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); return; } - - const weekUpperChecked = weekUpper?.checked || false; - const weekLowerChecked = weekLower?.checked || false; - - if (!weekUpperChecked && !weekLowerChecked) { - showAlert('add-lesson-alert', 'Не выбран тип недели', 'error'); - return; - } - - let weekType = null; - if (weekUpperChecked && weekLowerChecked) weekType = 'Обе'; - else if (weekUpperChecked) weekType = 'Верхняя'; - else if (weekLowerChecked) weekType = 'Нижняя'; - - try { - await api.post('/api/users/lessons/create', { - teacherId: parseInt(userId), - groupId: parseInt(groupId), - subjectId: parseInt(subjectId), - classroomId: parseInt(classroomId), - typeLesson: lessonType, - lessonFormat: lessonFormat, - day: dayOfWeek, - week: weekType, - time: timeSlot - }); - - if (modalViewLessons?.style.display !== 'none' && currentLessonsTeacherId == userId) { - await loadTeacherLessons(currentLessonsTeacherId, currentLessonsTeacherName); - } - - showAlert('add-lesson-alert', 'Занятие добавлено ✓', 'success'); - - lessonGroupSelect.selectedIndex = 0; - lessonDisciplineSelect.selectedIndex = 0; - lessonClassroomSelect.selectedIndex = 0; - lessonTypeSelect.selectedIndex = 0; - lessonDaySelect.selectedIndex = 0; - lessonTimeSelect.innerHTML = ''; - lessonTimeSelect.disabled = true; - - weekUpper.checked = false; - weekLower.checked = false; - document.querySelector('input[name="lessonFormat"][value="Очно"]').checked = true; - - setTimeout(() => { - hideAlert('add-lesson-alert'); - }, 3000); - } catch (err) { - showAlert('add-lesson-alert', err.message || 'Ошибка добавления занятия', 'error'); - } - }); - - lessonDaySelect.addEventListener('change', function () { - updateTimeOptions(this.value); - }); - - if (modalAddLessonClose) { - modalAddLessonClose.addEventListener('click', () => closeOverlay()); - } - - // Клик по оверлею (мимо модалок) закрывает всё - if (usersOverlay) { - usersOverlay.querySelector('.cs-overlay-scroll')?.addEventListener('click', (e) => { - if (e.target.classList.contains('cs-overlay-scroll')) { - closeOverlay(); - } - }); - } - - // ========================================================= - // Создание пользователя - // ========================================================= createForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-alert'); @@ -343,9 +57,9 @@ export async function initUsers() { } try { - const data = await api.post('/api/users', { - username, - password, + const data = await api.post('/api/users', { + username, + password, role, fullName, jobTitle, @@ -359,133 +73,18 @@ export async function initUsers() { } }); - // ========================================================= - // Инициализация - // ========================================================= - await Promise.all([loadUsers(), loadGroups(), loadSubjects(), loadClassrooms()]); - - // ========================================================= - // 2-я модалка: просмотр занятий - // ========================================================= - async function loadTeacherLessons(teacherId, teacherName) { - try { - lessonsContainer.innerHTML = '
Загрузка занятий...
'; - - modalTeacherName.textContent = teacherName - ? `Занятия преподавателя: ${teacherName}` - : 'Занятия преподавателя'; - - const lessons = await api.get(`/api/users/lessons/${teacherId}`); - - if (!lessons || lessons.length === 0) { - lessonsContainer.innerHTML = '
У преподавателя пока нет занятий
'; - return; - } - - const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']; - const lessonsByDay = {}; - - lessons.forEach(lesson => { - if (!lessonsByDay[lesson.day]) lessonsByDay[lesson.day] = []; - lessonsByDay[lesson.day].push(lesson); - }); - - Object.keys(lessonsByDay).forEach(day => { - lessonsByDay[day].sort((a, b) => a.time.localeCompare(b.time)); - }); - - let html = ''; - - daysOrder.forEach(day => { - if (!lessonsByDay[day]) return; - - html += `
${day}
`; - - lessonsByDay[day].forEach(lesson => { - html += ` -
-
- ${escapeHtml(lesson.groupName)} - ${escapeHtml(lesson.time)} -
-
-
${escapeHtml(lesson.subjectName)}
-
- ${escapeHtml(lesson.typeLesson)} - ${escapeHtml(lesson.lessonFormat)} - ${escapeHtml(lesson.week)} - ${escapeHtml(lesson.classroomName)} -
-
-
- `; - }); - }); - - lessonsContainer.innerHTML = html; - } catch (e) { - lessonsContainer.innerHTML = `
Ошибка загрузки: ${escapeHtml(e.message)}
`; - console.error('Ошибка загрузки занятий:', e); - } - } - - function openViewLessonsModal(teacherId, teacherName) { - currentLessonsTeacherId = teacherId; - currentLessonsTeacherName = teacherName || ''; - - if (modalViewLessons) modalViewLessons.style.display = ''; - loadTeacherLessons(teacherId, teacherName); - } - - function closeViewLessonsModal() { - if (modalViewLessons) modalViewLessons.style.display = 'none'; - currentLessonsTeacherId = null; - currentLessonsTeacherName = ''; - } - - document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - if (usersOverlay?.classList.contains('open')) { - closeOverlay(); - } - }); - - // ========================================================= - // ЕДИНЫЙ обработчик кликов по таблице (ВАЖНО: без дубля) - // ========================================================= usersTbody.addEventListener('click', async (e) => { const deleteBtn = e.target.closest('.btn-delete'); - if (deleteBtn) { - if (!confirm('Удалить пользователя?')) return; - try { - await api.delete('/api/users/' + deleteBtn.dataset.id); - loadUsers(); - } catch (err) { - alert(err.message || 'Ошибка удаления'); - } - return; - } + if (!deleteBtn) return; - const addLessonBtn = e.target.closest('.btn-add-lesson'); - if (addLessonBtn) { - e.preventDefault(); - - const teacherId = addLessonBtn.dataset.id; - const teacherName = addLessonBtn.dataset.name; - - openAddLessonModal(teacherId); - openViewLessonsModal(teacherId, teacherName); - - return; - } - - const viewLessonsBtn = e.target.closest('.btn-view-lessons'); - if (viewLessonsBtn) { - e.preventDefault(); - const teacherId = viewLessonsBtn.dataset.id; - const teacherName = viewLessonsBtn.dataset.name; - openViewLessonsModal(teacherId, teacherName); - return; + if (!confirm('Удалить пользователя?')) return; + try { + await api.delete('/api/users/' + deleteBtn.dataset.id); + loadUsers(); + } catch (err) { + alert(err.message || 'Ошибка удаления'); } }); + + await loadUsers(); } diff --git a/frontend/admin/views/department.html b/frontend/admin/views/department.html deleted file mode 100644 index 11bf25b..0000000 --- a/frontend/admin/views/department.html +++ /dev/null @@ -1,180 +0,0 @@ -
-
-

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

- -
-
-
-
- - -
- -
- -
- - -
-
- -
- - -
- - -
- -
-
- - -
-
- - - - - - - -
-
- -
- -
\ No newline at end of file diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html index a7ae105..dd31814 100644 --- a/frontend/admin/views/departments-data.html +++ b/frontend/admin/views/departments-data.html @@ -28,11 +28,12 @@ ID Название Код + Действия - Загрузка... + Загрузка... @@ -68,13 +69,58 @@ ID Название Код специальности + Действия - Загрузка... + Загрузка...
+ + + + diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 1bb9015..c05b8af 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -9,7 +9,7 @@
- +
@@ -18,16 +18,20 @@
- - + +
- +
- - + +
@@ -53,9 +57,9 @@ Название Численность (чел.) Форма обучения - ID кафедры + Кафедра Курс - Код специальности + Специальность Действия @@ -66,4 +70,4 @@ - \ No newline at end of file + diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html old mode 100755 new mode 100644 index 8104356..b5faf14 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -1,179 +1,281 @@ +
+
+

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

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

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

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

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

- +

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

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

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

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

Новый временной слот

+
-
\ No newline at end of file +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Сетка пар

+ +
+
+ + + + + + + + + + + + + + + +
ПараНачалоОкончаниеМинутДействия
Загрузка...
+
+
+ +
+

Учебный год

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

Учебные годы

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

Семестр

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

Праздники и исключения

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+ + + + + + + + + + + + + +
ДатаОписаниеДействия
Выберите учебный год
+
+
+ +
+

Матрица учебного графика

+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index bf282f3..6c9327e 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -49,141 +49,14 @@ Должность Кафедра Роль - Действия + Действия - Загрузка... + Загрузка...
- - - -
-
- - - - - - - -
-
\ No newline at end of file + From 89c822a073335bbf8e775f6ba804b50c613fe0d7 Mon Sep 17 00:00:00 2001 From: Zuev Date: Thu, 30 Apr 2026 23:40:49 +0300 Subject: [PATCH 03/16] =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BB=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=83=20=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D0=B1=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 319 ++------- SCHEDULE_PROPOSAL.md | 194 +----- SCHEDULE_TASKS.md | 236 +------ .../AcademicCalendarAdminController.java | 195 +++--- .../AcademicCalendarController.java | 255 +++++++ .../app/controller/GroupController.java | 299 +++++--- .../app/controller/SpecialityController.java | 106 ++- .../dto/AcademicCalendarActivityTypeDto.java | 12 + .../magistr/app/dto/AcademicCalendarDto.java | 21 + .../app/dto/AcademicCalendarGridDayDto.java | 17 + .../app/dto/AcademicCalendarMatrixDto.java | 13 - .../magistr/app/dto/CalendarStudyFormDto.java | 8 + .../magistr/app/dto/CreateGroupRequest.java | 22 + .../app/dto/GroupCalendarAssignmentDto.java | 12 + .../com/magistr/app/dto/GroupResponse.java | 63 +- .../java/com/magistr/app/dto/HolidayDto.java | 12 - .../magistr/app/dto/RenderedLessonDto.java | 3 +- .../magistr/app/dto/SpecialtyProfileDto.java | 11 + .../app/model/AcademicActivityType.java | 8 - .../magistr/app/model/AcademicCalendar.java | 90 +++ .../model/AcademicCalendarActivityType.java | 86 +++ .../app/model/AcademicCalendarDay.java | 90 +++ .../app/model/AcademicCalendarMatrix.java | 77 --- .../magistr/app/model/CalendarStudyForm.java | 42 ++ .../java/com/magistr/app/model/Holiday.java | 56 -- .../magistr/app/model/SpecialtyProfile.java | 54 ++ .../com/magistr/app/model/StudentGroup.java | 27 +- .../model/StudentGroupCalendarAssignment.java | 56 ++ ...cademicCalendarActivityTypeRepository.java | 14 + .../AcademicCalendarDayRepository.java | 43 ++ .../AcademicCalendarMatrixRepository.java | 23 - .../AcademicCalendarRepository.java | 41 ++ .../CalendarStudyFormRepository.java | 11 + .../app/repository/GroupRepository.java | 3 - .../app/repository/HolidayRepository.java | 16 - .../SpecialtyProfileRepository.java | 14 + ...dentGroupCalendarAssignmentRepository.java | 54 ++ .../app/service/AcademicDateService.java | 62 +- .../app/service/ScheduleGeneratorService.java | 18 +- .../main/resources/db/migration/V1__init.sql | 238 +++++-- docs/ACADEMIC_CALENDAR.md | 76 +++ docs/API.md | 129 +++- docs/BUSINESS_LOGIC.md | 25 +- docs/DATABASE.md | 143 +++- docs/FRONTEND.md | 10 +- docs/README.md | 3 +- frontend/admin/css/auditorium-workload.css | 11 +- frontend/admin/css/components.css | 98 ++- .../admin/js/views/auditorium-workload.js | 52 +- frontend/admin/js/views/departments-data.js | 140 ++++ frontend/admin/js/views/groups.js | 460 ++++++++++--- frontend/admin/js/views/schedule.js | 636 +++++++++++------- frontend/admin/views/auditorium-workload.html | 10 +- frontend/admin/views/departments-data.html | 63 ++ frontend/admin/views/groups.html | 110 ++- frontend/admin/views/schedule.html | 113 +++- 56 files changed, 3356 insertions(+), 1644 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java delete mode 100644 backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java delete mode 100644 backend/src/main/java/com/magistr/app/dto/HolidayDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java delete mode 100644 backend/src/main/java/com/magistr/app/model/AcademicActivityType.java create mode 100644 backend/src/main/java/com/magistr/app/model/AcademicCalendar.java create mode 100644 backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java create mode 100644 backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java delete mode 100644 backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java create mode 100644 backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java delete mode 100644 backend/src/main/java/com/magistr/app/model/Holiday.java create mode 100644 backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java create mode 100644 backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java delete mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java delete mode 100644 backend/src/main/java/com/magistr/app/repository/HolidayRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java create mode 100644 docs/ACADEMIC_CALENDAR.md mode change 100755 => 100644 frontend/admin/js/views/groups.js diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index d7f7256..51ff698 100644 --- a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -1,114 +1,57 @@ # Реализация динамического расписания -Этот файл фиксирует текущий результат внедрения динамической системы расписания: что уже добавлено в проект, как связаны база данных, backend, API и frontend, как работает генератор расписания и какие ограничения остаются для следующего этапа. +Файл фиксирует текущий результат внедрения динамического расписания и календарных учебных графиков. -## Статус внедрения +## Статус -Реализован первый рабочий срез динамического расписания. Новая модель теперь является базовой схемой проекта и создаётся сразу в `V1__init.sql`. +Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. По требованию текущей ветки новая отдельная Flyway-миграция не создавалась; применение рассчитано на полный сброс БД. -Старые таблицы `lessons` и `schedule_data`, старые контроллеры, DTO, модели, репозитории и админские вызовы старых API удалены. Новая модель расписания является единственным базовым способом хранения и генерации расписания. +Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы. -## Что добавлено +## База данных -### База данных - -Динамическая схема встроена в базовую Flyway-миграцию `V1__init.sql`. - -Она создаёт следующие таблицы: +`V1__init.sql` создаёт и заполняет: | Таблица | Назначение | |---------|------------| -| `time_slots` | Настраиваемая сетка пар: номер, время начала, время окончания, длительность | -| `academic_years` | Учебные годы | -| `semesters` | Семестры учебного года, от `start_date` считается первая неделя | -| `holidays` | Праздники и исключённые даты, когда занятия не проводятся | -| `academic_calendar_matrix` | Матрица учебного графика по семестру, курсу, специальности и неделе | -| `schedule_rules` | Правила проведения дисциплины с лимитом академических часов | -| `schedule_rule_groups` | Привязка правила к одной или нескольким группам | -| `schedule_rule_slots` | Конкретные шаблонные слоты правила: день, чётность, пара, преподаватель, аудитория, тип и формат | +| `specialties` | Коды и названия специальностей | +| `specialty_profiles` | Профили обучения внутри специальности | +| `student_groups` | Группы со ссылками на специальность и профиль | +| `calendar_study_forms` | Формы обучения календарного графика | +| `academic_calendar_activity_types` | Коды активностей из Excel-графика | +| `academic_calendars` | Календарные графики профиля, формы обучения и учебного года | +| `academic_calendar_days` | Дневная сетка графика по курсу и дате | +| `student_group_calendar_assignments` | Назначения графиков группам по учебному году | +| `time_slots` | Настраиваемая сетка пар | +| `schedule_rules` | Правила проведения дисциплин | +| `schedule_rule_groups` | Группы правила | +| `schedule_rule_slots` | Шаблонные слоты правила | -Миграция также создаёт тестовые данные сразу в новой модели: +Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. -1. Создаёт базовые временные слоты. -2. Создаёт учебные годы `2024-2025` и `2025-2026`. -3. Заполняет матрицу учебного графика базовым статусом `THEORY`. -4. Создаёт тестовые `schedule_rules` для групп и дисциплин. -5. Создаёт привязки групп в `schedule_rule_groups`, включая потоковую лекцию. -6. Создаёт шаблонные занятия в `schedule_rule_slots`. +## Backend -### Backend +Добавлены сущности и репозитории для профилей, календарей, кодов активностей, дневной сетки и назначений группам. -Добавлены JPA-модели для новой схемы: - -- `TimeSlot` -- `AcademicYear` -- `Semester` -- `Holiday` -- `AcademicCalendarMatrix` -- `ScheduleRule` -- `ScheduleRuleSlot` -- `AcademicActivityType` -- `ScheduleParity` - -Добавлены DTO для API: - -- `TimeSlotDto` -- `AcademicYearDto` -- `SemesterDto` -- `HolidayDto` -- `AcademicCalendarMatrixDto` -- `ScheduleRuleDto` -- `ScheduleRuleSlotDto` -- `RenderedLessonDto` - -Добавлены репозитории для новых таблиц: - -- `TimeSlotRepository` -- `AcademicYearRepository` -- `SemesterRepository` -- `HolidayRepository` -- `AcademicCalendarMatrixRepository` -- `ScheduleRuleRepository` -- `ScheduleRuleSlotRepository` - -Добавлены сервисы: +Ключевые сервисы: | Сервис | Назначение | |--------|------------| -| `AcademicDateService` | Календарная математика: семестр по дате, номер недели, чётность, праздник, курс группы, тип активности | -| `ScheduleGeneratorService` | Генерация расписания группы или преподавателя на диапазон дат | +| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | +| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | -Старый ручной слой занятий удалён: в backend больше нет `LessonsController`, `ScheduleDataController`, сущностей `Lesson`/`ScheduleData`, их DTO, репозиториев и валидаторов старых строк расписания. +`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. -### API +## API -Добавлен новый endpoint просмотра расписания: +Просмотр расписания: ```http GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 ``` -Правило запроса: нужно передать ровно один идентификатор — `groupId` или `teacherId`. - -Ответ — массив `RenderedLessonDto`, где у каждого занятия есть: - -- полная дата занятия; -- номер дня недели; -- название дня; -- номер недели семестра; -- чётность; -- временной слот; -- дисциплина; -- преподаватель; -- аудитория; -- тип и формат занятия; -- группы; -- лимит часов правила; -- уже списанные часы перед занятием; -- остаток часов после занятия. - -Добавлены административные API: +Административные API: | API | Назначение | |-----|------------| @@ -116,196 +59,48 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 | `/api/admin/calendar/years` | CRUD учебных годов | | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | | `/api/admin/calendar/semesters/{id}` | Обновление семестра | -| `/api/admin/calendar/holidays` | CRUD праздников | -| `/api/admin/calendar/matrix` | Получение и массовое сохранение матрицы учебного графика | +| `/api/admin/calendar/activity-types` | CRUD кодов активностей | +| `/api/admin/academic-calendars` | CRUD календарных учебных графиков | +| `/api/admin/academic-calendars/{id}/grid` | Получение и сохранение дневной сетки | +| `/api/specialties/{id}/profiles` | CRUD профилей специальности | +| `/api/groups/{id}/calendar-assignments` | Назначения графиков группе | | `/api/admin/schedule-rules` | CRUD правил расписания | -### Авторизация +## Frontend -Ответ `POST /api/auth/login` расширен полем `userId`. +Админский экран `schedule` содержит: -Frontend сохраняет его в `localStorage`, чтобы кабинет преподавателя мог запрашивать личное расписание через: +- конструктор правил динамического расписания; +- управление временными слотами; +- учебные годы и семестры; +- CRUD календарных графиков; +- Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей. -```http -GET /api/schedule?teacherId={userId}&startDate=...&endDate=... -``` +Экран `groups` создаёт и редактирует группы с профилем обучения, а также назначает календарный график группе на учебный год. -### Frontend +Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей. -Заглушки кабинетов заменены на базовые экраны просмотра расписания. +## Проверки -Админский экран `schedule` показывает текущие `schedule_rules` и сетку `time_slots` через новые admin API. - -#### Кабинет студента - -Файл: `frontend/student/index.html`. - -Текущая модель пользователя-студента не содержит прямой связи с учебной группой, поэтому экран работает через выбор группы: - -1. Загружает список групп из `/api/groups`. -2. Сохраняет выбранную группу в `localStorage.studentGroupId`. -3. Строит недельный диапазон дат. -4. Запрашивает расписание через `GET /api/schedule?groupId=...`. -5. Показывает занятия по дням недели. - -#### Кабинет преподавателя - -Файл: `frontend/teacher/index.html`. - -Экран использует `localStorage.userId`, который появляется после нового ответа авторизации: - -1. Определяет текущую неделю. -2. Позволяет переключать неделю стрелками или через выбор даты. -3. Запрашивает расписание через `GET /api/schedule?teacherId=...`. -4. Показывает занятия преподавателя в недельной сетке. -5. В занятии отображаются дисциплина, время, тип, аудитория и группы. - -## Как работает генерация расписания - -Генерация выполняется на backend по запросу клиента. Фактические занятия не хранятся отдельными строками на каждую дату, а вычисляются из правил. - -### Расписание группы - -Алгоритм `buildScheduleForGroup(groupId, startDate, endDate)`: - -1. Проверяет корректность диапазона дат. -2. Находит группу. -3. Для каждой даты диапазона определяет семестр. -4. Вычисляет номер недели семестра. -5. Вычисляет текущий курс группы по `year_start_study`. -6. Проверяет матрицу учебного графика. -7. Пропускает дату, если это праздник или не `THEORY`. -8. Загружает правила группы для найденного семестра. -9. Для каждого правила считает, сколько академических часов уже проведено до текущей даты. -10. Если лимит `total_academic_hours` ещё не исчерпан, проецирует подходящие слоты правила на текущую дату. - -### Расписание преподавателя - -Алгоритм `buildScheduleForTeacher(teacherId, startDate, endDate)` похож, но правила ищутся не по группе, а по преподавателю в `schedule_rule_slots.teacher_id`. - -В ответе занятие дополнительно содержит все группы, привязанные к правилу. Это поддерживает потоковые лекции. - -### Чётность недели - -Чётность вычисляется от `semesters.start_date`. - -Настройка: - -```properties -app.schedule.even-week-is-upper=false -``` - -По умолчанию: - -- нечётная неделя семестра соответствует `ODD`; -- чётная неделя семестра соответствует `EVEN`. - -Если `app.schedule.even-week-is-upper=true`, соответствие инвертируется. - -### Праздники - -Праздник считается пропуском занятия: - -- занятие не отображается в расписании; -- академические часы не списываются; -- дисциплина фактически продолжается дольше, пока не будет исчерпан лимит часов. - -### Лимит часов - -Каждый `schedule_rules.total_academic_hours` задаёт общий лимит академических часов дисциплины. - -Генератор симулирует период от `active_from_date` до запрошенной даты и считает только реально проведённые слоты: - -- один слот = 2 академических часа; -- праздники не считаются; -- недели с активностью не `THEORY` не считаются; -- после достижения лимита правило перестаёт выводиться. - -## Поток данных - -Новая базовая БД сразу создаёт рабочую модель так: - -```text -V1__init.sql - -> time_slots - -> academic_years / semesters / holidays / academic_calendar_matrix - -> schedule_rules - -> schedule_rule_groups - -> schedule_rule_slots -``` - -Для просмотра: - -```text -frontend - -> GET /api/schedule - -> ScheduleController - -> ScheduleGeneratorService - -> schedule_rules + schedule_rule_slots + calendar tables - -> RenderedLessonDto[] -``` - -## Что проверено - -Проверка backend: - -```bash -docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile -``` - -Результат: компиляция backend прошла успешно. - -Проверка миграции: - -1. Поднят временный PostgreSQL-контейнер на образе `postgres:alpine3.23`. -2. Применён `V1__init.sql`. -3. Проверены контрольные количества строк новой модели. - -Контрольный результат: - -| Объект | Количество | -|--------|------------| -| `schedule_rules` | 4 | -| `schedule_rule_groups` | 5 | -| `schedule_rule_slots` | 4 | -| `time_slots` | 7 | -| `academic_calendar_matrix` | 132 | -| `lessons` | таблица отсутствует | -| `schedule_data` | таблица отсутствует | - -Также выполнен: +Доступные локальные проверки: ```bash +node --check frontend/admin/js/views/schedule.js +node --check frontend/admin/js/views/groups.js +node --check frontend/admin/js/views/departments-data.js git diff --check ``` -Результат: замечаний по whitespace нет. +Полная проверка требует окружения с Maven и доступом к Docker: -## Ограничения текущего среза +```bash +mvn -DskipTests compile +docker compose down -v +docker compose up -d --build +``` -Этот срез создаёт рабочее ядро динамического расписания, но ещё не закрывает весь продуктовый объём. +## Следующие этапы -Ограничения: - -- строгая проверка конфликтов преподавателей, аудиторий и групп при сохранении правил ещё не реализована; -- студент пока выбирает группу вручную, потому что в модели пользователя нет связи `student -> group`. - -## Следующий этап - -Рекомендуемый следующий этап: - -1. Реализовать backend-валидацию конфликтов: - - преподаватель не может вести разные занятия одновременно; - - аудитория не может быть занята двумя разными занятиями одновременно; - - группа не может быть на двух занятиях одновременно, кроме корректного деления по подгруппам. -2. Добавить модель связи студента с учебной группой, чтобы кабинет студента не требовал ручного выбора группы. -3. Добавить отдельное управление подгруппами и подключить выбор `subgroupId` в конструкторе правил. - -## Связанные документы - -- `SCHEDULE_PROPOSAL.md` — исходная концепция динамического расписания. -- `SCHEDULE_TASKS.md` — исходная декомпозиция задач. -- `docs/API.md` — описание REST API. -- `docs/DATABASE.md` — описание схемы БД. -- `docs/BUSINESS_LOGIC.md` — бизнес-правила. -- `docs/FRONTEND.md` — frontend-архитектура. +- Добавить интеграционные тесты генерации по кодам `Т`, `Э`, `К`, `*`, `=`. +- Добавить строгую backend-валидацию конфликтов преподавателей, аудиторий и групп при сохранении правил. +- Добавить связь пользователя-студента с учебной группой, чтобы кабинет студента не требовал ручного выбора группы. diff --git a/SCHEDULE_PROPOSAL.md b/SCHEDULE_PROPOSAL.md index 3b8c6e0..825b22a 100644 --- a/SCHEDULE_PROPOSAL.md +++ b/SCHEDULE_PROPOSAL.md @@ -1,178 +1,48 @@ # Концепция динамической генерации расписания -Данный документ представляет собой подробное архитектурное описание новой системы управления расписанием. Система переходит от статического хранения каждой отдельной пары к параметрическому: мы сохраняем **правила проведения** дисциплины и **календарную сетку**, а фактическое расписание на любую дату вычисляется «на лету» (генерируется). +Документ фиксирует актуальную концепцию после внедрения календарных учебных графиков. Подробная продуктовая модель описана в [`docs/ACADEMIC_CALENDAR.md`](docs/ACADEMIC_CALENDAR.md), REST API — в [`docs/API.md`](docs/API.md), схема БД — в [`docs/DATABASE.md`](docs/DATABASE.md). -> **Контекст миграции:** Новая система полностью заменяет существующие таблицы `lessons` (статическое расписание) и `schedule_data` (плановая нагрузка). Обе таблицы будут мигрированы в единую модель `schedule_rules` + `schedule_rule_slots`, которая совмещает хранение нагрузки (часы) и расписания (слоты) в одной структуре. +## Базовая идея ---- +Фактические занятия не хранятся отдельными строками на каждую дату. Система хранит: -## 1. Подробное описание компонентов системы +- календарный учебный график по специальности, профилю, форме обучения и учебному году; +- правила расписания дисциплин; +- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия и формат; +- назначение конкретного календарного графика учебной группе на учебный год. -Новая архитектура строится на строгом разделении данных на три логических слоя: Календарь (основа отсчета времени), Правила (шаблоны занятий) и Генератор (движок рендеринга фактического расписания). +При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются. -### 1.1 Справочная база времени (Календарный учебный график) -Чтобы система понимала, *когда* можно ставить пары, а когда нет, вводится понятие календарного графика. Он состоит из трёх взаимосвязанных сущностей: +## Слои модели -* **Академические периоды (Учебные года и Семестры).** Иерархия из двух уровней: - * **Учебный год** — контейнер с названием и датами (напр. «2024/2025», `01.09.2024` — `30.06.2025`). - * **Семестр** — дочерняя сущность учебного года. Содержит дату начала, от которой отсчитывается «Неделя 1» данного семестра. Нумерация недель начинается заново для каждого семестра. Тип семестра (`autumn` / `spring`) определяет, какой набор правил активен. - - Именно от даты начала семестра отсчитывается «Неделя 1». Конвенция чётности (верхняя = чётная или нечётная) **настраивается на уровне тенанта**, так как у разных университетов разные традиции. Это избавляет систему от уязвимостей, связанных с плавающими днями начала учёбы, високосными годами и смещениями дней недели. +### Календарный слой -* **Справочник исключений (Праздники и Выходные).** В этой таблице хранятся конкретные даты `YYYY-MM-DD`, когда университет юридически или физически закрыт (например, государственные праздники). Если по правилу пара должна быть в этот день, алгоритм будет знать, что его нужно пропустить без штрафов и ошибок. +- `academic_years` и `semesters` задают границы учебного года и семестров. +- `specialties` и `specialty_profiles` определяют образовательную программу группы. +- `academic_calendars` хранит график профиля и формы обучения. +- `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`. +- `academic_calendar_days` хранит дневную сетку по курсу и дате. +- `student_group_calendar_assignments` выбирает график для группы на учебный год. -* **Матрица учебного графика.** Это цифровая копия эксель-таблицы (`Курс + Специальность` → `Номер недели` → `Тип деятельности`). Привязка идёт к `course_number` + `specialty_id`, а **не** к конкретной группе, так как учебный график одинаков для всех групп одного курса одной специальности. Номер текущего курса группы вычисляется из поля `year_start_study` модели `StudentGroup` относительно текущей даты по формуле: `course = текущий_учебный_год - year_start_study + 1`. Типы деятельности включают `THEORY` (Теория, пары идут в штатном режиме), `EXAM` (Э — экзаменационная сессия), `VACATION` (К — каникулы), `PRACTICE` (У, П — практика). Если, например, у 3-го курса на 18-й неделе стоит статус `EXAM`, алгоритм даже не будет пытаться генерировать для них теоретические лекции, а отобразит блок «Экзаменационная сессия». +### Слой правил расписания -### 1.2 Справочник временных слотов (Time Slots) -Вместо хардкода фиксированных 7 пар, система хранит временные слоты в отдельной **настраиваемой таблице**. Каждый тенант (университет) может иметь собственное количество пар, их длительность и временные рамки. +- `time_slots` задаёт сетку пар для тенанта. +- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов. +- `schedule_rule_groups` связывает правило с одной или несколькими группами. +- `schedule_rule_slots` хранит шаблонные занятия правила. -Слот содержит: -* `order_number` — порядковый номер пары в дне (1, 2, 3...). -* `start_time` — время начала (напр. `08:00`). -* `end_time` — время окончания (напр. `09:30`). -* `duration_minutes` — длительность пары в минутах. +## Алгоритм генерации -Это позволяет каждому университету настраивать количество и продолжительность пар без модификации кода. +1. Для даты определяется семестр. +2. Для группы находится назначенный календарный учебный график. +3. По году начала обучения вычисляется текущий курс группы. +4. По курсу и дате находится код активности. +5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. +6. Для разрешённого дня выбираются активные правила и слоты. +7. Лимит часов считается только по реально проведённым занятиям. -### 1.3 Движок правил (Schedule Rules) -Старый подход подразумевал, что каждая пара в базе (каждая клеточка) — это изолированная запись `lessons` («понедельник, 1-я пара, математика»). Новая система вводит сущность сводного **Правила Дисциплины**. Одно правило описывает расписание целого курса по конкретному предмету для одной или нескольких студенческих групп (включая потоковые лекции). +Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. -**Базовые параметры (Лимиты Правила):** -* `subject_id` — ID преподаваемой дисциплины. -* `semester_id` — ID семестра, к которому привязано правило. Одна и та же дисциплина может читаться в разных семестрах с разными параметрами. -* `startDate` — Дата или номер недели семестра, с которой предмет начинает читаться (поскольку не все предметы идут строго с 1-й недели семестра). -* `totalHours` — Полный объём выделенных **академических часов** (1 ак. час = 45 минут; одна пара = 2 ак. часа). Это важнейший **лимитатор**, который обеспечивает автоматическую остановку генерации: как только заявленные часы будут вычитаны, предмет перестает отображаться в расписании студентов на последующих неделях. +## Административный сценарий -**Связь с группами (Many-to-Many):** -Одно правило может быть связано с несколькими группами через промежуточную таблицу `schedule_rule_groups`. Это обеспечивает поддержку **потоковых лекций** — когда один преподаватель читает лекцию нескольким группам одновременно в одной аудитории. При этом правило создаётся один раз, а группы к нему привязываются списком. - -**Массив паттернов (Слоты правила):** -Само «тело» правила разбивается на подчинённые слоты. Если предмет идёт в Пн и Ср, это будет 2 слота внутри одного Правила. Слот содержит: -* `dayOfWeek`: день недели (1–7, Пн–Вс). -* `parity`: тип четности — `ENUM('BOTH', 'EVEN', 'ODD')`. `BOTH` — каждую неделю, `EVEN` — по чётным (нижним) неделям, `ODD` — по нечётным (верхним). Конкретное соответствие «чётная = верхняя или нижняя» определяется настройкой тенанта. -* `time_slot_id`: FK на таблицу `time_slots` — порядковый номер и время пары. -* `subgroup_id`: FK на подгруппу (NULL = вся группа). *Это гарантирует, что мы сможем ставить разным подгруппам пересекающиеся занятия в разных аудиториях без алгоритмических конфликтов.* -* `teacher_id`: FK на преподавателя слота. -* `classroom_id`: FK на аудиторию слота. -* `lesson_type_id`: FK на тип занятия (`Лекция`, `Практическая работа`, `Лабораторная работа`). -* `lesson_format`: формат проведения (`Очно` / `Онлайн`). - -> **Обоснование:** Хранение `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` в **слотах**, а не в главном правиле, позволяет гибко описывать ситуации вроде: лекции в понедельник читает лектор Иванов (Аудитория 100), а лабораторные в среду ведёт практик Петров (Аудитория 102В) — в рамках одного правила по предмету «Программирование», расходуя общий `totalHours`. - -### 1.4 Генератор (Рендерер) расписания -Это слой бизнес-логики (служба `ScheduleGeneratorService` в Java), который работает исключительно в оперативной памяти бэкенда и производит расчёт расписания «on-demand» (по требованию) при запросе от клиента фронтенда. - -**Пошаговый алгоритм работы генератора:** -1. Фронтенд (Интерфейс пользователя) запрашивает: *«Дай мне расписание группы ИТ-21 на конкретный период, например, с 14 октября по 20 октября»*. -2. Генератор определяет семестр по запрошенным датам и вычисляет, что 14 октября соответствует, к примеру, 7-й неделе семестра (вычисление от `startDate` семестра). -3. Он сверяется с *Матрицей учебного графика*. Для этого генератор определяет текущий курс группы по формуле `текущий_учебный_год - year_start_study + 1` и находит `specialty_id` группы. Если у данного курса/специальности сейчас стоит `VACATION` (Каникулы) или `PRACTICE` (Практика), генератор сразу возвращает пустой ответ или ответ со статусом периода. -4. Если статус недели позволяет проводить занятия (`THEORY`), генератор поднимает из Базы Данных все активные **Правила** для запрошенной группы (через таблицу `schedule_rule_groups`), привязанные к текущему семестру. -5. **Механика Лимитатора часов:** Для каждого правила алгоритм «симулирует» прогон времени с даты старта правила до текущей запрошенной недели. Он подсчитывает количество успешно проведённых ак. часов (по 2 ак. часа за каждый отработанный слот), пропуская даты, попавшие в справочник праздников, и недели с типом деятельности отличным от `THEORY`. -6. Если у правила лимит `totalHours` достиг значения `0`, программа понимает, что курс вычитан, и предмет не отображается. Если часы ещё остались, алгоритм проецирует шаблоны (слоты правила) на запрошенную текущую неделю с учётом чётности, аудиторий и подгрупп, отдавая готовый JSON-массив в браузер пользователя. - -**Генерация расписания для преподавателя:** -Аналогричный алгоритм, но поиск правил идёт не по привязке к группе, а по `teacher_id` в слотах. Генератор собирает все `schedule_rule_slots`, где `teacher_id` = ID текущего преподавателя, получает родительские правила и рендерит расписание, обогащая каждую запись списком групп из `schedule_rule_groups`. - -**Кеширование:** -Для оптимизации производительности (т.к. симуляция прогона за весь семестр для каждого запроса ресурсоёмка) предусмотрен кеш: -* Список праздников текущего учебного года кешируется при первом обращении и инвалидируется при изменении таблицы `holidays`. -* Матрица учебного графика кешируется по ключу `(course, specialty_id, semester_id)`. -* Результаты подсчёта `consumed_hours` для каждого правила могут кешироваться с инвалидацией при изменении праздников или правил. - ---- - -## 2. Архитектурные Решения - -На основе обсуждений были задокументированы следующие концептуальные решения по архитектуре: - -1. **Реакция на праздники (Продление курса):** - Алгоритм воспринимает праздник как «пропуск хода», не отнимая проведённые часы от `totalHours`. Это означает, что пара **не переносится** на другой день или время — она просто пропускается без вычета часов. Фактически предмет будет отображаться в расписании дольше (больше недель), пока `totalHours` не будет полностью исчерпан. Преподаватель честно выработает положенный объём часов за счёт увеличения количества недель преподавания. - -2. **Нормализация через связанные таблицы:** - Мы не используем сырые массивы (`INTEGER[]`) или JSONB-колонки. Реализована структура со строгой нормализацией: - * Главная таблица: `schedule_rules` (хранит лимиты и дату старта). - * Подчинённая таблица: `schedule_rule_slots` (хранит конкретный день, чётность, номер пары, преподавателя, аудиторию, тип и формат — прикреплённые к ID главного правила через Foreign Key). - * Связующая таблица: `schedule_rule_groups` (Many-to-Many между правилом и группами). - Это позволяет базе данных строить сложные выборки в стиле «Покажи загруженность кабинета №21 во вторник на второй паре по чётным неделям», исключая тяжёлый парсинг JSON. - -3. **Поддержка подгрупп внутри слотов:** - В таблицу `schedule_rule_slots` введено поле `subgroup_id` (Id подгруппы, nullable). Алгоритм генератора сможет рендерить два предмета для одной группы одновременно и без конфликтов, если они ассоциированы с разными подгруппами одной материнской группы. - -4. **Обогащённые слоты (Вариант Б):** - `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` хранятся в каждой строке `schedule_rule_slots`, а не в главном правиле. Это позволяет описывать лекции и практики одного предмета в рамках одного правила, расходуя общий `totalHours`. - -5. **Потоковые лекции через Many-to-Many:** - Одно правило связывается с несколькими группами через `schedule_rule_groups`. Для потоковой лекции создаётся одно правило, к которому привязываются все участвующие группы. - -6. **Настраиваемость по тенантам:** - Архитектурно все тенанты одинаковы — каждый университет получает идентичную пустую базу данных. Временные слоты (количество, длительность, время начала/окончания пар), конвенция чётности и прочие параметры не требуют специального механизма: каждый университет просто заполняет свою БД самостоятельно через панель администратора. - ---- - -## 3. Подробный План Действий по Реализации - -Интеграция новой архитектуры затронет весь стек приложения (DB → Backend → API → Frontend). Работу предлагается вести строго поэтапно: - -### Этап 1. База Данных (Flyway Миграции) - -**Схема Временных слотов:** -* `time_slots` (id, order_number, start_time TIME, end_time TIME, duration_minutes INT). - * Заполняется администратором. Нет фиксированных значений — каждый тенант настраивает свою сетку пар. - -**Схема Календарного графика:** -* `academic_years` (id, title VARCHAR, start_date DATE, end_date DATE). -* `semesters` (id, academic_year_id FK, semester_type ENUM('autumn','spring'), start_date DATE, end_date DATE). - * Именно от `semesters.start_date` отсчитывается «Неделя 1». -* `holidays` (id, date DATE, academic_year_id FK, description VARCHAR). -* `academic_calendar_matrix` (id, semester_id FK, course_number INT, specialty_id FK, week_number INT, activity_type ENUM('THEORY','EXAM','VACATION','PRACTICE')). - * Привязка к `course_number` + `specialty_id`, а НЕ к конкретной группе. - -**Схема Движка Правил:** -* `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date DATE, total_academic_hours INT). - * `total_academic_hours` — в академических часах (1 ак. час = 45 мин, одна пара = 2 ак. часа). -* `schedule_rule_groups` (schedule_rule_id FK, group_id FK) — PK составной. - * Связующая таблица для потоковых лекций. -* `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week INT CHECK(1–7), parity ENUM('BOTH','EVEN','ODD'), time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format VARCHAR). - -**Скрипт Миграции (Data ETL):** Написание SQL/Java скрипта для миграции данных из двух источников: -1. **Из `schedule_data`** → `schedule_rules` + `schedule_rule_groups`: перенос плановой нагрузки (`number_of_hours` → `total_academic_hours`, `group_id`, `subjects_id`, `teacher_id`, `lesson_type_id`, `is_division`, `semester_type`, `period`). -2. **Из `lessons`** → `schedule_rule_slots`: перенос расписания с трансформацией данных: - * `day` (строка «Понедельник»...«Суббота») → `day_of_week` (INT 1–6). - * `time` (строка «8:00 - 9:30») → `time_slot_id` (FK на `time_slots`). - * `week` (строка «Верхняя»/«Нижняя»/«Обе») → `parity` (ENUM `ODD`/`EVEN`/`BOTH`). - * Группировка записей с одинаковым `(subject_id, group_id)` в одно правило. - -После успешной миграции и верификации данных — удаление таблиц `lessons` и `schedule_data`. - -### Этап 2. Бэкенд и Вычислительное Ядро (Java + Spring Boot) -* `AcademicDateService.java` — сервис утилит для календарной математики: - * Перевод дат в номер недели семестра. - * Определение чётности недели с учётом настройки тенанта. - * Проверка попадания дня в справочник `holidays`. - * Вычисление текущего курса группы: `текущий_учебный_год - year_start_study + 1`. -* `ScheduleRuleRepository.java` — JPA репозитории для извлечения графа правил из базы данных, с оптимизацией N+1 проблемы через `JOIN FETCH` со слотами и группами. -* `ScheduleGeneratorService.java` — Сердце системы. Основные методы: - * `List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate)` — расписание группы. - * `List buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate)` — расписание преподавателя (поиск по `teacher_id` в слотах, обогащение информацией о группах). - * Реализует всю бизнес-логику из пункта 1.4 (подсчёт вычитанных часов, пропуск праздников, кеширование). -* Адаптация валидаторов пересечения аудиторий: теперь валидатор должен работать не на уровне «каждой пары», а симулировать весь семестр на этапе сохранения нового Правила в панели администратора. - -### Этап 3. Обновление REST API (Контроллеры) -* **Новый эндпоинт расписания:** `GET /api/schedule` переходит на диапазонную модель. Параметры: `?groupId=123&startDate=2024-10-14&endDate=2024-10-20` или `?teacherId=456&startDate=...&endDate=...`. Ответ — массив объектов с полными датами `YYYY-MM-DD`. -* **Обратная совместимость:** Старый эндпоинт `GET /api/users/lessons` будет помечен как `@Deprecated` и продолжит работать до полной миграции фронтенда. После завершения миграции фронтенда — удаление. -* **CRUD-контроллеры для админки:** - * `/api/admin/time-slots` (настройка сетки временных слотов). - * `/api/admin/calendar/years` (учебные годы и семестры). - * `/api/admin/calendar/matrix` (настройка каникул и сессий по курсам/специальностям/неделям). - * `/api/admin/calendar/holidays` (добавление исключений). - * `/api/admin/schedule-rules` (управление жизненным циклом Правил, их слотами и привязкой к группам). - -### Этап 4. Интерфейсы Frontend (Vanilla JS + HTML) -* **Страницы просмотра (Студенты и Преподаватели):** - * Реализация переключателя календарных дат (Date Picker или кнопки-перелистывания недель). - * Логика, которая при свайпе или клике запрашивает у API конкретный диапазон дат и перерисовывает DOM-дерево. - * Для преподавателей — отображение всех групп, привязанных к каждому занятию. -* **Панель Администратора (SPA-интерфейсы):** - * **Вкладка «Временные слоты»:** Настройка сетки пар — количество, время начала/окончания, длительность. - * **Вкладка «Учебный график»:** Визуальная сетка-матрица (недели по горизонтали, Курсы/Специальности по вертикали), где админ может закрашивать пересечения разными цветами, назначая статусы (Практика, Каникулы, Теория, Экзамены). - * **Вкладка «Конструктор Правил»:** Глобально новый визуальный инструмент расписания. Админ выбирает Группы (одну или несколько для потока) и Дисциплину, задаёт `totalHours` в академических часах, а затем динамически добавляет строчки массива слотов через кнопку «Добавить занятие» со списками (Selects) для Дня Недели, Временного слота, Подгруппы, Чётности, Преподавателя, Аудитории и Типа занятия. +Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу. diff --git a/SCHEDULE_TASKS.md b/SCHEDULE_TASKS.md index 30c600d..aec40d0 100644 --- a/SCHEDULE_TASKS.md +++ b/SCHEDULE_TASKS.md @@ -1,218 +1,32 @@ -# 📋 Задачи: Динамическая генерация расписания +# Задачи: динамическое расписание и календарный учебный график -> Декомпозиция [`SCHEDULE_PROPOSAL.md`](SCHEDULE_PROPOSAL.md) на подзадачи для доски планирования. -> Категории: **Backend**, **Frontend**, **DevOps/DB** +Актуальная декомпозиция относится к модели, где календарный учебный график хранится дневной сеткой и назначается группе на учебный год. ---- +## Выполнено -## DevOps / Database +- [x] Переписана базовая схема `V1__init.sql` под полный сброс БД. +- [x] Добавлены специальности, профили обучения и связь группы с профилем. +- [x] Добавлены формы обучения календарного графика. +- [x] Добавлен справочник кодов активностей Excel-графика. +- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам. +- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. +- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. +- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. +- [x] Обновлены админские экраны специальностей, групп и расписания. +- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. -### Flyway-миграция: Временные слоты -- [ ] Создать миграцию: таблица `time_slots` (id, order_number, start_time, end_time, duration_minutes) -- [ ] Добавить CHECK-ограничения (start_time < end_time, duration_minutes > 0, order_number > 0) +## Оставшиеся задачи ---- +- [ ] Добавить интеграционные тесты генерации расписания для кодов `Т`, `Э`, `К`, `*`, `=`. +- [ ] Добавить проверку конфликтов преподавателей, аудиторий и групп при сохранении правил. +- [ ] Добавить модель связи пользователя-студента с учебной группой. +- [ ] Добавить импорт календарного графика из Excel, если он понадобится после ручного редактора. -### Flyway-миграция: Учебные годы и семестры -- [ ] Создать миграцию: таблица `academic_years` (id, title, start_date, end_date) -- [ ] Создать миграцию: таблица `semesters` (id, academic_year_id FK, semester_type ENUM, start_date, end_date) -- [ ] Добавить CHECK-ограничения и индексы +## Проверочный сценарий ---- - -### Flyway-миграция: Праздники -- [ ] Создать миграцию: таблица `holidays` (id, date, academic_year_id FK, description) -- [ ] Добавить уникальный индекс на (date, academic_year_id) - ---- - -### Flyway-миграция: Матрица учебного графика -- [ ] Создать миграцию: таблица `academic_calendar_matrix` (id, semester_id FK, course_number, specialty_id FK, week_number, activity_type ENUM) -- [ ] Добавить ENUM: `THEORY`, `EXAM`, `VACATION`, `PRACTICE` -- [ ] Добавить уникальный индекс на (semester_id, course_number, specialty_id, week_number) - ---- - -### Flyway-миграция: Правила расписания -- [ ] Создать миграцию: таблица `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date, total_academic_hours) -- [ ] Создать миграцию: связующая таблица `schedule_rule_groups` (schedule_rule_id FK, group_id FK, PK составной) -- [ ] Создать миграцию: таблица `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week, parity ENUM, time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format) -- [ ] Добавить CHECK на day_of_week (1–7) -- [ ] Добавить ENUM: `BOTH`, `EVEN`, `ODD` - ---- - -### ETL-миграция данных -- [ ] Написать SQL/Java скрипт миграции `schedule_data` → `schedule_rules` + `schedule_rule_groups` - - [ ] Маппинг `number_of_hours` → `total_academic_hours` - - [ ] Маппинг привязок групп -- [ ] Написать SQL/Java скрипт миграции `lessons` → `schedule_rule_slots` - - [ ] Трансформация `day` (строка) → `day_of_week` (INT 1–6) - - [ ] Трансформация `time` (строка) → `time_slot_id` (FK) - - [ ] Трансформация `week` (строка) → `parity` (ENUM) - - [ ] Группировка записей с одинаковым (subject_id, group_id) в одно правило -- [ ] Верификация мигрированных данных (количество записей, целостность FK) -- [ ] Создать миграцию на удаление устаревших таблиц `lessons` и `schedule_data` (после верификации) - ---- - -## Backend (Java + Spring Boot) - -### JPA-сущности (Model) -- [ ] Создать Entity: `TimeSlot` -- [ ] Создать Entity: `AcademicYear` -- [ ] Создать Entity: `Semester` (связь ManyToOne → AcademicYear) -- [ ] Создать Entity: `Holiday` (связь ManyToOne → AcademicYear) -- [ ] Создать Entity: `AcademicCalendarMatrix` (связи на Semester, Specialty) -- [ ] Создать Entity: `ScheduleRule` (связи на Subject, Semester) -- [ ] Создать Entity: `ScheduleRuleSlot` (связи на ScheduleRule, TimeSlot, Teacher, Classroom, LessonType) -- [ ] Настроить ManyToMany-связь ScheduleRule ↔ StudentGroup через `schedule_rule_groups` - ---- - -### DTO -- [ ] Создать DTO: `TimeSlotDto` -- [ ] Создать DTO: `AcademicYearDto`, `SemesterDto` -- [ ] Создать DTO: `HolidayDto` -- [ ] Создать DTO: `AcademicCalendarMatrixDto` -- [ ] Создать DTO: `ScheduleRuleDto`, `ScheduleRuleSlotDto` -- [ ] Создать DTO: `RenderedLessonDto` (ответ генератора расписания) - ---- - -### Repository -- [ ] Создать `TimeSlotRepository` -- [ ] Создать `AcademicYearRepository` -- [ ] Создать `SemesterRepository` (метод findByDateRange) -- [ ] Создать `HolidayRepository` (метод findByAcademicYearId) -- [ ] Создать `AcademicCalendarMatrixRepository` (метод findBySemesterAndCourseAndSpecialty) -- [ ] Создать `ScheduleRuleRepository` с JOIN FETCH (решение N+1 проблемы) - - [ ] Метод: findByGroupIdAndSemesterId (через schedule_rule_groups) - - [ ] Метод: findByTeacherIdAndSemesterId (через schedule_rule_slots.teacher_id) - ---- - -### Сервис: AcademicDateService -- [ ] Метод: перевод произвольной даты → номер недели семестра -- [ ] Метод: определение чётности недели с учётом настройки тенанта -- [ ] Метод: проверка попадания даты в справочник `holidays` -- [ ] Метод: вычисление текущего курса группы (`текущий_учебный_год - year_start_study + 1`) -- [ ] Метод: определение семестра по дате -- [ ] Написать юнит-тесты для AcademicDateService - ---- - -### Сервис: ScheduleGeneratorService -- [ ] Метод: `buildScheduleForGroup(groupId, startDate, endDate)` — расписание группы - - [ ] Определение семестра по диапазону дат - - [ ] Вычисление номера недели и курса группы - - [ ] Проверка типа деятельности через матрицу графика - - [ ] Загрузка активных правил для группы - - [ ] Симуляция прогона часов (подсчёт consumed_hours) - - [ ] Пропуск праздников при подсчёте часов - - [ ] Проекция слотов на запрошенную неделю с учётом чётности и подгрупп -- [ ] Метод: `buildScheduleForTeacher(teacherId, startDate, endDate)` — расписание преподавателя - - [ ] Поиск правил по teacher_id в слотах - - [ ] Обогащение ответа списком групп из schedule_rule_groups -- [ ] Написать юнит-тесты для ScheduleGeneratorService -- [ ] Написать интеграционные тесты (полный цикл с тестовой БД) - ---- - -### Кеширование -- [ ] Реализовать кеш списка праздников по учебному году -- [ ] Реализовать кеш матрицы учебного графика по ключу (course, specialty_id, semester_id) -- [ ] Реализовать кеш consumed_hours для каждого правила -- [ ] Реализовать инвалидацию кеша праздников при CRUD-операциях с holidays -- [ ] Реализовать инвалидацию кеша consumed_hours при изменении правил или праздников - ---- - -### Валидация -- [ ] Адаптировать валидатор пересечения аудиторий (симуляция всего семестра при сохранении правила) -- [ ] Валидация пересечения преподавателей (один преподаватель не может вести две пары одновременно) -- [ ] Валидация пересечения групп (одна группа не может быть на двух занятиях одновременно, кроме подгрупп) - ---- - -### REST API: Контроллеры -- [ ] `GET /api/schedule` — Новый эндпоинт расписания (параметры: groupId/teacherId + startDate + endDate) -- [ ] Пометить `GET /api/users/lessons` как `@Deprecated` (обратная совместимость) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/time-slots` -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/years` -- [ ] CRUD: `GET/PUT /api/admin/calendar/semesters` (вложены в years) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/holidays` -- [ ] CRUD: `GET/PUT /api/admin/calendar/matrix` (массовое сохранение матрицы) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/schedule-rules` - - [ ] Включая вложенные слоты и привязку групп -- [ ] Написать интеграционные тесты для API - ---- - -### Удаление устаревшего кода -- [ ] Удалить/рефакторить старый `LessonsController` (после миграции фронтенда) -- [ ] Удалить/рефакторить старый `ScheduleDataController` -- [ ] Удалить старые Entity: `Lesson`, `ScheduleData` -- [ ] Удалить старые Repository и Service для lessons/schedule_data - ---- - -## Frontend (Vanilla JS + HTML/CSS) - -### Просмотр расписания: Студенты -- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) -- [ ] Переключить API-запросы на новый `GET /api/schedule?groupId=...&startDate=...&endDate=...` -- [ ] Рендеринг расписания по дням и временным слотам -- [ ] Отображение статуса периода (Каникулы / Практика / Экзамены), если неделя не учебная -- [ ] Отображение информации о подгруппах (два занятия рядом для разных подгрупп) - ---- - -### Просмотр расписания: Преподаватели -- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) -- [ ] Переключить API-запросы на новый `GET /api/schedule?teacherId=...&startDate=...&endDate=...` -- [ ] Отображение всех групп, привязанных к каждому занятию -- [ ] Отображение подгрупп, если преподаватель ведёт у подгруппы - ---- - -### Панель администратора: Вкладка «Временные слоты» -- [ ] Создать UI-страницу настройки временных слотов -- [ ] CRUD-интерфейс: добавление/редактирование/удаление пар -- [ ] Отображение таблицы: номер пары → время начала → время окончания → длительность -- [ ] Валидация на фронтенде (пересечение времён, корректность данных) - ---- - -### Панель администратора: Вкладка «Учебный график» -- [ ] Создать UI: выбор учебного года и семестра -- [ ] Создать UI: CRUD учебных годов и семестров -- [ ] Создать UI: CRUD праздников (список дат с описанием) -- [ ] Создать визуальную сетку-матрицу: - - [ ] Горизонтальная ось — номера недель - - [ ] Вертикальная ось — Курс + Специальность - - [ ] Цветовая кодировка ячеек: Теория/Экзамены/Каникулы/Практика - - [ ] Клик/драг для массового назначения статуса -- [ ] Сохранение матрицы через API `PUT /api/admin/calendar/matrix` - ---- - -### Панель администратора: Вкладка «Конструктор Правил» -- [ ] Создать UI: список существующих правил с фильтрацией (по группе, предмету, семестру) -- [ ] Форма создания/редактирования правила: - - [ ] Мультиселект групп (для потоковых лекций) - - [ ] Выбор дисциплины (subject) - - [ ] Выбор семестра - - [ ] Ввод totalHours (академические часы) - - [ ] Ввод даты начала (active_from_date) -- [ ] Динамический массив слотов (кнопка «Добавить занятие»): - - [ ] Select: День недели - - [ ] Select: Временной слот (из таблицы time_slots) - - [ ] Select: Чётность (Обе/Чётная/Нечётная) - - [ ] Select: Подгруппа (опционально) - - [ ] Select: Преподаватель - - [ ] Select: Аудитория - - [ ] Select: Тип занятия (Лекция/Практика/Лаба) - - [ ] Select: Формат (Очно/Онлайн) -- [ ] Визуальное предупреждение при конфликтах (аудитория/преподаватель уже заняты) -- [ ] Удаление правила с подтверждением +1. Полностью сбросить локальную БД. +2. Запустить приложение и применить `V1__init.sql`. +3. Создать профиль специальности. +4. Создать календарный график и заполнить дневную сетку. +5. Назначить график группе. +6. Проверить `GET /api/schedule` на дне теории и на днях, где пары запрещены. 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 d3c1214..8d15db0 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java @@ -5,7 +5,6 @@ import com.magistr.app.model.*; import com.magistr.app.repository.*; import com.magistr.app.service.ScheduleGeneratorService; import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.util.Comparator; @@ -18,19 +17,19 @@ public class AcademicCalendarAdminController { private final AcademicYearRepository academicYearRepository; private final SemesterRepository semesterRepository; - private final HolidayRepository holidayRepository; - private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final AcademicCalendarActivityTypeRepository activityTypeRepository; + private final AcademicCalendarDayRepository calendarDayRepository; private final ScheduleGeneratorService scheduleGeneratorService; public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, SemesterRepository semesterRepository, - HolidayRepository holidayRepository, - AcademicCalendarMatrixRepository calendarMatrixRepository, + AcademicCalendarActivityTypeRepository activityTypeRepository, + AcademicCalendarDayRepository calendarDayRepository, ScheduleGeneratorService scheduleGeneratorService) { this.academicYearRepository = academicYearRepository; this.semesterRepository = semesterRepository; - this.holidayRepository = holidayRepository; - this.calendarMatrixRepository = calendarMatrixRepository; + this.activityTypeRepository = activityTypeRepository; + this.calendarDayRepository = calendarDayRepository; this.scheduleGeneratorService = scheduleGeneratorService; } @@ -128,113 +127,60 @@ public class AcademicCalendarAdminController { return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); } - @GetMapping("/holidays") - public ResponseEntity getHolidays(@RequestParam Long academicYearId) { - if (!academicYearRepository.existsById(academicYearId)) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); - } - return ResponseEntity.ok(holidayRepository.findByAcademicYearIdOrderByDateAsc(academicYearId).stream() - .map(this::toHolidayDto) - .toList()); + @GetMapping("/activity-types") + public List getActivityTypes() { + return activityTypeRepository.findAllByOrderByDisplayOrderAscCodeAsc().stream() + .map(this::toActivityTypeDto) + .toList(); } - @PostMapping("/holidays") - public ResponseEntity createHoliday(@RequestBody HolidayDto request) { - AcademicYear year = academicYearRepository.findById(request.academicYearId()).orElse(null); - if (year == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + @PostMapping("/activity-types") + public ResponseEntity createActivityType(@RequestBody AcademicCalendarActivityTypeDto request) { + String validationError = validateActivityType(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); } - if (request.date() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна")); + if (activityTypeRepository.findByCode(request.code().trim()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует")); } - if (holidayRepository.existsByAcademicYearIdAndDate(year.getId(), request.date())) { - return ResponseEntity.badRequest().body(Map.of("message", "Эта дата уже добавлена в исключения")); - } - - Holiday holiday = new Holiday(); - holiday.setAcademicYear(year); - holiday.setDate(request.date()); - holiday.setDescription(request.description()); + AcademicCalendarActivityType activityType = new AcademicCalendarActivityType(); + applyActivityType(activityType, request); scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType))); } - @PutMapping("/holidays/{id}") - public ResponseEntity updateHoliday(@PathVariable Long id, @RequestBody HolidayDto request) { - Holiday holiday = holidayRepository.findById(id).orElse(null); - if (holiday == null) { + @PutMapping("/activity-types/{id}") + public ResponseEntity updateActivityType(@PathVariable Long id, + @RequestBody AcademicCalendarActivityTypeDto request) { + AcademicCalendarActivityType activityType = activityTypeRepository.findById(id).orElse(null); + if (activityType == null) { return ResponseEntity.notFound().build(); } - if (request.date() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна")); + String validationError = validateActivityType(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); } - holiday.setDate(request.date()); - holiday.setDescription(request.description()); + if (activityTypeRepository.findByCode(request.code().trim()) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует")); + } + applyActivityType(activityType, request); scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType))); } - @DeleteMapping("/holidays/{id}") - public ResponseEntity deleteHoliday(@PathVariable Long id) { - if (!holidayRepository.existsById(id)) { + @DeleteMapping("/activity-types/{id}") + public ResponseEntity deleteActivityType(@PathVariable Long id) { + if (!activityTypeRepository.existsById(id)) { return ResponseEntity.notFound().build(); } - holidayRepository.deleteById(id); + if (calendarDayRepository.existsByActivityTypeId(id)) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках")); + } + activityTypeRepository.deleteById(id); scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(Map.of("message", "Праздник удалён")); - } - - @GetMapping("/matrix") - public ResponseEntity getMatrix(@RequestParam Long semesterId, - @RequestParam Integer courseNumber, - @RequestParam Long specialtyId) { - if (!semesterRepository.existsById(semesterId)) { - return ResponseEntity.badRequest().body(Map.of("message", "Семестр не найден")); - } - return ResponseEntity.ok(calendarMatrixRepository - .findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc(semesterId, courseNumber, specialtyId) - .stream() - .map(this::toMatrixDto) - .toList()); - } - - @PutMapping("/matrix") - @Transactional - public ResponseEntity saveMatrix(@RequestBody List rows) { - if (rows == null || rows.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну строку матрицы")); - } - - try { - for (AcademicCalendarMatrixDto row : rows) { - Semester semester = semesterRepository.findById(row.semesterId()) - .orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); - if (row.courseNumber() == null || row.courseNumber() <= 0 - || row.specialtyId() == null - || row.weekNumber() == null || row.weekNumber() <= 0) { - throw new IllegalArgumentException("Курс, специальность и номер недели обязательны"); - } - AcademicCalendarMatrix matrix = calendarMatrixRepository - .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( - row.semesterId(), - row.courseNumber(), - row.specialtyId(), - row.weekNumber() - ) - .orElseGet(AcademicCalendarMatrix::new); - matrix.setSemester(semester); - matrix.setCourseNumber(row.courseNumber()); - matrix.setSpecialtyId(row.specialtyId()); - matrix.setWeekNumber(row.weekNumber()); - matrix.setActivityType(row.activityType() == null ? AcademicActivityType.THEORY : row.activityType()); - calendarMatrixRepository.save(matrix); - } - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); - } - - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(Map.of("message", "Матрица учебного графика сохранена")); + return ResponseEntity.ok(Map.of("message", "Код активности удалён")); } private String validateYear(AcademicYearDto request) { @@ -263,6 +209,19 @@ public class AcademicCalendarAdminController { return null; } + private String validateActivityType(AcademicCalendarActivityTypeDto request) { + if (request == null) { + return "Передайте код активности"; + } + if (request.code() == null || request.code().isBlank()) { + return "Код активности обязателен"; + } + if (request.name() == null || request.name().isBlank()) { + return "Название активности обязательно"; + } + return null; + } + private void applyYear(AcademicYear year, AcademicYearDto request) { year.setTitle(request.title().trim()); year.setStartDate(request.startDate()); @@ -275,6 +234,19 @@ public class AcademicCalendarAdminController { semester.setEndDate(request.endDate()); } + private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) { + activityType.setCode(request.code().trim()); + activityType.setName(request.name().trim()); + activityType.setAllowSchedule(Boolean.TRUE.equals(request.allowSchedule())); + activityType.setColorCode(request.colorCode() == null || request.colorCode().isBlank() + ? "#64748b" + : request.colorCode().trim()); + activityType.setDisplayOrder(request.displayOrder() == null ? 100 : request.displayOrder()); + activityType.setDescription(request.description() == null || request.description().isBlank() + ? null + : request.description().trim()); + } + private AcademicYearDto toAcademicYearDto(AcademicYear year) { List semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId()) .stream() @@ -294,24 +266,15 @@ public class AcademicCalendarAdminController { ); } - private HolidayDto toHolidayDto(Holiday holiday) { - return new HolidayDto( - holiday.getId(), - holiday.getDate(), - holiday.getAcademicYear().getId(), - holiday.getAcademicYear().getTitle(), - holiday.getDescription() - ); - } - - private AcademicCalendarMatrixDto toMatrixDto(AcademicCalendarMatrix matrix) { - return new AcademicCalendarMatrixDto( - matrix.getId(), - matrix.getSemester().getId(), - matrix.getCourseNumber(), - matrix.getSpecialtyId(), - matrix.getWeekNumber(), - matrix.getActivityType() + private AcademicCalendarActivityTypeDto toActivityTypeDto(AcademicCalendarActivityType activityType) { + return new AcademicCalendarActivityTypeDto( + activityType.getId(), + activityType.getCode(), + activityType.getName(), + activityType.getAllowSchedule(), + activityType.getColorCode(), + activityType.getDisplayOrder(), + activityType.getDescription() ); } } diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java new file mode 100644 index 0000000..37941e8 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -0,0 +1,255 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.*; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/academic-calendars") +public class AcademicCalendarController { + + private final AcademicCalendarRepository calendarRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarActivityTypeRepository activityTypeRepository; + private final AcademicYearRepository academicYearRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository profileRepository; + private final CalendarStudyFormRepository studyFormRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicCalendarController(AcademicCalendarRepository calendarRepository, + AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarActivityTypeRepository activityTypeRepository, + AcademicYearRepository academicYearRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository profileRepository, + CalendarStudyFormRepository studyFormRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.calendarRepository = calendarRepository; + this.calendarDayRepository = calendarDayRepository; + this.activityTypeRepository = activityTypeRepository; + this.academicYearRepository = academicYearRepository; + this.specialtiesRepository = specialtiesRepository; + this.profileRepository = profileRepository; + this.studyFormRepository = studyFormRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getCalendars(@RequestParam(required = false) Long academicYearId, + @RequestParam(required = false) Long specialtyId, + @RequestParam(required = false) Long profileId) { + return calendarRepository.findAllWithDetails(academicYearId, specialtyId, profileId).stream() + .map(this::toCalendarDto) + .toList(); + } + + @GetMapping("/study-forms") + public List getStudyForms() { + return studyFormRepository.findAllByOrderByNameAsc().stream() + .map(form -> new CalendarStudyFormDto(form.getId(), form.getName(), form.getDescription())) + .toList(); + } + + @GetMapping("/{id}") + public ResponseEntity getCalendar(@PathVariable Long id) { + return calendarRepository.findByIdWithDetails(id) + .>map(calendar -> ResponseEntity.ok(toCalendarDto(calendar))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createCalendar(@RequestBody AcademicCalendarDto request) { + try { + AcademicCalendar calendar = new AcademicCalendar(); + applyCalendar(calendar, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @PutMapping("/{id}") + public ResponseEntity updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) { + AcademicCalendar calendar = calendarRepository.findById(id).orElse(null); + if (calendar == null) { + return ResponseEntity.notFound().build(); + } + try { + applyCalendar(calendar, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCalendar(@PathVariable Long id) { + if (!calendarRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + try { + calendarRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Календарный график удалён")); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Нельзя удалить график, который назначен учебным группам")); + } + } + + @GetMapping("/{id}/grid") + public ResponseEntity getGrid(@PathVariable Long id) { + if (!calendarRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream() + .map(this::toGridDayDto) + .toList()); + } + + @PutMapping("/{id}/grid") + @Transactional + public ResponseEntity saveGrid(@PathVariable Long id, @RequestBody List rows) { + AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null); + if (calendar == null) { + return ResponseEntity.notFound().build(); + } + if (rows == null || rows.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика")); + } + + try { + calendarDayRepository.deleteByCalendarId(id); + for (AcademicCalendarGridDayDto row : rows) { + AcademicCalendarDay day = buildGridDay(calendar, row); + calendarDayRepository.save(day); + } + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) { + if (request == null) { + throw new IllegalArgumentException("Передайте данные календарного графика"); + } + if (request.title() == null || request.title().isBlank()) { + throw new IllegalArgumentException("Название графика обязательно"); + } + if (request.courseCount() == null || request.courseCount() <= 0) { + throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); + } + if (request.academicYearId() == null || request.specialtyId() == null + || request.specialtyProfileId() == null || request.studyFormId() == null) { + throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны"); + } + + AcademicYear year = academicYearRepository.findById(request.academicYearId()) + .orElseThrow(() -> new IllegalArgumentException("Учебный год не найден")); + Speciality speciality = specialtiesRepository.findById(request.specialtyId()) + .orElseThrow(() -> new IllegalArgumentException("Специальность не найдена")); + SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId()) + .orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден")); + if (!profile.getSpeciality().getId().equals(speciality.getId())) { + throw new IllegalArgumentException("Профиль не относится к выбранной специальности"); + } + CalendarStudyForm studyForm = studyFormRepository.findById(request.studyFormId()) + .orElseThrow(() -> new IllegalArgumentException("Форма обучения графика не найдена")); + + calendar.setTitle(request.title().trim()); + calendar.setAcademicYear(year); + calendar.setSpeciality(speciality); + calendar.setSpecialtyProfile(profile); + calendar.setStudyForm(studyForm); + calendar.setCourseCount(request.courseCount()); + } + + private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) { + if (row.courseNumber() == null || row.courseNumber() <= 0 + || row.date() == null + || row.weekNumber() == null || row.weekNumber() <= 0 + || row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) { + throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны"); + } + if (row.date().isBefore(calendar.getAcademicYear().getStartDate()) + || row.date().isAfter(calendar.getAcademicYear().getEndDate())) { + throw new IllegalArgumentException("Дата выходит за пределы учебного года"); + } + if (row.courseNumber() > calendar.getCourseCount()) { + throw new IllegalArgumentException("Номер курса выходит за пределы графика"); + } + + AcademicCalendarActivityType activityType = resolveActivityType(row); + AcademicCalendarDay day = new AcademicCalendarDay(); + day.setAcademicCalendar(calendar); + day.setCourseNumber(row.courseNumber()); + day.setDate(row.date()); + day.setWeekNumber(row.weekNumber()); + day.setDayOfWeek(row.dayOfWeek()); + day.setActivityType(activityType); + return day; + } + + private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) { + if (row.activityTypeId() != null) { + return activityTypeRepository.findById(row.activityTypeId()) + .orElseThrow(() -> new IllegalArgumentException("Код активности не найден")); + } + if (row.activityCode() != null && !row.activityCode().isBlank()) { + return activityTypeRepository.findByCode(row.activityCode().trim()) + .orElseThrow(() -> new IllegalArgumentException("Код активности не найден")); + } + throw new IllegalArgumentException("Код активности обязателен"); + } + + private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) { + AcademicYear year = calendar.getAcademicYear(); + Speciality speciality = calendar.getSpeciality(); + SpecialtyProfile profile = calendar.getSpecialtyProfile(); + CalendarStudyForm studyForm = calendar.getStudyForm(); + return new AcademicCalendarDto( + calendar.getId(), + calendar.getTitle(), + year.getId(), + year.getTitle(), + year.getStartDate(), + year.getEndDate(), + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getId(), + profile.getName(), + studyForm.getId(), + studyForm.getName(), + calendar.getCourseCount() + ); + } + + private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) { + AcademicCalendarActivityType activityType = day.getActivityType(); + return new AcademicCalendarGridDayDto( + day.getId(), + day.getAcademicCalendar().getId(), + day.getCourseNumber(), + day.getDate(), + day.getWeekNumber(), + day.getDayOfWeek(), + activityType.getId(), + activityType.getCode(), + activityType.getName(), + activityType.getAllowSchedule() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 9fd2578..fdb179e 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,17 @@ package com.magistr.app.controller; 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.Speciality; +import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.StudentGroup; -import com.magistr.app.repository.EducationFormRepository; -import com.magistr.app.repository.GroupRepository; +import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.utils.CourseAndSemesterCalculator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,7 +19,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.Year; import java.util.List; import java.util.Map; import java.util.Optional; @@ -26,11 +31,29 @@ public class GroupController { private final GroupRepository groupRepository; private final EducationFormRepository educationFormRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository specialtyProfileRepository; + private final AcademicYearRepository academicYearRepository; + private final AcademicCalendarRepository academicCalendarRepository; + private final StudentGroupCalendarAssignmentRepository assignmentRepository; + private final ScheduleGeneratorService scheduleGeneratorService; public GroupController(GroupRepository groupRepository, - EducationFormRepository educationFormRepository) { + EducationFormRepository educationFormRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository specialtyProfileRepository, + AcademicYearRepository academicYearRepository, + AcademicCalendarRepository academicCalendarRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, + ScheduleGeneratorService scheduleGeneratorService) { this.groupRepository = groupRepository; this.educationFormRepository = educationFormRepository; + this.specialtiesRepository = specialtiesRepository; + this.specialtyProfileRepository = specialtyProfileRepository; + this.academicYearRepository = academicYearRepository; + this.academicCalendarRepository = academicCalendarRepository; + this.assignmentRepository = assignmentRepository; + this.scheduleGeneratorService = scheduleGeneratorService; } @GetMapping @@ -41,21 +64,7 @@ public class GroupController { List groups = groupRepository.findAll(); List response = groups.stream() - .map(g -> { - int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); - int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); - return new GroupResponse( - g.getId(), - g.getName(), - g.getGroupSize(), - g.getEducationForm().getId(), - g.getEducationForm().getName(), - g.getDepartmentId(), - course, - semester, - g.getSpecialityCode() - ); - }) + .map(this::mapToResponse) .toList(); logger.info("Получено {} групп", response.size()); return response; @@ -78,21 +87,7 @@ public class GroupController { } List response = groups.stream() - .map(g -> { - int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); - int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); - return new GroupResponse( - g.getId(), - g.getName(), - g.getGroupSize(), - g.getEducationForm().getId(), - g.getEducationForm().getName(), - g.getDepartmentId(), - course, - semester, - g.getSpecialityCode() - ); - }) + .map(this::mapToResponse) .toList(); logger.info("Найдено {} групп для кафедры с ID - {}", response.size(), departmentId); @@ -110,51 +105,25 @@ public class GroupController { logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}", request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy()); try { - if (request.getName() == null || request.getName().isBlank()) { - String errorMessage = "Название группы обязательно"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (groupRepository.findByName(request.getName().trim()).isPresent()) { - String errorMessage = "Группа с таким названием уже существует"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getGroupSize() == null) { - String errorMessage = "Численность группы обязательна"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getEducationFormId() == null) { - String errorMessage = "Форма обучения обязательна"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { - String errorMessage = "ID кафедры обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } -// if (request.getCourse() == null || request.getCourse() == 0) { -// String errorMessage = "Курс обязателен"; -// logger.error("Ошибка валидации: {}", errorMessage); -// return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); -// } - if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { - String errorMessage = "Год начала обучения обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) { - String errorMessage = "Код специальности обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + ResponseEntity validationError = validateGroupRequest(request); + if (validationError != null) { + return validationError; } Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); if (efOpt.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); } + Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) + .orElse(null); + if (speciality == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена")); + } + SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId()) + .orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности")); + } StudentGroup group = new StudentGroup(); group.setName(request.getName().trim()); @@ -162,20 +131,13 @@ public class GroupController { group.setEducationForm(efOpt.get()); group.setDepartmentId(request.getDepartmentId()); group.setYearStartStudy(request.getYearStartStudy()); - group.setSpecialityCode(request.getSpecialityCode()); + group.setSpeciality(speciality); + group.setSpecialtyProfile(profile); groupRepository.save(group); logger.info("Группа успешно создана с ID - {}", group.getId()); - return ResponseEntity.ok(new GroupResponse( - group.getId(), - group.getName(), - group.getGroupSize(), - group.getEducationForm().getId(), - group.getEducationForm().getName(), - group.getDepartmentId(), - group.getYearStartStudy(), - group.getSpecialityCode())); + return ResponseEntity.ok(mapToResponse(group)); } catch (Exception e ) { logger.error("Ошибка при создании группы: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -183,6 +145,55 @@ public class GroupController { } } + @PutMapping("/{id}") + public ResponseEntity updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) { + logger.info("Получен запрос на обновление группы с ID - {}", id); + try { + Optional groupOpt = groupRepository.findById(id); + if (groupOpt.isEmpty()) { + logger.info("Группа с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + + ResponseEntity validationError = validateGroupRequest(request); + if (validationError != null) { + return validationError; + } + + Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); + if (efOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); + } + Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) + .orElse(null); + if (speciality == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена")); + } + SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId()) + .orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности")); + } + + StudentGroup group = groupOpt.get(); + group.setName(request.getName().trim()); + group.setGroupSize(request.getGroupSize()); + group.setEducationForm(efOpt.get()); + group.setDepartmentId(request.getDepartmentId()); + group.setYearStartStudy(request.getYearStartStudy()); + group.setSpeciality(speciality); + group.setSpecialtyProfile(profile); + groupRepository.save(group); + + logger.info("Группа с ID - {} успешно обновлена", id); + return ResponseEntity.ok(mapToResponse(group)); + } catch (Exception e) { + logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage())); + } + } + @DeleteMapping("/{id}") public ResponseEntity deleteGroup(@PathVariable Long id) { logger.info("Получен запрос на удаление группы с ID - {}", id); @@ -194,4 +205,126 @@ public class GroupController { logger.info("Группа с ID - {} успешно удалена", id); return ResponseEntity.ok(Map.of("message", "Группа удалена")); } + + private ResponseEntity validateGroupRequest(CreateGroupRequest request) { + if (request.getName() == null || request.getName().isBlank()) { + return validationError("Название группы обязательно"); + } + if (request.getGroupSize() == null) { + return validationError("Численность группы обязательна"); + } + if (request.getEducationFormId() == null) { + return validationError("Форма обучения обязательна"); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + return validationError("ID кафедры обязателен"); + } + if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { + return validationError("Год начала обучения обязателен"); + } + if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) { + return validationError("Код специальности обязателен"); + } + if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) { + return validationError("Профиль обучения обязателен"); + } + return null; + } + + private ResponseEntity validationError(String errorMessage) { + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + private GroupResponse mapToResponse(StudentGroup group) { + int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy()); + int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy()); + Speciality speciality = group.getSpeciality(); + SpecialtyProfile profile = group.getSpecialtyProfile(); + return new GroupResponse( + group.getId(), + group.getName(), + group.getGroupSize(), + group.getEducationForm().getId(), + group.getEducationForm().getName(), + group.getDepartmentId(), + group.getYearStartStudy(), + course, + semester, + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getId(), + profile.getName() + ); + } + + @GetMapping("/{id}/calendar-assignments") + public ResponseEntity getCalendarAssignments(@PathVariable Long id) { + if (!groupRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream() + .map(this::toAssignmentDto) + .toList()); + } + + @PutMapping("/{id}/calendar-assignments") + public ResponseEntity saveCalendarAssignment(@PathVariable Long id, + @RequestBody GroupCalendarAssignmentDto request) { + if (request == null || request.academicYearId() == null || request.calendarId() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны")); + } + StudentGroup group = groupRepository.findById(id).orElse(null); + if (group == null) { + return ResponseEntity.notFound().build(); + } + AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null); + if (academicYear == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null); + if (calendar == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден")); + } + if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году")); + } + if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId()) + || !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы")); + } + + StudentGroupCalendarAssignment assignment = assignmentRepository + .findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId()) + .orElseGet(StudentGroupCalendarAssignment::new); + assignment.setStudentGroup(group); + assignment.setAcademicYear(academicYear); + assignment.setAcademicCalendar(calendar); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment))); + } + + @DeleteMapping("/{id}/calendar-assignments/{assignmentId}") + public ResponseEntity deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) { + StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null); + if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) { + return ResponseEntity.notFound().build(); + } + assignmentRepository.delete(assignment); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено")); + } + + private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) { + return new GroupCalendarAssignmentDto( + assignment.getId(), + assignment.getStudentGroup().getId(), + assignment.getStudentGroup().getName(), + assignment.getAcademicYear().getId(), + assignment.getAcademicYear().getTitle(), + assignment.getAcademicCalendar().getId(), + assignment.getAcademicCalendar().getTitle() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java index ab05f3e..a601627 100644 --- a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java +++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java @@ -2,8 +2,11 @@ package com.magistr.app.controller; import com.magistr.app.dto.CreateSpecialityRequest; import com.magistr.app.dto.SpecialityResponse; +import com.magistr.app.dto.SpecialtyProfileDto; import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.repository.SpecialtiesRepository; +import com.magistr.app.repository.SpecialtyProfileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -20,9 +23,12 @@ public class SpecialityController { private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class); private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository specialtyProfileRepository; - public SpecialityController(SpecialtiesRepository specialtiesRepository) { + public SpecialityController(SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository specialtyProfileRepository) { this.specialtiesRepository = specialtiesRepository; + this.specialtyProfileRepository = specialtyProfileRepository; } @GetMapping @@ -75,6 +81,10 @@ public class SpecialityController { speciality.setSpecialityName(request.getSpecialityName()); speciality.setSpecialityCode(request.getSpecialityCode()); specialtiesRepository.save(speciality); + SpecialtyProfile defaultProfile = new SpecialtyProfile(); + defaultProfile.setSpeciality(speciality); + defaultProfile.setName("Без профиля"); + specialtyProfileRepository.save(defaultProfile); logger.info("Специальность успешно создана с ID: {}", speciality.getId()); @@ -159,4 +169,98 @@ public class SpecialityController { logger.info("Специальность с ID - {} успешно удалена", id); return ResponseEntity.ok(Map.of("message", "Специальность удалена")); } + + @GetMapping("/{specialtyId}/profiles") + public ResponseEntity getProfiles(@PathVariable Long specialtyId) { + if (!specialtiesRepository.existsById(specialtyId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(specialtyProfileRepository.findBySpecialityIdOrderByNameAsc(specialtyId).stream() + .map(this::toProfileDto) + .toList()); + } + + @PostMapping("/{specialtyId}/profiles") + public ResponseEntity createProfile(@PathVariable Long specialtyId, @RequestBody SpecialtyProfileDto request) { + Speciality speciality = specialtiesRepository.findById(specialtyId).orElse(null); + if (speciality == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validateProfile(specialtyId, null, request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + SpecialtyProfile profile = new SpecialtyProfile(); + profile.setSpeciality(speciality); + profile.setName(request.name().trim()); + profile.setDescription(trimToNull(request.description())); + return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile))); + } + + @PutMapping("/{specialtyId}/profiles/{profileId}") + public ResponseEntity updateProfile(@PathVariable Long specialtyId, + @PathVariable Long profileId, + @RequestBody SpecialtyProfileDto request) { + SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) { + return ResponseEntity.notFound().build(); + } + String validationError = validateProfile(specialtyId, profileId, request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + profile.setName(request.name().trim()); + profile.setDescription(trimToNull(request.description())); + return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile))); + } + + @DeleteMapping("/{specialtyId}/profiles/{profileId}") + public ResponseEntity deleteProfile(@PathVariable Long specialtyId, @PathVariable Long profileId) { + SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) { + return ResponseEntity.notFound().build(); + } + try { + specialtyProfileRepository.delete(profile); + return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён")); + } catch (Exception e) { + logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e); + return ResponseEntity.badRequest() + .body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками")); + } + } + + private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) { + if (request == null) { + return "Передайте данные профиля"; + } + if (request.name() == null || request.name().isBlank()) { + return "Название профиля обязательно"; + } + return specialtyProfileRepository.findBySpecialityIdAndName(specialtyId, request.name().trim()) + .filter(existing -> !existing.getId().equals(profileId)) + .map(existing -> "Профиль с таким названием уже существует") + .orElse(null); + } + + private SpecialtyProfileDto toProfileDto(SpecialtyProfile profile) { + Speciality speciality = profile.getSpeciality(); + return new SpecialtyProfileDto( + profile.getId(), + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getName(), + profile.getDescription() + ); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java new file mode 100644 index 0000000..d5a1fc3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record AcademicCalendarActivityTypeDto( + Long id, + String code, + String name, + Boolean allowSchedule, + String colorCode, + Integer displayOrder, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java new file mode 100644 index 0000000..2f0dd96 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java @@ -0,0 +1,21 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record AcademicCalendarDto( + Long id, + String title, + Long academicYearId, + String academicYearTitle, + LocalDate academicYearStartDate, + LocalDate academicYearEndDate, + Long specialtyId, + String specialtyCode, + String specialtyName, + Long specialtyProfileId, + String specialtyProfileName, + Long studyFormId, + String studyFormName, + Integer courseCount +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java new file mode 100644 index 0000000..29e4509 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java @@ -0,0 +1,17 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record AcademicCalendarGridDayDto( + Long id, + Long calendarId, + Integer courseNumber, + LocalDate date, + Integer weekNumber, + Integer dayOfWeek, + Long activityTypeId, + String activityCode, + String activityName, + Boolean allowSchedule +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java deleted file mode 100644 index 6fad2dd..0000000 --- a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.magistr.app.dto; - -import com.magistr.app.model.AcademicActivityType; - -public record AcademicCalendarMatrixDto( - Long id, - Long semesterId, - Integer courseNumber, - Long specialtyId, - Integer weekNumber, - AcademicActivityType activityType -) { -} diff --git a/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java b/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java new file mode 100644 index 0000000..7880b76 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java @@ -0,0 +1,8 @@ +package com.magistr.app.dto; + +public record CalendarStudyFormDto( + Long id, + String name, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java index 107482f..8f058d3 100755 --- a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java @@ -7,6 +7,8 @@ public class CreateGroupRequest { private Long educationFormId; private Long departmentId; private Integer yearStartStudy; + private Long specialtyId; + private Long specialtyProfileId; private Long specialityCode; public String getName() { @@ -56,4 +58,24 @@ public class CreateGroupRequest { public void setSpecialityCode(Long specialityCode) { this.specialityCode = specialityCode; } + + public Long getSpecialtyId() { + return specialtyId; + } + + public void setSpecialtyId(Long specialtyId) { + this.specialtyId = specialtyId; + } + + public Long getSpecialtyProfileId() { + return specialtyProfileId; + } + + public void setSpecialtyProfileId(Long specialtyProfileId) { + this.specialtyProfileId = specialtyProfileId; + } + + public Long getEffectiveSpecialtyId() { + return specialtyId != null ? specialtyId : specialityCode; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java new file mode 100644 index 0000000..a949840 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record GroupCalendarAssignmentDto( + Long id, + Long groupId, + String groupName, + Long academicYearId, + String academicYearTitle, + Long calendarId, + String calendarTitle +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java index 8e6f174..4807f35 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -14,21 +14,26 @@ public class GroupResponse { private Integer yearStartStudy; private Integer course; private Integer semester; - private Long specialityCode; + private Long specialtyId; + private String specialtyCode; + private String specialtyName; + private Long specialtyProfileId; + private String specialtyProfileName; - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Integer semester, Long specialityCode) { - this.id = id; - this.name = name; - this.groupSize = groupSize; - this.educationFormId = educationFormId; - this.educationFormName = educationFormName; - this.departmentId = departmentId; - this.course = course; - this.semester = semester; - this.specialityCode = specialityCode; - } - - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer yearStartStudy, Long specialityCode) { + public GroupResponse(Long id, + String name, + Long groupSize, + Long educationFormId, + String educationFormName, + Long departmentId, + Integer yearStartStudy, + Integer course, + Integer semester, + Long specialtyId, + String specialtyCode, + String specialtyName, + Long specialtyProfileId, + String specialtyProfileName) { this.id = id; this.name = name; this.groupSize = groupSize; @@ -36,7 +41,13 @@ public class GroupResponse { this.educationFormName = educationFormName; this.departmentId = departmentId; this.yearStartStudy = yearStartStudy; - this.specialityCode = specialityCode; + this.course = course; + this.semester = semester; + this.specialtyId = specialtyId; + this.specialtyCode = specialtyCode; + this.specialtyName = specialtyName; + this.specialtyProfileId = specialtyProfileId; + this.specialtyProfileName = specialtyProfileName; } public Long getId() { @@ -76,6 +87,26 @@ public class GroupResponse { } public Long getSpecialityCode() { - return specialityCode; + return specialtyId; + } + + public Long getSpecialtyId() { + return specialtyId; + } + + public String getSpecialtyCode() { + return specialtyCode; + } + + public String getSpecialtyName() { + return specialtyName; + } + + public Long getSpecialtyProfileId() { + return specialtyProfileId; + } + + public String getSpecialtyProfileName() { + return specialtyProfileName; } } diff --git a/backend/src/main/java/com/magistr/app/dto/HolidayDto.java b/backend/src/main/java/com/magistr/app/dto/HolidayDto.java deleted file mode 100644 index 8c0bc19..0000000 --- a/backend/src/main/java/com/magistr/app/dto/HolidayDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.magistr.app.dto; - -import java.time.LocalDate; - -public record HolidayDto( - Long id, - LocalDate date, - Long academicYearId, - String academicYearTitle, - String description -) { -} diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 59a5f25..04db846 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -1,6 +1,5 @@ package com.magistr.app.dto; -import com.magistr.app.model.AcademicActivityType; import com.magistr.app.model.ScheduleParity; import java.time.LocalDate; @@ -31,7 +30,7 @@ public record RenderedLessonDto( Long subgroupId, List groupIds, List groupNames, - AcademicActivityType activityType, + String activityType, Integer totalAcademicHours, Integer consumedAcademicHoursBeforeLesson, Integer remainingAcademicHoursAfterLesson diff --git a/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java b/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java new file mode 100644 index 0000000..2452621 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java @@ -0,0 +1,11 @@ +package com.magistr.app.dto; + +public record SpecialtyProfileDto( + Long id, + Long specialtyId, + String specialtyCode, + String specialtyName, + String name, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java b/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java deleted file mode 100644 index 2b4cfb2..0000000 --- a/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.magistr.app.model; - -public enum AcademicActivityType { - THEORY, - EXAM, - VACATION, - PRACTICE -} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java new file mode 100644 index 0000000..9d5894d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java @@ -0,0 +1,90 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendars") +public class AcademicCalendar { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_profile_id", nullable = false) + private SpecialtyProfile specialtyProfile; + + @ManyToOne(optional = false) + @JoinColumn(name = "study_form_id", nullable = false) + private CalendarStudyForm studyForm; + + @Column(name = "course_count", nullable = false) + private Integer courseCount; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public Speciality getSpeciality() { + return speciality; + } + + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public SpecialtyProfile getSpecialtyProfile() { + return specialtyProfile; + } + + public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) { + this.specialtyProfile = specialtyProfile; + } + + public CalendarStudyForm getStudyForm() { + return studyForm; + } + + public void setStudyForm(CalendarStudyForm studyForm) { + this.studyForm = studyForm; + } + + public Integer getCourseCount() { + return courseCount; + } + + public void setCourseCount(Integer courseCount) { + this.courseCount = courseCount; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java new file mode 100644 index 0000000..826eccd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java @@ -0,0 +1,86 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendar_activity_types") +public class AcademicCalendarActivityType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 10) + private String code; + + @Column(nullable = false) + private String name; + + @Column(name = "allow_schedule", nullable = false) + private Boolean allowSchedule; + + @Column(name = "color_code", nullable = false, length = 7) + private String colorCode; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Boolean getAllowSchedule() { + return allowSchedule; + } + + public void setAllowSchedule(Boolean allowSchedule) { + this.allowSchedule = allowSchedule; + } + + public String getColorCode() { + return colorCode; + } + + public void setColorCode(String colorCode) { + this.colorCode = colorCode; + } + + public Integer getDisplayOrder() { + return displayOrder; + } + + public void setDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java new file mode 100644 index 0000000..b256ccb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java @@ -0,0 +1,90 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "academic_calendar_days") +public class AcademicCalendarDay { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "calendar_id", nullable = false) + private AcademicCalendar academicCalendar; + + @Column(name = "course_number", nullable = false) + private Integer courseNumber; + + @Column(nullable = false) + private LocalDate date; + + @Column(name = "week_number", nullable = false) + private Integer weekNumber; + + @Column(name = "day_of_week", nullable = false) + private Integer dayOfWeek; + + @ManyToOne(optional = false) + @JoinColumn(name = "activity_type_id", nullable = false) + private AcademicCalendarActivityType activityType; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AcademicCalendar getAcademicCalendar() { + return academicCalendar; + } + + public void setAcademicCalendar(AcademicCalendar academicCalendar) { + this.academicCalendar = academicCalendar; + } + + public Integer getCourseNumber() { + return courseNumber; + } + + public void setCourseNumber(Integer courseNumber) { + this.courseNumber = courseNumber; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public Integer getWeekNumber() { + return weekNumber; + } + + public void setWeekNumber(Integer weekNumber) { + this.weekNumber = weekNumber; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public AcademicCalendarActivityType getActivityType() { + return activityType; + } + + public void setActivityType(AcademicCalendarActivityType activityType) { + this.activityType = activityType; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java deleted file mode 100644 index 1d92917..0000000 --- a/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name = "academic_calendar_matrix") -public class AcademicCalendarMatrix { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(optional = false) - @JoinColumn(name = "semester_id", nullable = false) - private Semester semester; - - @Column(name = "course_number", nullable = false) - private Integer courseNumber; - - @Column(name = "specialty_id", nullable = false) - private Long specialtyId; - - @Column(name = "week_number", nullable = false) - private Integer weekNumber; - - @Enumerated(EnumType.STRING) - @Column(name = "activity_type", nullable = false, length = 20) - private AcademicActivityType activityType; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Semester getSemester() { - return semester; - } - - public void setSemester(Semester semester) { - this.semester = semester; - } - - public Integer getCourseNumber() { - return courseNumber; - } - - public void setCourseNumber(Integer courseNumber) { - this.courseNumber = courseNumber; - } - - public Long getSpecialtyId() { - return specialtyId; - } - - public void setSpecialtyId(Long specialtyId) { - this.specialtyId = specialtyId; - } - - public Integer getWeekNumber() { - return weekNumber; - } - - public void setWeekNumber(Integer weekNumber) { - this.weekNumber = weekNumber; - } - - public AcademicActivityType getActivityType() { - return activityType; - } - - public void setActivityType(AcademicActivityType activityType) { - this.activityType = activityType; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java b/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java new file mode 100644 index 0000000..49ea74f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java @@ -0,0 +1,42 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "calendar_study_forms") +public class CalendarStudyForm { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Holiday.java b/backend/src/main/java/com/magistr/app/model/Holiday.java deleted file mode 100644 index c01ca07..0000000 --- a/backend/src/main/java/com/magistr/app/model/Holiday.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -import java.time.LocalDate; - -@Entity -@Table(name = "holidays") -public class Holiday { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private LocalDate date; - - @ManyToOne(optional = false) - @JoinColumn(name = "academic_year_id", nullable = false) - private AcademicYear academicYear; - - @Column(length = 255) - private String description; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public LocalDate getDate() { - return date; - } - - public void setDate(LocalDate date) { - this.date = date; - } - - public AcademicYear getAcademicYear() { - return academicYear; - } - - public void setAcademicYear(AcademicYear academicYear) { - this.academicYear = academicYear; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java new file mode 100644 index 0000000..4c6ae37 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java @@ -0,0 +1,54 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "specialty_profiles") +public class SpecialtyProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @Column(nullable = false, length = 500) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Speciality getSpeciality() { + return speciality; + } + + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroup.java b/backend/src/main/java/com/magistr/app/model/StudentGroup.java index c0f78fd..fa42974 100755 --- a/backend/src/main/java/com/magistr/app/model/StudentGroup.java +++ b/backend/src/main/java/com/magistr/app/model/StudentGroup.java @@ -10,7 +10,7 @@ public class StudentGroup { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(unique = true, nullable = false, length = 100) + @Column(nullable = false, length = 100) private String name; @Column(name = "group_size", nullable = false) @@ -23,8 +23,13 @@ public class StudentGroup { @Column(name = "department_id", nullable = false) private Long departmentId; - @Column(name="specialty_code", nullable = false) - private Long specialityCode; + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_profile_id", nullable = false) + private SpecialtyProfile specialtyProfile; @Column(name="year_start_study", nullable = false) private Integer yearStartStudy; @@ -72,12 +77,20 @@ public class StudentGroup { this.departmentId = departmentId; } - public Long getSpecialityCode() { - return specialityCode; + public Speciality getSpeciality() { + return speciality; } - public void setSpecialityCode(Long specialityCode) { - this.specialityCode = specialityCode; + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public SpecialtyProfile getSpecialtyProfile() { + return specialtyProfile; + } + + public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) { + this.specialtyProfile = specialtyProfile; } public Integer getYearStartStudy() { diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java b/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java new file mode 100644 index 0000000..8076ce1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java @@ -0,0 +1,56 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "student_group_calendar_assignments") +public class StudentGroupCalendarAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "group_id", nullable = false) + private StudentGroup studentGroup; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @ManyToOne(optional = false) + @JoinColumn(name = "calendar_id", nullable = false) + private AcademicCalendar academicCalendar; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public StudentGroup getStudentGroup() { + return studentGroup; + } + + public void setStudentGroup(StudentGroup studentGroup) { + this.studentGroup = studentGroup; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public AcademicCalendar getAcademicCalendar() { + return academicCalendar; + } + + public void setAcademicCalendar(AcademicCalendar academicCalendar) { + this.academicCalendar = academicCalendar; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java new file mode 100644 index 0000000..f4a2d8c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java @@ -0,0 +1,14 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarActivityType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarActivityTypeRepository extends JpaRepository { + + List findAllByOrderByDisplayOrderAscCodeAsc(); + + Optional findByCode(String code); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java new file mode 100644 index 0000000..761e1be --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java @@ -0,0 +1,43 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarDay; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarDayRepository extends JpaRepository { + + @Query(""" + select day + from AcademicCalendarDay day + join fetch day.activityType + where day.academicCalendar.id = :calendarId + order by day.courseNumber asc, day.date asc + """) + List findByCalendarIdWithActivity(@Param("calendarId") Long calendarId); + + @Query(""" + select day + from AcademicCalendarDay day + join fetch day.activityType + where day.academicCalendar.id = :calendarId + and day.courseNumber = :courseNumber + and day.date = :date + """) + Optional findByCalendarIdAndCourseNumberAndDate( + @Param("calendarId") Long calendarId, + @Param("courseNumber") Integer courseNumber, + @Param("date") LocalDate date + ); + + boolean existsByActivityTypeId(Long activityTypeId); + + @Modifying + @Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId") + void deleteByCalendarId(@Param("calendarId") Long calendarId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java deleted file mode 100644 index 70964d6..0000000 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.AcademicCalendarMatrix; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface AcademicCalendarMatrixRepository extends JpaRepository { - - Optional findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( - Long semesterId, - Integer courseNumber, - Long specialtyId, - Integer weekNumber - ); - - List findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc( - Long semesterId, - Integer courseNumber, - Long specialtyId - ); -} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java new file mode 100644 index 0000000..5d2257e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java @@ -0,0 +1,41 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendar; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarRepository extends JpaRepository { + + @Query(""" + select calendar + from AcademicCalendar calendar + join fetch calendar.academicYear + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where (:academicYearId is null or calendar.academicYear.id = :academicYearId) + and (:specialtyId is null or calendar.speciality.id = :specialtyId) + and (:profileId is null or calendar.specialtyProfile.id = :profileId) + order by calendar.academicYear.startDate desc, calendar.title asc + """) + List findAllWithDetails( + @Param("academicYearId") Long academicYearId, + @Param("specialtyId") Long specialtyId, + @Param("profileId") Long profileId + ); + + @Query(""" + select calendar + from AcademicCalendar calendar + join fetch calendar.academicYear + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where calendar.id = :id + """) + Optional findByIdWithDetails(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java b/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java new file mode 100644 index 0000000..7c7eb25 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java @@ -0,0 +1,11 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.CalendarStudyForm; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CalendarStudyFormRepository extends JpaRepository { + + List findAllByOrderByNameAsc(); +} diff --git a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java index 7ab7312..520f8e7 100755 --- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java @@ -4,12 +4,9 @@ import com.magistr.app.model.StudentGroup; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Optional; public interface GroupRepository extends JpaRepository { - Optional findByName(String name); - List findByEducationFormId(Long educationFormId); List findByDepartmentId(Long departmentId); diff --git a/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java b/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java deleted file mode 100644 index 43ec732..0000000 --- a/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.Holiday; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDate; -import java.util.List; - -public interface HolidayRepository extends JpaRepository { - - List findByAcademicYearIdOrderByDateAsc(Long academicYearId); - - List findByAcademicYearIdAndDateBetween(Long academicYearId, LocalDate startDate, LocalDate endDate); - - boolean existsByAcademicYearIdAndDate(Long academicYearId, LocalDate date); -} diff --git a/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java b/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java new file mode 100644 index 0000000..931adc3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java @@ -0,0 +1,14 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.SpecialtyProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SpecialtyProfileRepository extends JpaRepository { + + List findBySpecialityIdOrderByNameAsc(Long specialityId); + + Optional findBySpecialityIdAndName(Long specialityId, String name); +} diff --git a/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java new file mode 100644 index 0000000..446c99d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java @@ -0,0 +1,54 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.StudentGroupCalendarAssignment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface StudentGroupCalendarAssignmentRepository extends JpaRepository { + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.studentGroup + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where assignment.studentGroup.id = :groupId + order by assignment.academicYear.startDate desc + """) + List findByGroupIdWithDetails(@Param("groupId") Long groupId); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where assignment.studentGroup.id = :groupId + and assignment.academicYear.id = :academicYearId + """) + Optional findByGroupIdAndAcademicYearIdWithDetails( + @Param("groupId") Long groupId, + @Param("academicYearId") Long academicYearId + ); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.academicCalendar + where assignment.studentGroup.id = :groupId + and assignment.academicYear.id = :academicYearId + """) + Optional findForSchedule( + @Param("groupId") Long groupId, + @Param("academicYearId") Long academicYearId + ); +} diff --git a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java index 0a2248d..8180415 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java @@ -1,9 +1,9 @@ package com.magistr.app.service; import com.magistr.app.model.*; -import com.magistr.app.repository.AcademicCalendarMatrixRepository; -import com.magistr.app.repository.HolidayRepository; +import com.magistr.app.repository.AcademicCalendarDayRepository; import com.magistr.app.repository.SemesterRepository; +import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -15,17 +15,17 @@ import java.util.Optional; public class AcademicDateService { private final SemesterRepository semesterRepository; - private final HolidayRepository holidayRepository; - private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final boolean evenWeekIsUpper; public AcademicDateService(SemesterRepository semesterRepository, - HolidayRepository holidayRepository, - AcademicCalendarMatrixRepository calendarMatrixRepository, + AcademicCalendarDayRepository calendarDayRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, @Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) { this.semesterRepository = semesterRepository; - this.holidayRepository = holidayRepository; - this.calendarMatrixRepository = calendarMatrixRepository; + this.calendarDayRepository = calendarDayRepository; + this.assignmentRepository = assignmentRepository; this.evenWeekIsUpper = evenWeekIsUpper; } @@ -50,38 +50,40 @@ public class AcademicDateService { return evenWeek ? ScheduleParity.EVEN : ScheduleParity.ODD; } - public boolean isHoliday(Semester semester, LocalDate date) { - if (semester == null || semester.getAcademicYear() == null) { - return false; - } - return holidayRepository.existsByAcademicYearIdAndDate(semester.getAcademicYear().getId(), date); - } - public int getCourseNumber(StudentGroup group, LocalDate date) { int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1; return academicYearStart - group.getYearStartStudy() + 1; } - public AcademicActivityType getActivityType(StudentGroup group, Semester semester, LocalDate date) { - int weekNumber = getWeekNumber(semester, date); - int courseNumber = getCourseNumber(group, date); - if (weekNumber < 1 || courseNumber < 1) { - return AcademicActivityType.VACATION; + public Optional getActivityType(StudentGroup group, Semester semester, LocalDate date) { + if (group == null || semester == null || semester.getAcademicYear() == null || date == null) { + return Optional.empty(); } - return calendarMatrixRepository - .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( - semester.getId(), + int courseNumber = getCourseNumber(group, date); + if (courseNumber < 1) { + return Optional.empty(); + } + + return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()) + .flatMap(assignment -> calendarDayRepository.findByCalendarIdAndCourseNumberAndDate( + assignment.getAcademicCalendar().getId(), courseNumber, - group.getSpecialityCode(), - weekNumber - ) - .map(AcademicCalendarMatrix::getActivityType) - .orElse(AcademicActivityType.THEORY); + date + )) + .map(AcademicCalendarDay::getActivityType); } public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { - return getActivityType(group, semester, date) == AcademicActivityType.THEORY - && !isHoliday(semester, date); + return getActivityType(group, semester, date) + .map(AcademicCalendarActivityType::getAllowSchedule) + .orElse(false); + } + + public boolean hasCalendarAssignment(StudentGroup group, Semester semester) { + if (group == null || semester == null || semester.getAcademicYear() == null) { + return false; + } + return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent(); } } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index a7554cc..4574678 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -5,6 +5,8 @@ import com.magistr.app.model.*; import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.ScheduleRuleRepository; import com.magistr.app.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.time.DayOfWeek; @@ -17,6 +19,7 @@ import java.util.stream.Collectors; @Service public class ScheduleGeneratorService { + private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class); private static final int ACADEMIC_HOURS_PER_SLOT = 2; private static final long MAX_RANGE_DAYS = 120; @@ -43,6 +46,7 @@ public class ScheduleGeneratorService { Map> rulesBySemester = new HashMap<>(); List result = new ArrayList<>(); + Set warnedAcademicYears = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { Optional semesterOpt = academicDateService.findSemester(date); @@ -51,6 +55,11 @@ public class ScheduleGeneratorService { } Semester semester = semesterOpt.get(); + if (!academicDateService.hasCalendarAssignment(group, semester) + && warnedAcademicYears.add(semester.getAcademicYear().getId())) { + logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}", + group.getName(), semester.getAcademicYear().getTitle()); + } if (!academicDateService.isTheoryDay(group, semester, date)) { continue; } @@ -148,10 +157,6 @@ public class ScheduleGeneratorService { } Semester semester = semesterOpt.get(); - if (academicDateService.isHoliday(semester, date)) { - return; - } - List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); if (eligibleGroups.isEmpty()) { return; @@ -216,9 +221,6 @@ public class ScheduleGeneratorService { if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) { continue; } - if (academicDateService.isHoliday(semester, date)) { - continue; - } if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) { continue; } @@ -274,7 +276,7 @@ public class ScheduleGeneratorService { slot.getSubgroupId(), sortedGroups.stream().map(StudentGroup::getId).toList(), sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), - AcademicActivityType.THEORY, + "Т", rule.getTotalAcademicHours(), consumedBeforeLesson, remainingAfterLesson diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index de034a4..4c08017 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -20,13 +20,28 @@ INSERT INTO departments (name, code) VALUES CREATE TABLE IF NOT EXISTS specialties ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, - specialty_code VARCHAR(255) NOT NULL + specialty_code VARCHAR(255) UNIQUE NOT NULL ); INSERT INTO specialties (name, specialty_code) VALUES ('Информационная безопасность', '10.03.01'), ('Информатика и вычислительная техника', '09.03.01'), - ('Программная инженерия', '09.03.04'); + ('Программная инженерия', '09.03.04') +ON CONFLICT (specialty_code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS specialty_profiles ( + id BIGSERIAL PRIMARY KEY, + specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE, + name VARCHAR(500) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name) +); + +INSERT INTO specialty_profiles (specialty_id, name) +SELECT id, 'Без профиля' +FROM specialties +ON CONFLICT (specialty_id, name) DO NOTHING; -- ========================================== -- Пользователи и роли @@ -70,20 +85,28 @@ ON CONFLICT (name) DO NOTHING; -- ========================================== CREATE TABLE IF NOT EXISTS student_groups ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, group_size BIGINT NOT NULL, education_form_id BIGINT NOT NULL REFERENCES education_forms(id), department_id BIGINT NOT NULL REFERENCES departments(id), - specialty_code INT NOT NULL REFERENCES specialties(id), + specialty_id BIGINT NOT NULL REFERENCES specialties(id), + specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), year_start_study BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Тестовая базовая группа для работы -INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_code, year_start_study) -VALUES ('ИВТ-21-1', 25, 1, 1, 2, 2025), - ('ИБ-41м', 15, 2, 1, 1, 2024) -ON CONFLICT (name) DO NOTHING; +INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study) +SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study +FROM (VALUES + ('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT), + ('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT) +) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study) +JOIN education_forms ef ON ef.name = data.education_form_name +JOIN departments dept ON dept.name = data.department_name +JOIN specialties specialty ON specialty.specialty_code = data.specialty_code +JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' +WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name); -- ========================================== -- Подгруппы (например: "ИВТ-21-1 Подгруппа 1") @@ -272,45 +295,131 @@ ON CONFLICT (academic_year_id, semester_type) DO NOTHING; CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date); -CREATE TABLE IF NOT EXISTS holidays ( +CREATE TABLE IF NOT EXISTS calendar_study_forms ( id BIGSERIAL PRIMARY KEY, - date DATE NOT NULL, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO calendar_study_forms (name, description) VALUES + ('очная', 'Очная форма обучения') +ON CONFLICT (name) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendar_activity_types ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(10) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + allow_schedule BOOLEAN NOT NULL DEFAULT FALSE, + color_code VARCHAR(7) NOT NULL DEFAULT '#64748b', + display_order INT NOT NULL DEFAULT 100, + description TEXT +); + +INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES + ('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'), + ('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'), + ('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'), + ('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'), + ('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'), + ('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'), + ('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'), + ('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'), + ('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'), + ('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'), + ('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'), + ('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'), + ('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания') +ON CONFLICT (code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendars ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, - description VARCHAR(255), - CONSTRAINT uq_holidays_year_date UNIQUE (academic_year_id, date) -); - -CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date); - -CREATE TABLE IF NOT EXISTS academic_calendar_matrix ( - id BIGSERIAL PRIMARY KEY, - semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, - course_number INT NOT NULL, specialty_id BIGINT NOT NULL REFERENCES specialties(id), - week_number INT NOT NULL, - activity_type VARCHAR(20) NOT NULL, - CONSTRAINT chk_calendar_course_positive CHECK (course_number > 0), - CONSTRAINT chk_calendar_week_positive CHECK (week_number > 0), - CONSTRAINT chk_calendar_activity_type CHECK (activity_type IN ('THEORY', 'EXAM', 'VACATION', 'PRACTICE')), - CONSTRAINT uq_calendar_matrix UNIQUE (semester_id, course_number, specialty_id, week_number) + specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), + study_form_id BIGINT NOT NULL REFERENCES calendar_study_forms(id), + course_count INT NOT NULL DEFAULT 4, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_academic_calendars_course_count CHECK (course_count > 0), + CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title) ); -CREATE INDEX IF NOT EXISTS idx_calendar_lookup - ON academic_calendar_matrix(semester_id, course_number, specialty_id, week_number); +CREATE INDEX IF NOT EXISTS idx_academic_calendars_context + ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id); -INSERT INTO academic_calendar_matrix (semester_id, course_number, specialty_id, week_number, activity_type) +INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count) SELECT - s.id, - SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 AS course_number, - sg.specialty_code AS specialty_id, - weeks.week_number, - 'THEORY' -FROM semesters s -JOIN academic_years ay ON ay.id = s.academic_year_id -CROSS JOIN student_groups sg -CROSS JOIN GENERATE_SERIES(1, 22) AS weeks(week_number) -WHERE SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 > 0 -ON CONFLICT (semester_id, course_number, specialty_id, week_number) DO NOTHING; + specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title, + ay.id, + specialty.id, + profile.id, + study_form.id, + 4 +FROM academic_years ay +CROSS JOIN calendar_study_forms study_form +JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01') +JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' +WHERE ay.title = '2025-2026' +ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendar_days ( + id BIGSERIAL PRIMARY KEY, + calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, + course_number INT NOT NULL, + date DATE NOT NULL, + week_number INT NOT NULL, + day_of_week INT NOT NULL, + activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id), + CONSTRAINT chk_calendar_days_course_positive CHECK (course_number > 0), + CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0), + CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7), + CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup + ON academic_calendar_days(calendar_id, course_number, date); + +INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id) +SELECT + calendar.id, + course.course_number, + days.date::DATE, + ((days.date::DATE - ay.start_date) / 7) + 1, + EXTRACT(ISODOW FROM days.date::DATE)::INT, + activity.id +FROM academic_calendars calendar +JOIN academic_years ay ON ay.id = calendar.academic_year_id +JOIN academic_calendar_activity_types activity ON activity.code = 'Т' +CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number) +CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date) +ON CONFLICT (calendar_id, course_number, date) DO NOTHING; + +CREATE TABLE IF NOT EXISTS student_group_calendar_assignments ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_group_calendar_year UNIQUE (group_id, academic_year_id) +); + +CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup + ON student_group_calendar_assignments(group_id, academic_year_id); + +INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id) +SELECT + sg.id, + ay.id, + calendar.id +FROM student_groups sg +JOIN academic_years ay ON ay.title = '2025-2026' +JOIN academic_calendars calendar + ON calendar.academic_year_id = ay.id + AND calendar.specialty_profile_id = sg.specialty_profile_id +WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м') +ON CONFLICT (group_id, academic_year_id) DO NOTHING; CREATE TABLE IF NOT EXISTS schedule_rules ( id BIGSERIAL PRIMARY KEY, @@ -445,17 +554,26 @@ CREATE TRIGGER update_users_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_academic_calendars_updated_at + BEFORE UPDATE ON academic_calendars + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + -- ========================================== -- Комментарии к таблицам и полям (для документации) -- ========================================== COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)'; COMMENT ON TABLE departments IS 'Кафедры'; COMMENT ON TABLE specialties IS 'Специальности'; +COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности'; COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; COMMENT ON TABLE academic_years IS 'Учебные годы'; COMMENT ON TABLE semesters IS 'Семестры учебного года'; -COMMENT ON TABLE holidays IS 'Праздники и неучебные даты'; -COMMENT ON TABLE academic_calendar_matrix IS 'Матрица учебного графика по курсу, специальности и неделе'; +COMMENT ON TABLE calendar_study_forms IS 'Формы обучения календарного учебного графика'; +COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; +COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; +COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; +COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году'; COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; @@ -489,7 +607,8 @@ COMMENT ON COLUMN student_groups.name IS 'Название группы'; COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов'; COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа'; COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; -COMMENT ON COLUMN student_groups.specialty_code IS 'ID специальности'; +COMMENT ON COLUMN student_groups.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN student_groups.specialty_profile_id IS 'ID профиля обучения'; COMMENT ON COLUMN student_groups.year_start_study IS 'Год начала обучения'; COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; @@ -526,7 +645,7 @@ COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создан COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории'; COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования'; -COMMENT ON COLUMN classroom_equipments.quantity IS 'Дата и время создания'; -- Так было в V2 +COMMENT ON COLUMN classroom_equipments.quantity IS 'Количество единиц оборудования'; COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи'; COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя'; @@ -542,6 +661,37 @@ COMMENT ON COLUMN specialties.id IS 'ID специальности'; COMMENT ON COLUMN specialties.name IS 'Название специальности'; COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности'; +COMMENT ON COLUMN specialty_profiles.id IS 'ID профиля обучения'; +COMMENT ON COLUMN specialty_profiles.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN specialty_profiles.name IS 'Название профиля обучения'; +COMMENT ON COLUMN specialty_profiles.description IS 'Описание профиля обучения'; + +COMMENT ON COLUMN calendar_study_forms.id IS 'ID формы обучения графика'; +COMMENT ON COLUMN calendar_study_forms.name IS 'Название формы обучения графика'; +COMMENT ON COLUMN calendar_study_forms.description IS 'Описание формы обучения графика'; + +COMMENT ON COLUMN academic_calendar_activity_types.code IS 'Код активности из календарного графика'; +COMMENT ON COLUMN academic_calendar_activity_types.name IS 'Название активности'; +COMMENT ON COLUMN academic_calendar_activity_types.allow_schedule IS 'Разрешает ли код генерацию обычного расписания'; + +COMMENT ON COLUMN academic_calendars.title IS 'Название календарного учебного графика'; +COMMENT ON COLUMN academic_calendars.academic_year_id IS 'ID учебного года'; +COMMENT ON COLUMN academic_calendars.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля обучения'; +COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения графика'; +COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике'; + +COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика'; +COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса'; +COMMENT ON COLUMN academic_calendar_days.date IS 'Дата'; +COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году'; +COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO'; +COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности'; + +COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы'; +COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года'; +COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика'; + COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя'; COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета'; COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md new file mode 100644 index 0000000..e8f86fa --- /dev/null +++ b/docs/ACADEMIC_CALENDAR.md @@ -0,0 +1,76 @@ +# Календарный учебный график + +## Назначение + +Календарный учебный график хранит правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. Динамическое расписание использует этот график как фильтр: обычные пары выводятся только в дни теоретического обучения. + +График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели. + +## Связи данных + +Основная цепочка выглядит так: + +```text +специальность -> профиль обучения -> календарный учебный график -> назначение группе +``` + +- `specialties` хранит код и название специальности, например `09.03.04`. +- `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей. +- `academic_calendars` хранит график профиля, формы обучения и учебного года. +- `academic_calendar_days` хранит дневную сетку графика по курсу и дате. +- `student_groups` связана со специальностью и профилем обучения. +- `student_group_calendar_assignments` назначает группе конкретный график на учебный год. + +Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам. + +## Коды активностей + +| Код | Значение | Обычные пары | +|-----|----------|--------------| +| `Т` | Теоретическое обучение | Да | +| `Э` | Экзаменационная сессия | Нет | +| `К` | Каникулы | Нет | +| `У` | Учебная практика | Нет | +| `П` | Производственная практика | Нет | +| `Пд` | Преддипломная практика | Нет | +| `Н` | Научно-исследовательская работа | Нет | +| `Г` | Государственная итоговая аттестация | Нет | +| `Д` | Подготовка выпускной квалификационной работы | Нет | +| `ПА` | Промежуточная аттестация | Нет | +| `С` | Самостоятельная работа или специальный период | Нет | +| `*` | Праздничный или нерабочий день | Нет | +| `=` | День вне учебного года или пустая ячейка графика | Нет | + +Технически разрешение генерации задаётся полем `academic_calendar_activity_types.allow_schedule`. В seed-данных `true` установлен только для `Т`. + +## Правило генерации расписания + +`ScheduleGeneratorService` при запросе `GET /api/schedule` проверяет календарный график до вывода занятия: + +1. Определяется учебный год и семестр даты. +2. Для группы находится назначение графика на этот учебный год. +3. По году начала обучения группы вычисляется текущий курс. +4. В `academic_calendar_days` ищется ячейка для курса и даты. +5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`. +6. Если код не разрешает пары, день пропускается и академические часы не списываются. + +Если график группе не назначен, расписание группы возвращается пустым списком без ошибки. Это сделано намеренно: отсутствие календаря означает, что нет разрешённых дат для генерации. + +## Работа администратора + +1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. +2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. +3. В разделе расписания администратор создаёт учебный год и семестры. +4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения и количество курсов. +5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. +6. В разделе групп администратор назначает календарный график группе на нужный учебный год. + +После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря. + +## Особенности модели + +- Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке. +- Дни вне учебного года или пустые периоды Excel хранятся кодом `=`. +- График привязан к профилю обучения, а не только к коду специальности. +- Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры. +- Excel-импорт в первой реализации не предусмотрен: редактор заполняет данные через сайт. diff --git a/docs/API.md b/docs/API.md index a7f633b..cfad4e2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,7 +101,7 @@ ## Динамическое расписание -Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат. +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. ### `GET /api/schedule` @@ -116,7 +116,7 @@ | `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | | `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | -Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. +Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. **Пример:** ```http @@ -150,7 +150,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "subgroupId": null, "groupIds": [1], "groupNames": ["ИВТ-21-1"], - "activityType": "THEORY", + "activityType": "Т", "totalAcademicHours": 72, "consumedAcademicHoursBeforeLesson": 24, "remainingAcademicHoursAfterLesson": 46 @@ -179,7 +179,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 } ``` -### Календарный график администратора +### Учебные годы, семестры и коды календарного графика | Метод | URL | Назначение | |-------|-----|------------| @@ -190,12 +190,59 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | `GET` | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | | `POST` | `/api/admin/calendar/years/{academicYearId}/semesters` | Создать семестр | | `PUT` | `/api/admin/calendar/semesters/{id}` | Обновить семестр | -| `GET` | `/api/admin/calendar/holidays?academicYearId=1` | Праздники учебного года | -| `POST` | `/api/admin/calendar/holidays` | Создать праздник | -| `PUT` | `/api/admin/calendar/holidays/{id}` | Обновить праздник | -| `DELETE` | `/api/admin/calendar/holidays/{id}` | Удалить праздник | -| `GET` | `/api/admin/calendar/matrix?semesterId=1&courseNumber=1&specialtyId=2` | Матрица учебного графика | -| `PUT` | `/api/admin/calendar/matrix` | Массовое сохранение матрицы | +| `GET` | `/api/admin/calendar/activity-types` | Справочник кодов активностей графика | +| `POST` | `/api/admin/calendar/activity-types` | Создать код активности | +| `PUT` | `/api/admin/calendar/activity-types/{id}` | Обновить код активности | +| `DELETE` | `/api/admin/calendar/activity-types/{id}` | Удалить код активности | + +**Тело создания/обновления кода активности:** +```json +{ + "code": "Т", + "name": "Теоретическое обучение", + "allowSchedule": true, + "colorCode": "#22c55e", + "displayOrder": 10, + "description": "Обычные пары разрешены" +} +``` + +### Календарные учебные графики + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | +| `GET` | `/api/admin/academic-calendars/study-forms` | Формы обучения графика | +| `GET` | `/api/admin/academic-calendars/{id}` | Один график | +| `POST` | `/api/admin/academic-calendars` | Создать график | +| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | +| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график | +| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | +| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки | + +**Тело создания/обновления графика:** +```json +{ + "title": "09.03.04 очная форма 2025-2026", + "academicYearId": 1, + "specialtyId": 2, + "specialtyProfileId": 3, + "studyFormId": 1, + "courseCount": 4 +} +``` + +**Ячейка сетки графика:** +```json +{ + "calendarId": 1, + "courseNumber": 1, + "date": "2025-09-01", + "weekNumber": 1, + "dayOfWeek": 1, + "activityTypeId": 1 +} +``` ### `POST /api/admin/schedule-rules` @@ -274,6 +321,10 @@ CRUD доступен по: | `POST` | `/api/specialties` | Создать специальность | | `PUT` | `/api/specialties/{id}` | Обновить специальность | | `DELETE` | `/api/specialties/{id}` | Удалить специальность | +| `GET` | `/api/specialties/{id}/profiles` | Профили выбранной специальности | +| `POST` | `/api/specialties/{id}/profiles` | Создать профиль | +| `PUT` | `/api/specialties/{id}/profiles/{profileId}` | Обновить профиль | +| `DELETE` | `/api/specialties/{id}/profiles/{profileId}` | Удалить профиль | **Тело создания/обновления:** ```json @@ -283,6 +334,16 @@ CRUD доступен по: } ``` +При создании специальности автоматически создаётся профиль `Без профиля`. + +**Тело создания/обновления профиля:** +```json +{ + "name": "Безопасность автоматизированных систем", + "description": "Необязательное описание" +} +``` + --- ## Группы @@ -301,7 +362,14 @@ CRUD доступен по: "educationFormId": 1, "educationFormName": "Бакалавриат", "departmentId": 1, + "yearStartStudy": 2023, "course": 3, + "semester": 6, + "specialtyId": 1, + "specialtyCode": "09.03.04", + "specialtyName": "Программная инженерия", + "specialtyProfileId": 2, + "specialtyProfileName": "Без профиля", "specialityCode": 1 } ] @@ -322,16 +390,53 @@ CRUD доступен по: "educationFormId": 1, "departmentId": 1, "yearStartStudy": 2026, - "specialityCode": 1 + "specialtyId": 1, + "specialtyProfileId": 2 } ``` -`specialityCode` исторически содержит ID записи из `/api/specialties`; текущий курс вычисляется из `yearStartStudy`. +`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`. + +Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`. + +### `PUT /api/groups/{id}` + +Редактирование группы. + +```json +{ + "name": "ИВТ-11", + "groupSize": 24, + "educationFormId": 1, + "departmentId": 1, + "yearStartStudy": 2026, + "specialtyId": 1, + "specialtyProfileId": 2 +} +``` ### `DELETE /api/groups/{id}` Удаление группы. +### Календарные графики группы + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/groups/{id}/calendar-assignments` | Список назначений графиков группе | +| `PUT` | `/api/groups/{id}/calendar-assignments` | Создать или заменить назначение на учебный год | +| `DELETE` | `/api/groups/{id}/calendar-assignments/{assignmentId}` | Удалить назначение | + +**Тело назначения графика:** +```json +{ + "academicYearId": 1, + "calendarId": 5 +} +``` + +Назначаемый график должен относиться к тому же учебному году, специальности и профилю, что и группа. + --- ## Аудитории diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index da1e6ea..637d3b5 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -28,9 +28,10 @@ ### Специальности (Specialties) -Учебные направления с кодом по ФГОС. +Учебные направления с кодом по ФГОС. У одной специальности может быть несколько профилей обучения, например базовый профиль и профиль конкретной образовательной программы. - Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04) +- При создании специальности автоматически создаётся профиль `Без профиля` ### Формы обучения (Education Forms) @@ -41,9 +42,10 @@ ### Учебные группы (Student Groups) -- **Поля:** Название (уникальное), численность, форма обучения, кафедра, специальность, год начала обучения +- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) +- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график ### Аудитории (Classrooms) @@ -74,8 +76,11 @@ | Компонент | Назначение | |-----------|------------| | `academic_years` / `semesters` | Учебные годы и семестры. Неделя 1 считается от `semesters.start_date` | -| `holidays` | Даты, когда занятия не проводятся и часы не списываются | -| `academic_calendar_matrix` | Тип недели для курса и специальности: теория, сессия, каникулы, практика | +| `specialty_profiles` | Профили обучения внутри специальности | +| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года | +| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | +| `academic_calendar_days` | Дневная сетка по курсу и дате | +| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | | `time_slots` | Настраиваемая сетка пар для тенанта | | `schedule_rules` | Лимит часов дисциплины в семестре | | `schedule_rule_groups` | Группы правила, включая потоковые лекции | @@ -84,12 +89,14 @@ Генератор `ScheduleGeneratorService` рендерит расписание по запросу: 1. Определяет семестр для каждой даты диапазона. 2. Вычисляет номер недели и чётность. -3. Проверяет праздники и матрицу учебного графика. -4. Загружает правила группы или преподавателя. -5. Симулирует уже проведённые занятия от `active_from_date`. -6. Останавливает вывод правила, когда достигнут `total_academic_hours`. +3. Находит назначенный группе календарный учебный график на учебный год даты. +4. Определяет код активности по курсу группы и конкретной дате. +5. Пропускает день, если код активности не разрешает обычные пары. +6. Загружает правила группы или преподавателя. +7. Симулирует уже проведённые занятия от `active_from_date`. +8. Останавливает вывод правила, когда достигнут `total_academic_hours`. -Праздник считается пропуском: занятие не переносится и не списывает академические часы. +Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. ### Временны́е слоты diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 5dd0cd8..c7ab763 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -23,7 +23,14 @@ erDiagram specialties { BIGSERIAL id PK VARCHAR name - VARCHAR specialty_code + VARCHAR specialty_code UK + } + + specialty_profiles { + BIGSERIAL id PK + BIGINT specialty_id FK + VARCHAR name + TEXT description } users { @@ -47,11 +54,12 @@ erDiagram student_groups { BIGSERIAL id PK - VARCHAR name UK + VARCHAR name BIGINT group_size BIGINT education_form_id FK BIGINT department_id FK - BIGINT specialty_code FK + BIGINT specialty_id FK + BIGINT specialty_profile_id FK BIGINT year_start_study TIMESTAMP created_at } @@ -140,20 +148,48 @@ erDiagram DATE end_date } - holidays { + calendar_study_forms { BIGSERIAL id PK - DATE date - BIGINT academic_year_id FK - VARCHAR description + VARCHAR name UK + TEXT description } - academic_calendar_matrix { + academic_calendar_activity_types { BIGSERIAL id PK - BIGINT semester_id FK - INT course_number + VARCHAR code UK + VARCHAR name + BOOLEAN allow_schedule + VARCHAR color_code + INT display_order + } + + academic_calendars { + BIGSERIAL id PK + VARCHAR title + BIGINT academic_year_id FK BIGINT specialty_id FK + BIGINT specialty_profile_id FK + BIGINT study_form_id FK + INT course_count + TIMESTAMP created_at + TIMESTAMP updated_at + } + + academic_calendar_days { + BIGSERIAL id PK + BIGINT calendar_id FK + INT course_number + DATE date INT week_number - VARCHAR activity_type + INT day_of_week + BIGINT activity_type_id FK + } + + student_group_calendar_assignments { + BIGSERIAL id PK + BIGINT group_id FK + BIGINT academic_year_id FK + BIGINT calendar_id FK } schedule_rules { @@ -186,9 +222,12 @@ erDiagram departments ||--o{ student_groups : "department_id" departments ||--o{ subjects : "department_id" education_forms ||--o{ student_groups : "education_form_id" - specialties ||--o{ student_groups : "specialty_code" + specialties ||--o{ specialty_profiles : "specialty_id" + specialties ||--o{ student_groups : "specialty_id" + specialty_profiles ||--o{ student_groups : "specialty_profile_id" student_groups ||--o{ subgroups : "group_id" student_groups ||--o{ schedule_rule_groups : "group_id" + student_groups ||--o{ student_group_calendar_assignments : "group_id" users ||--o{ teacher_subjects : "user_id" users ||--o{ teacher_lesson_types : "user_id" subjects ||--o{ teacher_subjects : "subject_id" @@ -200,10 +239,15 @@ erDiagram classrooms ||--o{ classroom_equipments : "classroom_id" equipments ||--o{ classroom_equipments : "equipment_id" academic_years ||--o{ semesters : "academic_year_id" - academic_years ||--o{ holidays : "academic_year_id" - semesters ||--o{ academic_calendar_matrix : "semester_id" + academic_years ||--o{ academic_calendars : "academic_year_id" + academic_years ||--o{ student_group_calendar_assignments : "academic_year_id" semesters ||--o{ schedule_rules : "semester_id" - specialties ||--o{ academic_calendar_matrix : "specialty_id" + specialties ||--o{ academic_calendars : "specialty_id" + specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" + calendar_study_forms ||--o{ academic_calendars : "study_form_id" + academic_calendars ||--o{ academic_calendar_days : "calendar_id" + academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" + academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" time_slots ||--o{ schedule_rule_slots : "time_slot_id" @@ -229,7 +273,15 @@ erDiagram |---------|-----|----------| | `id` | BIGSERIAL PK | ID специальности | | `name` | VARCHAR(255) | Название специальности | -| `specialty_code` | VARCHAR(255) | Код ФГОС (напр. `10.03.01`) | +| `specialty_code` | VARCHAR(255) UNIQUE | Код ФГОС (напр. `10.03.01`) | + +#### `specialty_profiles` — Профили обучения +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID профиля | +| `specialty_id` | BIGINT FK → specialties | Специальность, внутри которой существует профиль | +| `name` | VARCHAR(255) | Название профиля, уникально внутри специальности | +| `description` | TEXT | Описание профиля | ### Пользователи @@ -261,11 +313,12 @@ erDiagram | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `name` | VARCHAR(100) UNIQUE | Название группы (напр. `ИВТ-21-1`) | +| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное | | `group_size` | BIGINT | Количество студентов | | `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `department_id` | BIGINT FK → departments | Кафедра | -| `specialty_code` | BIGINT FK → specialties | Специальность | +| `specialty_id` | BIGINT FK → specialties | Специальность | +| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы | | `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | #### `subgroups` — Подгруппы @@ -367,23 +420,55 @@ erDiagram | `start_date` | DATE | Дата начала, от неё считается неделя 1 | | `end_date` | DATE | Дата окончания | -#### `holidays` — Праздники и исключения +#### `calendar_study_forms` — Формы обучения календарного графика | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `date` | DATE | Неучебная дата | -| `academic_year_id` | BIGINT FK → academic_years | Учебный год | -| `description` | VARCHAR(255) | Описание | +| `name` | VARCHAR(100) UNIQUE | Форма обучения графика, seed: `очная` | +| `description` | TEXT | Описание | -#### `academic_calendar_matrix` — Матрица учебного графика +#### `academic_calendar_activity_types` — Коды активностей графика | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `semester_id` | BIGINT FK → semesters | Семестр | -| `course_number` | INT | Курс | +| `code` | VARCHAR(10) UNIQUE | Код из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | +| `name` | VARCHAR(100) | Расшифровка кода | +| `allow_schedule` | BOOLEAN | Разрешает генерацию обычных пар | +| `color_code` | VARCHAR(7) | HEX-цвет для редактора | +| `display_order` | INT | Порядок вывода в UI | +| `description` | TEXT | Дополнительное описание | + +#### `academic_calendars` — Календарные учебные графики +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `title` | VARCHAR(255) | Название графика | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | | `specialty_id` | BIGINT FK → specialties | Специальность | -| `week_number` | INT | Номер недели семестра | -| `activity_type` | VARCHAR(20) | `THEORY`, `EXAM`, `VACATION`, `PRACTICE` | +| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | +| `study_form_id` | BIGINT FK → calendar_study_forms | Форма обучения графика | +| `course_count` | INT CHECK(> 0) | Количество курсов в сетке | +| `created_at` | TIMESTAMP | Дата создания | +| `updated_at` | TIMESTAMP | Дата обновления | + +#### `academic_calendar_days` — Дневная сетка календарного графика +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | +| `course_number` | INT | Номер курса | +| `date` | DATE | Дата учебного года | +| `week_number` | INT | Номер недели от начала учебного года | +| `day_of_week` | INT CHECK(1–7) | День недели ISO | +| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | + +#### `student_group_calendar_assignments` — Назначения графиков группам +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `group_id` | BIGINT FK → student_groups (CASCADE) | Учебная группа | +| `academic_year_id` | BIGINT FK → academic_years (CASCADE) | Учебный год | +| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Назначенный график | #### `schedule_rules` — Правила расписания | Колонка | Тип | Описание | @@ -426,11 +511,13 @@ erDiagram 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline +> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных. + ### Текущие миграции | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: справочники, динамическое расписание, тестовые правила, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index c71916b..6261df7 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -103,15 +103,17 @@ frontend/ | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Конструктор правил динамического расписания, сетка пар, учебные годы, семестры, праздники и матрица графика | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar`, `/api/lesson-types` | +| `schedule` | Конструктор правил динамического расписания, сетка пар, учебные годы, семестры и Excel-подобный редактор календарного учебного графика | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/lesson-types` | +| `auditorium-workload` | Загруженность аудиторий: аудитории в строках, временные слоты в столбцах | mock-данные | | `database` | Тенанты | `/api/database` | | `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | ### Особенности админских вкладок -- Вкладка `groups` загружает кафедры и специальности из справочников и при создании группы отправляет `yearStartStudy`; поле `specialityCode` содержит ID выбранной специальности. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. -- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. +- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. +- Вкладка `auditorium-workload` показывает матрицу загруженности: строки — аудитории, столбцы — временные слоты. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. В этом же модуле администратор создаёт календарные графики, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. +- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}`, `PUT/DELETE /api/specialties/{id}` и CRUD профилей `/api/specialties/{id}/profiles`. ### Страница настроек (`/admin/settings/`) diff --git a/docs/README.md b/docs/README.md index 3775cc5..875f486 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ ## Обзор -**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент) и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. +**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент), календарные учебные графики и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. - **Продакшн:** [https://magistr.zuev.company](https://magistr.zuev.company) - **Локальная разработка:** [http://localhost:80](http://localhost:80) @@ -106,6 +106,7 @@ magistr/ |----------|-----------| | [Архитектура](ARCHITECTURE.md) | Общая архитектура, мультитенантность, взаимодействие компонентов | | [Бизнес-логика](BUSINESS_LOGIC.md) | Ролевая модель, правила расписания, управление ресурсами | +| [Календарный учебный график](ACADEMIC_CALENDAR.md) | Модель графиков по специальностям, профилям и группам | | [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции | | [REST API](API.md) | Все эндпоинты с примерами запросов и ответов | | [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг | diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 8ea95ae..ad76478 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -1,4 +1,4 @@ -/* ===== Auditorium Workload Specific Styles ===== */ +/* ===== Стили загруженности аудиторий ===== */ .workload-grid-container { width: 100%; @@ -37,7 +37,8 @@ box-shadow: 0 1px 0 var(--bg-card-border); } -.workload-table .time-cell { +.workload-table .time-cell, +.workload-table .axis-cell { background: var(--bg-input); color: var(--text-secondary); font-weight: 500; @@ -62,7 +63,7 @@ box-shadow: 1px 1px 0 var(--bg-card-border); } -/* Diagonal line using SVG or linear-gradient */ +/* Диагональная линия через linear-gradient */ .workload-table .top-left-cell::before { content: ''; position: absolute; @@ -126,7 +127,7 @@ } .lesson-group { - font-weight: 700; /* Bolder specific for groups request mockup */ + font-weight: 700; /* Акцент на названии группы */ color: var(--text-primary); margin-bottom: 0.1rem; font-size: 0.75rem; @@ -137,7 +138,7 @@ font-size: 0.75rem; } -/* Custom scrollbar adjustments for grid container */ +/* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index c0cb7b0..1cb7e41 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -162,28 +162,96 @@ margin-top: 1rem; } -.matrix-grid { +.calendar-fill-panel { display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + grid-template-columns: repeat(4, minmax(150px, 1fr)) auto; gap: 0.75rem; - margin-top: 1rem; -} - -.matrix-week { - display: grid; - gap: 0.45rem; + align-items: end; + margin: 1rem 0; padding: 0.75rem; background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); } -.matrix-week > span { +.academic-calendar-grid { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.calendar-course-block { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + overflow: hidden; +} + +.calendar-course-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-course-header h3 { + font-size: 1rem; +} + +.calendar-course-header span, +.calendar-week-label { + color: var(--text-secondary); + font-size: 0.78rem; +} + +.calendar-days-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 0.5rem; + padding: 0.75rem; +} + +.calendar-day { + display: grid; + gap: 0.4rem; + min-height: 92px; + padding: 0.55rem; + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.calendar-day-meta { + display: flex; + justify-content: space-between; + gap: 0.35rem; color: var(--text-secondary); font-size: 0.78rem; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; +} + +.calendar-day-activity { + min-width: 0; +} + +.calendar-totals { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.calendar-total-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--chip-color) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--chip-color) 52%, transparent); + color: var(--text-primary); + font-size: 0.8rem; + font-weight: 600; } .muted { @@ -194,12 +262,20 @@ .schedule-slot-row { grid-template-columns: repeat(2, minmax(180px, 1fr)); } + + .calendar-fill-panel { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } } @media (max-width: 640px) { .schedule-slot-row { grid-template-columns: 1fr; } + + .calendar-fill-panel { + grid-template-columns: 1fr; + } } /* Hide Number Arrows */ diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js index bd3dc44..a608e24 100644 --- a/frontend/admin/js/views/auditorium-workload.js +++ b/frontend/admin/js/views/auditorium-workload.js @@ -1,7 +1,7 @@ import { initMultiSelect } from '../utils.js'; export function initAuditoriumWorkload() { - // Initialize date input with current date + // Дата по умолчанию — текущий день. const dateInput = document.getElementById('workload-date'); if (dateInput) { const today = new Date(); @@ -11,20 +11,20 @@ export function initAuditoriumWorkload() { dateInput.value = `${yyyy}-${mm}-${dd}`; } - // Initialize Multi-Selects + // Инициализация мультиселектов. initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes'); initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); - // Populate Filters with Mock/Initial Data + // Заполнение фильтров начальными данными. populateFilters(); - // Render Mock Data for the Grid based on the UI requested layout + // Отрисовка mock-данных в нужной ориентации сетки. renderMockGrid(); } function populateFilters() { - // Buildings + // Корпуса. const buildingsContainer = document.getElementById('building-checkboxes'); const buildings = [ { id: 1, name: "Корпус 1 (Главный)" }, @@ -39,7 +39,7 @@ function populateFilters() { `).join(''); - // Capacities + // Вместимость. const capacityContainer = document.getElementById('capacity-checkboxes'); const capacities = [ { id: 'small', name: "До 30 мест" }, @@ -55,7 +55,7 @@ function populateFilters() { `).join(''); - // Equipment + // Оборудование. const equipmentContainer = document.getElementById('equipment-checkboxes'); const equipmentList = [ { id: 1, name: "Проектор" }, @@ -73,7 +73,7 @@ function populateFilters() { } function renderMockGrid() { - // In future this will be loaded from API + // В будущем данные будут загружаться из API. const timeslots = [ "8:00-9:30", "9:40-11:10", @@ -89,7 +89,7 @@ function renderMockGrid() { "201", "202", "204", "205", "206", "207", "208" ]; - // Mock schedule data mapped by room and time + // Mock-расписание по аудитории и времени. // Ключ: "roomId_timeSlotId", значение: объект занятия const mockSchedule = { "201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." }, @@ -105,37 +105,43 @@ function renderMockGrid() { "205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." }, }; - // Render Headers + // В столбцах отображаются временные слоты, в строках — аудитории. const headerRow = document.getElementById('workload-header-row'); - // Start after the first fixed cell (which is already in HTML) + headerRow.innerHTML = ` + + Время + Аудитория + + `; - auditoriums.forEach(room => { + timeslots.forEach(time => { const th = document.createElement('th'); - th.textContent = room; + th.textContent = time; headerRow.appendChild(th); }); - // Render Body Rows + // Строки таблицы. const tbody = document.getElementById('workload-tbody'); + tbody.innerHTML = ''; - timeslots.forEach((time) => { + auditoriums.forEach((room) => { const tr = document.createElement('tr'); - // Add Time Cell - const tdTime = document.createElement('td'); - tdTime.className = 'time-cell'; - tdTime.textContent = time; - tr.appendChild(tdTime); + // Ячейка аудитории. + const tdRoom = document.createElement('td'); + tdRoom.className = 'axis-cell'; + tdRoom.textContent = room; + tr.appendChild(tdRoom); - // Add Room Cells for this Time - auditoriums.forEach(room => { + // Ячейки временных слотов для аудитории. + timeslots.forEach(time => { const td = document.createElement('td'); const scheduleKey = `${room}_${time}`; const lesson = mockSchedule[scheduleKey]; if (lesson) { - // Render lesson card + // Карточка занятия. td.innerHTML = `
${lesson.subject}
diff --git a/frontend/admin/js/views/departments-data.js b/frontend/admin/js/views/departments-data.js index 2e2e64f..45eca7f 100644 --- a/frontend/admin/js/views/departments-data.js +++ b/frontend/admin/js/views/departments-data.js @@ -7,15 +7,22 @@ export async function initDepartmentsData() { const createDeptForm = document.getElementById('create-department-form'); const createSpecForm = document.getElementById('create-specialty-form'); + const createProfileForm = document.getElementById('create-profile-form'); + const profileSpecialtySelect = document.getElementById('profile-specialty'); + const profilesTbody = document.getElementById('profiles-tbody'); const editDeptForm = document.getElementById('edit-department-form'); const editSpecForm = document.getElementById('edit-specialty-form'); + const editProfileForm = document.getElementById('edit-profile-form'); const editDeptModal = document.getElementById('modal-edit-department'); const editSpecModal = document.getElementById('modal-edit-specialty'); + const editProfileModal = document.getElementById('modal-edit-profile'); const editDeptClose = document.getElementById('modal-edit-department-close'); const editSpecClose = document.getElementById('modal-edit-specialty-close'); + const editProfileClose = document.getElementById('modal-edit-profile-close'); let departments = []; let specialties = []; + let profiles = []; async function loadData() { // Load Departments @@ -30,8 +37,11 @@ export async function initDepartmentsData() { try { specialties = await api.get('/api/specialties'); renderSpecialties(); + renderProfileSpecialtySelect(); + await loadProfiles(); } catch (e) { specTbody.innerHTML = '-'; + profilesTbody.innerHTML = '-'; } } @@ -71,6 +81,52 @@ export async function initDepartmentsData() { `).join(''); } + function renderProfileSpecialtySelect() { + const current = profileSpecialtySelect.value; + profileSpecialtySelect.innerHTML = '' + + specialties.map(s => ``).join(''); + if (current && specialties.some(s => String(s.id) === String(current))) { + profileSpecialtySelect.value = current; + } else if (specialties.length) { + profileSpecialtySelect.value = String(specialties[0].id); + } + syncSelect(profileSpecialtySelect); + } + + async function loadProfiles() { + const specialtyId = profileSpecialtySelect.value; + if (!specialtyId) { + profiles = []; + profilesTbody.innerHTML = 'Выберите специальность'; + return; + } + profilesTbody.innerHTML = 'Загрузка...'; + try { + profiles = await api.get(`/api/specialties/${specialtyId}/profiles`); + renderProfiles(); + } catch (error) { + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderProfiles() { + if (!profiles.length) { + profilesTbody.innerHTML = 'Профили не созданы'; + return; + } + profilesTbody.innerHTML = profiles.map(profile => ` + + ${profile.id} + ${escapeHtml(profile.name)} + ${escapeHtml(profile.description || '-')} + + + + + + `).join(''); + } + createDeptForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-dept-alert'); @@ -141,6 +197,59 @@ export async function initDepartmentsData() { } }); + profileSpecialtySelect.addEventListener('change', loadProfiles); + + createProfileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-profile-alert'); + const specialtyId = profileSpecialtySelect.value; + const name = document.getElementById('profile-name').value.trim(); + const description = document.getElementById('profile-description').value.trim(); + + if (!specialtyId || !name) { + showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error'); + return; + } + + try { + await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description }); + showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success'); + createProfileForm.reset(); + profileSpecialtySelect.value = specialtyId; + syncSelect(profileSpecialtySelect); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error'); + } + }); + + profilesTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-profile'); + const deleteBtn = e.target.closest('.btn-delete-profile'); + + if (editBtn) { + const profile = profiles.find(item => item.id == editBtn.dataset.id); + if (!profile) return; + document.getElementById('edit-profile-id').value = profile.id; + document.getElementById('edit-profile-name').value = profile.name || ''; + document.getElementById('edit-profile-description').value = profile.description || ''; + hideAlert('edit-profile-alert'); + editProfileModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить профиль обучения?')) return; + try { + await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteBtn.dataset.id}`); + showAlert('create-profile-alert', 'Профиль удалён', 'success'); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); + } + } + }); + specTbody.addEventListener('click', async (e) => { const editBtn = e.target.closest('.btn-edit-specialty'); const deleteBtn = e.target.closest('.btn-delete-specialty'); @@ -213,14 +322,45 @@ export async function initDepartmentsData() { } }); + editProfileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-profile-alert'); + const specialtyId = profileSpecialtySelect.value; + const id = document.getElementById('edit-profile-id').value; + const name = document.getElementById('edit-profile-name').value.trim(); + const description = document.getElementById('edit-profile-description').value.trim(); + + if (!specialtyId || !id || !name) { + showAlert('edit-profile-alert', 'Заполните название профиля', 'error'); + return; + } + + try { + await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description }); + editProfileModal.classList.remove('open'); + showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success'); + await loadProfiles(); + } catch (error) { + showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error'); + } + }); + editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open')); editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open')); + editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open')); editDeptModal.addEventListener('click', (e) => { if (e.target === editDeptModal) editDeptModal.classList.remove('open'); }); editSpecModal.addEventListener('click', (e) => { if (e.target === editSpecModal) editSpecModal.classList.remove('open'); }); + editProfileModal.addEventListener('click', (e) => { + if (e.target === editProfileModal) editProfileModal.classList.remove('open'); + }); await loadData(); + + function syncSelect(select) { + select.dispatchEvent(new Event('change', { bubbles: true })); + } } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js old mode 100755 new mode 100644 index a8a65d8..d06cf62 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -8,105 +8,408 @@ export async function initGroups() { const newGroupEfSelect = document.getElementById('new-group-ef'); const newGroupDepartmentSelect = document.getElementById('new-group-department'); const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); + const newGroupProfileSelect = document.getElementById('new-group-profile'); const filterEfSelect = document.getElementById('filter-ef'); + const modalEditGroup = document.getElementById('modal-edit-group'); + const modalEditGroupClose = document.getElementById('modal-edit-group-close'); + const editGroupForm = document.getElementById('edit-group-form'); + const editGroupEfSelect = document.getElementById('edit-group-ef'); + const editGroupDepartmentSelect = document.getElementById('edit-group-department'); + const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code'); + const editGroupProfileSelect = document.getElementById('edit-group-profile'); + const calendarForm = document.getElementById('group-calendar-form'); + const calendarGroupSelect = document.getElementById('calendar-group'); + const calendarYearSelect = document.getElementById('calendar-year'); + const calendarSelect = document.getElementById('calendar-id'); + const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); let allGroups = []; let educationForms = []; let departments = []; let specialties = []; + let profilesBySpecialty = new Map(); + let academicYears = []; + let calendars = []; + let currentAssignments = []; + + bindEvents(); + await loadInitialData(); + + function bindEvents() { + filterEfSelect.addEventListener('change', applyGroupFilter); + newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); + editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); + calendarGroupSelect.addEventListener('change', () => { + populateCalendarSelect(); + loadAssignments(); + }); + calendarYearSelect.addEventListener('change', populateCalendarSelect); + createGroupForm.addEventListener('submit', createGroup); + editGroupForm.addEventListener('submit', updateGroup); + calendarForm.addEventListener('submit', saveAssignment); + groupsTbody.addEventListener('click', handleGroupTableClick); + calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); + modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); + modalEditGroup.addEventListener('click', (event) => { + if (event.target === modalEditGroup) { + modalEditGroup.classList.remove('open'); + } + }); + } async function loadInitialData() { try { - [educationForms, departments, specialties] = await Promise.all([ + [educationForms, departments, specialties, academicYears, calendars] = await Promise.all([ fetchEducationForms(), api.get('/api/departments'), - api.get('/api/specialties') + api.get('/api/specialties'), + api.get('/api/admin/calendar/years'), + api.get('/api/admin/academic-calendars') ]); + await loadProfiles(); populateEfSelects(educationForms); - populateDepartmentSelect(departments); - populateSpecialitySelect(specialties); + populateDepartmentSelects(departments); + populateSpecialitySelects(specialties); + populateYearSelect(); + populateCalendarSelect(); await loadGroups(); - } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки данных'; + } catch (error) { + groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } } + async function loadProfiles() { + const pairs = await Promise.all(specialties.map(async (speciality) => { + const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); + return [String(speciality.id), profiles]; + })); + profilesBySpecialty = new Map(pairs); + } + async function loadGroups() { try { allGroups = await api.get('/api/groups'); applyGroupFilter(); - } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки'; + populateGroupSelect(); + await loadAssignments(); + } catch (error) { + groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function applyGroupFilter() { const filterId = filterEfSelect.value; const filtered = filterId - ? allGroups.filter(g => g.educationFormId == filterId) + ? allGroups.filter(group => group.educationFormId == filterId) : allGroups; renderGroups(filtered); } - filterEfSelect.addEventListener('change', applyGroupFilter); + function renderGroups(groups) { + if (!groups || !groups.length) { + groupsTbody.innerHTML = 'Нет групп'; + return; + } + groupsTbody.innerHTML = groups.map(group => ` + + ${group.id} + ${escapeHtml(group.name)} + ${escapeHtml(String(group.groupSize))} + ${escapeHtml(group.educationFormName)} + ${escapeHtml(departmentLabel(group.departmentId))} + ${group.course || '-'} + ${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))} + ${escapeHtml(group.specialtyProfileName || '-')} + + + + + + `).join(''); + } function populateEfSelects(forms) { - // Group creation select - const currentVal = newGroupEfSelect.value; - newGroupEfSelect.innerHTML = forms.map(ef => - `` - ).join(''); - if (currentVal && forms.find(f => f.id == currentVal)) { - newGroupEfSelect.value = currentVal; - } - syncSelects(newGroupEfSelect); - - // Filter select + populateEfSelect(newGroupEfSelect, forms); + populateEfSelect(editGroupEfSelect, forms); const currentFilter = filterEfSelect.value; filterEfSelect.innerHTML = '' + - forms.map(ef => - `` - ).join(''); + forms.map(form => ``).join(''); if (currentFilter) filterEfSelect.value = currentFilter; syncSelects(filterEfSelect); } - function populateDepartmentSelect(items) { - const currentVal = newGroupDepartmentSelect.value; - if (!items || !items.length) { - newGroupDepartmentSelect.innerHTML = ''; - return; + function populateEfSelect(select, forms) { + const currentVal = select.value; + select.innerHTML = forms.map(form => ``).join(''); + if (currentVal && forms.find(form => form.id == currentVal)) { + select.value = currentVal; } + syncSelects(select); + } - newGroupDepartmentSelect.innerHTML = '' + + function populateDepartmentSelects(items) { + populateDepartmentSelect(newGroupDepartmentSelect, items); + populateDepartmentSelect(editGroupDepartmentSelect, items); + } + + function populateDepartmentSelect(select, items) { + const currentVal = select.value; + select.innerHTML = '' + items.map(department => { const name = department.departmentName || department.name || 'Без названия'; const code = department.departmentCode || department.code || department.id; return ``; }).join(''); if (currentVal && items.find(department => department.id == currentVal)) { - newGroupDepartmentSelect.value = currentVal; + select.value = currentVal; } - syncSelects(newGroupDepartmentSelect); + syncSelects(select); } - function populateSpecialitySelect(items) { - const currentVal = newGroupSpecialitySelect.value; - if (!items || !items.length) { - newGroupSpecialitySelect.innerHTML = ''; + function populateSpecialitySelects(items) { + populateSpecialitySelect(newGroupSpecialitySelect, items); + populateSpecialitySelect(editGroupSpecialitySelect, items); + populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value); + populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value); + } + + function populateSpecialitySelect(select, items) { + const currentVal = select.value; + select.innerHTML = '' + + items.map(speciality => ``).join(''); + if (currentVal && items.find(speciality => speciality.id == currentVal)) { + select.value = currentVal; + } + syncSelects(select); + } + + function populateProfileSelect(select, specialtyId, selectedProfileId = null) { + const profiles = profilesBySpecialty.get(String(specialtyId)) || []; + select.innerHTML = profiles.length + ? '' + profiles.map(profile => + `` + ).join('') + : ''; + if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { + select.value = selectedProfileId; + } + syncSelects(select); + } + + function populateYearSelect() { + calendarYearSelect.innerHTML = '' + + academicYears.map(year => ``).join(''); + if (!calendarYearSelect.value && academicYears.length) { + calendarYearSelect.value = String(academicYears[0].id); + } + syncSelects(calendarYearSelect); + } + + function populateGroupSelect() { + const current = calendarGroupSelect.value; + calendarGroupSelect.innerHTML = '' + + allGroups.map(group => ``).join(''); + if (current && allGroups.some(group => String(group.id) === String(current))) { + calendarGroupSelect.value = current; + } else if (allGroups.length) { + calendarGroupSelect.value = String(allGroups[0].id); + } + syncSelects(calendarGroupSelect); + } + + function populateCalendarSelect() { + const group = selectedCalendarGroup(); + const yearId = calendarYearSelect.value; + const matching = group && yearId + ? calendars.filter(calendar => + String(calendar.academicYearId) === String(yearId) + && String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode) + && String(calendar.specialtyProfileId) === String(group.specialtyProfileId) + ) + : []; + calendarSelect.innerHTML = matching.length + ? '' + matching.map(calendar => + `` + ).join('') + : ''; + syncSelects(calendarSelect); + } + + async function createGroup(event) { + event.preventDefault(); + hideAlert('create-group-alert'); + const payload = readGroupPayload('new'); + if (!payload) return; + + try { + const data = await api.post('/api/groups', payload); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" создана`, 'success'); + createGroupForm.reset(); + syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); + await loadGroups(); + } catch (error) { + showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); + } + } + + async function updateGroup(event) { + event.preventDefault(); + hideAlert('edit-group-alert'); + const id = document.getElementById('edit-group-id').value; + const payload = readGroupPayload('edit'); + if (!payload) return; + + try { + const data = await api.put('/api/groups/' + id, payload); + modalEditGroup.classList.remove('open'); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); + await loadGroups(); + } catch (error) { + showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); + } + } + + function readGroupPayload(prefix) { + const isEdit = prefix === 'edit'; + const alertId = isEdit ? 'edit-group-alert' : 'create-group-alert'; + const name = document.getElementById(`${prefix}-group-name`).value.trim(); + const groupSize = document.getElementById(`${prefix}-group-size`).value; + const educationFormId = (isEdit ? editGroupEfSelect : newGroupEfSelect).value; + const departmentId = (isEdit ? editGroupDepartmentSelect : newGroupDepartmentSelect).value; + const yearStartStudy = document.getElementById(`${prefix}-group-yearStartStudy`).value; + const specialtyId = (isEdit ? editGroupSpecialitySelect : newGroupSpecialitySelect).value; + const specialtyProfileId = (isEdit ? editGroupProfileSelect : newGroupProfileSelect).value; + + if (!name) { showAlert(alertId, 'Введите название группы', 'error'); return null; } + if (!groupSize) { showAlert(alertId, 'Введите размер группы', 'error'); return null; } + if (!educationFormId) { showAlert(alertId, 'Выберите форму обучения', 'error'); return null; } + if (!departmentId) { showAlert(alertId, 'Выберите кафедру', 'error'); return null; } + if (!yearStartStudy) { showAlert(alertId, 'Введите год начала обучения', 'error'); return null; } + if (!specialtyId) { showAlert(alertId, 'Выберите специальность', 'error'); return null; } + if (!specialtyProfileId) { showAlert(alertId, 'Выберите профиль обучения', 'error'); return null; } + + return { + name, + groupSize: Number(groupSize), + educationFormId: Number(educationFormId), + departmentId: Number(departmentId), + yearStartStudy: Number(yearStartStudy), + specialtyId: Number(specialtyId), + specialtyProfileId: Number(specialtyProfileId) + }; + } + + async function handleGroupTableClick(event) { + const btnDelete = event.target.closest('.btn-delete'); + const btnEdit = event.target.closest('.btn-edit-group'); + + if (btnDelete) { + if (!confirm('Удалить группу?')) return; + try { + await api.delete('/api/groups/' + btnDelete.dataset.id); + await loadGroups(); + } catch (error) { + alert(error.message || 'Ошибка удаления'); + } + } + + if (btnEdit) { + openEditGroupModal(btnEdit.dataset.id); + } + } + + function openEditGroupModal(id) { + const group = allGroups.find(item => item.id == id); + if (!group) { + alert('Группа не найдена'); return; } - newGroupSpecialitySelect.innerHTML = '' + - items.map(speciality => { - const name = speciality.specialityName || speciality.name || 'Без названия'; - const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; - return ``; - }).join(''); - if (currentVal && items.find(speciality => speciality.id == currentVal)) { - newGroupSpecialitySelect.value = currentVal; + document.getElementById('edit-group-id').value = group.id; + document.getElementById('edit-group-name').value = group.name || ''; + document.getElementById('edit-group-size').value = group.groupSize || ''; + editGroupEfSelect.value = group.educationFormId || ''; + editGroupDepartmentSelect.value = group.departmentId || ''; + document.getElementById('edit-group-yearStartStudy').value = group.yearStartStudy || ''; + editGroupSpecialitySelect.value = group.specialtyId || group.specialityCode || ''; + populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value, group.specialtyProfileId); + syncSelects(editGroupEfSelect, editGroupDepartmentSelect, editGroupSpecialitySelect, editGroupProfileSelect); + hideAlert('edit-group-alert'); + modalEditGroup.classList.add('open'); + } + + async function saveAssignment(event) { + event.preventDefault(); + hideAlert('group-calendar-alert'); + const groupId = calendarGroupSelect.value; + const academicYearId = calendarYearSelect.value; + const calendarId = calendarSelect.value; + + if (!groupId || !academicYearId || !calendarId) { + showAlert('group-calendar-alert', 'Выберите группу, учебный год и график', 'error'); + return; } - syncSelects(newGroupSpecialitySelect); + + try { + await api.put(`/api/groups/${groupId}/calendar-assignments`, { + academicYearId: Number(academicYearId), + calendarId: Number(calendarId) + }); + showAlert('group-calendar-alert', 'Календарный график назначен', 'success'); + await loadAssignments(); + } catch (error) { + showAlert('group-calendar-alert', error.message || 'Ошибка назначения графика', 'error'); + } + } + + async function loadAssignments() { + const groupId = calendarGroupSelect.value; + if (!groupId) { + currentAssignments = []; + calendarAssignmentsTbody.innerHTML = 'Выберите группу'; + return; + } + + try { + currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`); + renderAssignments(); + } catch (error) { + calendarAssignmentsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderAssignments() { + if (!currentAssignments.length) { + calendarAssignmentsTbody.innerHTML = 'Графики не назначены'; + return; + } + calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => ` + + ${escapeHtml(assignment.academicYearTitle)} + ${escapeHtml(assignment.calendarTitle)} + + + + + `).join(''); + } + + async function handleAssignmentTableClick(event) { + const deleteButton = event.target.closest('.btn-delete-assignment'); + if (!deleteButton) return; + if (!confirm('Удалить назначение графика?')) return; + + try { + await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`); + showAlert('group-calendar-alert', 'Назначение удалено', 'success'); + await loadAssignments(); + } catch (error) { + showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error'); + } + } + + function selectedCalendarGroup() { + return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value)); } function departmentLabel(departmentId) { @@ -123,73 +426,6 @@ export async function initGroups() { return name ? `${code} — ${name}` : code; } - function renderGroups(groups) { - if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; - return; - } - groupsTbody.innerHTML = groups.map(g => ` - - ${g.id} - ${escapeHtml(g.name)} - ${escapeHtml(g.groupSize)} - ${escapeHtml(g.educationFormName)} - ${escapeHtml(departmentLabel(g.departmentId))} - ${g.course || '-'} - ${escapeHtml(specialityLabel(g.specialityCode))} - - `).join(''); - } - - createGroupForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('create-group-alert'); - const name = document.getElementById('new-group-name').value.trim(); - const groupSize = document.getElementById('new-group-size').value; - const educationFormId = newGroupEfSelect.value; - const departmentId = newGroupDepartmentSelect.value; - const yearStartStudy = document.getElementById('new-group-yearStartStudy').value; - const specialityCode = newGroupSpecialitySelect.value; - - if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; } - if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; } - if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; } - if (!departmentId) { showAlert('create-group-alert', 'Выберите кафедру', 'error'); return; } - if (!yearStartStudy) { showAlert('create-group-alert', 'Введите год начала обучения', 'error'); return; } - if (!specialityCode) { showAlert('create-group-alert', 'Выберите специальность', 'error'); return; } - - try { - const data = await api.post('/api/groups', { - name, - groupSize: Number(groupSize), - educationFormId: Number(educationFormId), - departmentId: Number(departmentId), - yearStartStudy: Number(yearStartStudy), - specialityCode: Number(specialityCode) - }); - showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); - createGroupForm.reset(); - syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect); - loadGroups(); - } catch (e) { - showAlert('create-group-alert', e.message || 'Ошибка создания', 'error'); - } - }); - - groupsTbody.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-delete'); - if (!btn) return; - if (!confirm('Удалить группу?')) return; - try { - await api.delete('/api/groups/' + btn.dataset.id); - loadGroups(); - } catch (e) { - alert(e.message || 'Ошибка удаления'); - } - }); - - await loadInitialData(); - function syncSelects(...selects) { selects.filter(Boolean).forEach(select => { select.dispatchEvent(new Event('change', { bubbles: true })); diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 3c867ab..ee38e92 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -22,13 +22,6 @@ const SEMESTER_LABELS = { spring: 'весенний' }; -const ACTIVITY_LABELS = { - THEORY: 'Теория', - EXAM: 'Экзамены', - VACATION: 'Каникулы', - PRACTICE: 'Практика' -}; - export async function initSchedule() { const ruleForm = document.getElementById('schedule-rule-form'); const ruleFormTitle = document.getElementById('schedule-rule-form-title'); @@ -74,20 +67,29 @@ export async function initSchedule() { const semesterEndInput = document.getElementById('semester-end'); const semesterResetButton = document.getElementById('semester-reset'); - const holidayForm = document.getElementById('holiday-form'); - const holidayIdInput = document.getElementById('holiday-id'); - const holidayYearSelect = document.getElementById('holiday-year'); - const holidayDateInput = document.getElementById('holiday-date'); - const holidayDescriptionInput = document.getElementById('holiday-description'); - const holidayResetButton = document.getElementById('holiday-reset'); - const holidaysTbody = document.getElementById('holidays-tbody'); + const calendarForm = document.getElementById('academic-calendar-form'); + const calendarFormTitle = document.getElementById('academic-calendar-form-title'); + const calendarIdInput = document.getElementById('academic-calendar-id'); + const calendarTitleInput = document.getElementById('academic-calendar-title'); + const calendarYearSelect = document.getElementById('academic-calendar-year'); + const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); + const calendarProfileSelect = document.getElementById('academic-calendar-profile'); + const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); + const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); + const calendarResetButton = document.getElementById('academic-calendar-reset'); + const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); + const calendarsTbody = document.getElementById('academic-calendars-tbody'); - const matrixSemesterSelect = document.getElementById('matrix-semester'); - const matrixCourseInput = document.getElementById('matrix-course'); - const matrixSpecialtySelect = document.getElementById('matrix-specialty'); - const matrixLoadButton = document.getElementById('matrix-load'); - const matrixSaveButton = document.getElementById('matrix-save'); - const matrixGrid = document.getElementById('matrix-grid'); + const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); + const editorLoadButton = document.getElementById('calendar-editor-load'); + const editorSaveButton = document.getElementById('calendar-editor-save'); + const fillCourseSelect = document.getElementById('calendar-fill-course'); + const fillStartInput = document.getElementById('calendar-fill-start'); + const fillEndInput = document.getElementById('calendar-fill-end'); + const fillActivitySelect = document.getElementById('calendar-fill-activity'); + const fillApplyButton = document.getElementById('calendar-fill-apply'); + const calendarGrid = document.getElementById('calendar-grid'); + const calendarTotals = document.getElementById('calendar-totals'); let rules = []; let subjects = []; @@ -99,15 +101,19 @@ export async function initSchedule() { let academicYears = []; let semesters = []; let specialties = []; - let holidays = []; + let profilesBySpecialty = new Map(); + let studyForms = []; + let activityTypes = []; + let calendars = []; + let currentGridRows = []; bindEvents(); try { - await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]); + await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]); renderRuleGroups([]); renderRuleSlots([{}]); - await Promise.all([loadRules(), loadHolidays()]); + await Promise.all([loadRules(), loadCalendars()]); } catch (error) { showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); } @@ -116,15 +122,17 @@ export async function initSchedule() { refreshRulesButton?.addEventListener('click', loadRules); refreshSlotsButton?.addEventListener('click', loadTimeSlots); academicYearsRefreshButton?.addEventListener('click', loadYears); + calendarsRefreshButton?.addEventListener('click', loadCalendars); ruleResetButton?.addEventListener('click', resetRuleForm); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); timeSlotResetButton?.addEventListener('click', resetTimeSlotForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm); semesterResetButton?.addEventListener('click', resetSemesterForm); - holidayResetButton?.addEventListener('click', resetHolidayForm); - holidayYearSelect?.addEventListener('change', loadHolidays); - matrixLoadButton?.addEventListener('click', loadMatrix); - matrixSaveButton?.addEventListener('click', saveMatrix); + calendarResetButton?.addEventListener('click', resetCalendarForm); + calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); + editorLoadButton?.addEventListener('click', loadCalendarGrid); + editorSaveButton?.addEventListener('click', saveCalendarGrid); + fillApplyButton?.addEventListener('click', applyCalendarFill); ruleSlotsContainer.addEventListener('click', (event) => { const removeButton = event.target.closest('.schedule-slot-remove'); @@ -134,16 +142,22 @@ export async function initSchedule() { renderRuleSlots(slots.length ? slots : [{}]); }); + calendarGrid.addEventListener('change', (event) => { + if (event.target.matches('.calendar-day-activity')) { + renderCalendarTotals(); + } + }); + ruleForm.addEventListener('submit', saveRule); timeSlotForm.addEventListener('submit', saveTimeSlot); academicYearForm.addEventListener('submit', saveAcademicYear); semesterForm.addEventListener('submit', saveSemester); - holidayForm.addEventListener('submit', saveHoliday); + calendarForm.addEventListener('submit', saveCalendar); rulesTbody.addEventListener('click', handleRuleTableClick); timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick); academicYearsTbody.addEventListener('click', handleAcademicYearTableClick); - holidaysTbody.addEventListener('click', handleHolidayTableClick); + calendarsTbody.addEventListener('click', handleCalendarTableClick); } async function loadBaseLists() { @@ -155,11 +169,19 @@ export async function initSchedule() { api.get('/api/lesson-types'), api.get('/api/specialties') ]); - + await loadProfiles(); populateSubjectSelects(); populateSpecialtySelects(); } + async function loadProfiles() { + const pairs = await Promise.all(specialties.map(async (speciality) => { + const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); + return [String(speciality.id), profiles]; + })); + profilesBySpecialty = new Map(pairs); + } + async function loadRules() { rulesTbody.innerHTML = 'Загрузка...'; try { @@ -171,11 +193,10 @@ export async function initSchedule() { } function renderRules() { - if (!rules || !rules.length) { + if (!rules.length) { rulesTbody.innerHTML = 'Правила расписания не созданы'; return; } - rulesTbody.innerHTML = rules.map(rule => ` ${rule.id} @@ -195,7 +216,6 @@ export async function initSchedule() { function renderRuleSlotsSummary(slots) { if (!slots || !slots.length) return 'Нет слотов'; - return slots.map(slot => { const parity = PARITY_LABELS[slot.parity] || slot.parity || '-'; const parts = [ @@ -237,7 +257,6 @@ export async function initSchedule() { async function saveRule(event) { event.preventDefault(); hideAlert('schedule-rule-alert'); - try { const payload = buildRulePayload(); const id = ruleIdInput.value; @@ -256,26 +275,12 @@ export async function initSchedule() { const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) .map(input => Number(input.value)); const slots = readRuleSlots(true); - - if (!ruleSubjectSelect.value) { - throw new Error('Выберите дисциплину'); - } - if (!ruleSemesterSelect.value) { - throw new Error('Выберите семестр'); - } - if (!ruleActiveFromInput.value) { - throw new Error('Укажите дату начала правила'); - } - if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) { - throw new Error('Укажите количество академических часов'); - } - if (!groupIds.length) { - throw new Error('Выберите хотя бы одну группу'); - } - if (!slots.length) { - throw new Error('Добавьте хотя бы один слот занятия'); - } - + if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); + if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); + if (!ruleActiveFromInput.value) throw new Error('Укажите дату начала правила'); + if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) throw new Error('Укажите количество академических часов'); + if (!groupIds.length) throw new Error('Выберите хотя бы одну группу'); + if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия'); return { id: ruleIdInput.value ? Number(ruleIdInput.value) : null, subjectId: Number(ruleSubjectSelect.value), @@ -316,7 +321,6 @@ export async function initSchedule() { ruleGroupsContainer.innerHTML = '
Нет групп
'; return; } - const selected = new Set((selectedIds || []).map(String)); ruleGroupsContainer.innerHTML = groups.map(group => `
- +
- + - +
- Аудитория - Время + Время + Аудитория
diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html index dd31814..897ee96 100644 --- a/frontend/admin/views/departments-data.html +++ b/frontend/admin/views/departments-data.html @@ -40,6 +40,47 @@ +
+

Профили обучения

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + + + +
IDПрофильОписаниеДействия
Выберите специальность
+
+
+

Создание специальности

@@ -124,3 +165,25 @@
+ + diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index c05b8af..cdba50c 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -1,4 +1,4 @@ - +

Новая группа

@@ -33,6 +33,12 @@
+
+ + +
@@ -60,14 +66,114 @@ Кафедра Курс Специальность + Профиль Действия - Загрузка... + Загрузка... + +
+

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

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + + +
Учебный годГрафикДействия
Выберите группу
+
+
+ + + diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index b5faf14..af7209e 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -213,41 +213,72 @@
-

Праздники и исключения

-
- +
+

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

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

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

+ +
+
- - + + + + + + - + - +
ДатаОписаниеНазваниеУчебный годСпециальностьПрофильФормаКурсов Действия
Выберите учебный годЗагрузка...
@@ -255,27 +286,41 @@
-

Матрица учебного графика

+

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

- -
-
- - -
-
- - -
- - + +
- -
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
From bf02efb8b8e386fb20557b88bcc43f55ac83ae8c Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 1 May 2026 14:02:01 +0300 Subject: [PATCH 04/16] =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 2 +- .../AcademicCalendarController.java | 19 +- .../controller/EducationFormController.java | 15 +- .../app/controller/GroupController.java | 3 + .../magistr/app/dto/CalendarStudyFormDto.java | 8 - .../magistr/app/model/AcademicCalendar.java | 6 +- .../magistr/app/model/CalendarStudyForm.java | 42 - .../AcademicCalendarRepository.java | 2 + .../CalendarStudyFormRepository.java | 11 - .../repository/EducationFormRepository.java | 3 + .../main/resources/db/migration/V1__init.sql | 22 +- docs/ACADEMIC_CALENDAR.md | 8 +- docs/API.md | 5 +- docs/BUSINESS_LOGIC.md | 4 +- docs/DATABASE.md | 19 +- docs/FRONTEND.md | 18 +- frontend/admin/css/components.css | 205 ++++- frontend/admin/index.html | 18 + frontend/admin/js/main.js | 4 + frontend/admin/js/views/academic-calendar.js | 869 ++++++++++++++++++ frontend/admin/js/views/departments-data.js | 140 --- frontend/admin/js/views/groups.js | 1 + frontend/admin/js/views/profiles.js | 192 ++++ frontend/admin/js/views/schedule.js | 619 +------------ frontend/admin/views/academic-calendar.html | 225 +++++ frontend/admin/views/departments-data.html | 63 -- frontend/admin/views/profiles.html | 72 ++ frontend/admin/views/schedule.html | 194 ---- 28 files changed, 1644 insertions(+), 1145 deletions(-) delete mode 100644 backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java delete mode 100644 backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java delete mode 100644 backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java create mode 100644 frontend/admin/js/views/academic-calendar.js create mode 100644 frontend/admin/js/views/profiles.js create mode 100644 frontend/admin/views/academic-calendar.html create mode 100644 frontend/admin/views/profiles.html diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 51ff698..888fb93 100644 --- a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -17,7 +17,7 @@ | `specialties` | Коды и названия специальностей | | `specialty_profiles` | Профили обучения внутри специальности | | `student_groups` | Группы со ссылками на специальность и профиль | -| `calendar_study_forms` | Формы обучения календарного графика | +| `education_forms` | Общий справочник форм обучения для групп и календарных графиков | | `academic_calendar_activity_types` | Коды активностей из Excel-графика | | `academic_calendars` | Календарные графики профиля, формы обучения и учебного года | | `academic_calendar_days` | Дневная сетка графика по курсу и дате | 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 37941e8..4352867 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -21,7 +21,7 @@ public class AcademicCalendarController { private final AcademicYearRepository academicYearRepository; private final SpecialtiesRepository specialtiesRepository; private final SpecialtyProfileRepository profileRepository; - private final CalendarStudyFormRepository studyFormRepository; + private final EducationFormRepository educationFormRepository; private final ScheduleGeneratorService scheduleGeneratorService; public AcademicCalendarController(AcademicCalendarRepository calendarRepository, @@ -30,7 +30,7 @@ public class AcademicCalendarController { AcademicYearRepository academicYearRepository, SpecialtiesRepository specialtiesRepository, SpecialtyProfileRepository profileRepository, - CalendarStudyFormRepository studyFormRepository, + EducationFormRepository educationFormRepository, ScheduleGeneratorService scheduleGeneratorService) { this.calendarRepository = calendarRepository; this.calendarDayRepository = calendarDayRepository; @@ -38,7 +38,7 @@ public class AcademicCalendarController { this.academicYearRepository = academicYearRepository; this.specialtiesRepository = specialtiesRepository; this.profileRepository = profileRepository; - this.studyFormRepository = studyFormRepository; + this.educationFormRepository = educationFormRepository; this.scheduleGeneratorService = scheduleGeneratorService; } @@ -51,13 +51,6 @@ public class AcademicCalendarController { .toList(); } - @GetMapping("/study-forms") - public List getStudyForms() { - return studyFormRepository.findAllByOrderByNameAsc().stream() - .map(form -> new CalendarStudyFormDto(form.getId(), form.getName(), form.getDescription())) - .toList(); - } - @GetMapping("/{id}") public ResponseEntity getCalendar(@PathVariable Long id) { return calendarRepository.findByIdWithDetails(id) @@ -165,8 +158,8 @@ public class AcademicCalendarController { if (!profile.getSpeciality().getId().equals(speciality.getId())) { throw new IllegalArgumentException("Профиль не относится к выбранной специальности"); } - CalendarStudyForm studyForm = studyFormRepository.findById(request.studyFormId()) - .orElseThrow(() -> new IllegalArgumentException("Форма обучения графика не найдена")); + EducationForm studyForm = educationFormRepository.findById(request.studyFormId()) + .orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена")); calendar.setTitle(request.title().trim()); calendar.setAcademicYear(year); @@ -218,7 +211,7 @@ public class AcademicCalendarController { AcademicYear year = calendar.getAcademicYear(); Speciality speciality = calendar.getSpeciality(); SpecialtyProfile profile = calendar.getSpecialtyProfile(); - CalendarStudyForm studyForm = calendar.getStudyForm(); + EducationForm studyForm = calendar.getStudyForm(); return new AcademicCalendarDto( calendar.getId(), calendar.getTitle(), diff --git a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java index dd31357..b8885ce 100755 --- a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java +++ b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java @@ -2,6 +2,7 @@ package com.magistr.app.controller; import com.magistr.app.model.EducationForm; import com.magistr.app.model.StudentGroup; +import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.EducationFormRepository; import com.magistr.app.repository.GroupRepository; import org.springframework.http.ResponseEntity; @@ -16,16 +17,19 @@ public class EducationFormController { private final EducationFormRepository educationFormRepository; private final GroupRepository groupRepository; + private final AcademicCalendarRepository academicCalendarRepository; public EducationFormController(EducationFormRepository educationFormRepository, - GroupRepository groupRepository) { + GroupRepository groupRepository, + AcademicCalendarRepository academicCalendarRepository) { this.educationFormRepository = educationFormRepository; this.groupRepository = groupRepository; + this.academicCalendarRepository = academicCalendarRepository; } @GetMapping public List> getAll() { - return educationFormRepository.findAll().stream() + return educationFormRepository.findAllByOrderByNameAsc().stream() .map(ef -> Map.of("id", ef.getId(), "name", ef.getName())) .toList(); } @@ -54,12 +58,17 @@ public class EducationFormController { return ResponseEntity.notFound().build(); } - // Check if any groups use this education form + // Нельзя удалить форму обучения, пока она используется группами или графиками. List linked = groupRepository.findByEducationFormId(id); if (!linked.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")")); } + long linkedCalendars = academicCalendarRepository.countByStudyFormId(id); + if (linkedCalendars > 0) { + return ResponseEntity.badRequest().body(Map.of( + "message", "Невозможно удалить: есть привязанные календарные графики (" + linkedCalendars + ")")); + } educationFormRepository.deleteById(id); return ResponseEntity.ok(Map.of("message", "Форма обучения удалена")); diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index fdb179e..8462ce7 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -294,6 +294,9 @@ public class GroupController { || !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) { return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы")); } + if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы")); + } StudentGroupCalendarAssignment assignment = assignmentRepository .findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId()) diff --git a/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java b/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java deleted file mode 100644 index 7880b76..0000000 --- a/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.magistr.app.dto; - -public record CalendarStudyFormDto( - Long id, - String name, - String description -) { -} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java index 9d5894d..f528be8 100644 --- a/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java @@ -27,7 +27,7 @@ public class AcademicCalendar { @ManyToOne(optional = false) @JoinColumn(name = "study_form_id", nullable = false) - private CalendarStudyForm studyForm; + private EducationForm studyForm; @Column(name = "course_count", nullable = false) private Integer courseCount; @@ -72,11 +72,11 @@ public class AcademicCalendar { this.specialtyProfile = specialtyProfile; } - public CalendarStudyForm getStudyForm() { + public EducationForm getStudyForm() { return studyForm; } - public void setStudyForm(CalendarStudyForm studyForm) { + public void setStudyForm(EducationForm studyForm) { this.studyForm = studyForm; } diff --git a/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java b/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java deleted file mode 100644 index 49ea74f..0000000 --- a/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name = "calendar_study_forms") -public class CalendarStudyForm { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 100) - private String name; - - @Column(columnDefinition = "TEXT") - private String description; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } -} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java index 5d2257e..fdb8ec8 100644 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java @@ -10,6 +10,8 @@ import java.util.Optional; public interface AcademicCalendarRepository extends JpaRepository { + long countByStudyFormId(Long studyFormId); + @Query(""" select calendar from AcademicCalendar calendar diff --git a/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java b/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java deleted file mode 100644 index 7c7eb25..0000000 --- a/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.CalendarStudyForm; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface CalendarStudyFormRepository extends JpaRepository { - - List findAllByOrderByNameAsc(); -} diff --git a/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java b/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java index dd94980..8d87e5a 100755 --- a/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java @@ -3,9 +3,12 @@ package com.magistr.app.repository; import com.magistr.app.model.EducationForm; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface EducationFormRepository extends JpaRepository { Optional findByName(String name); + + List findAllByOrderByNameAsc(); } diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 4c08017..43f45e6 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -295,17 +295,6 @@ ON CONFLICT (academic_year_id, semester_type) DO NOTHING; CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date); -CREATE TABLE IF NOT EXISTS calendar_study_forms ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -INSERT INTO calendar_study_forms (name, description) VALUES - ('очная', 'Очная форма обучения') -ON CONFLICT (name) DO NOTHING; - CREATE TABLE IF NOT EXISTS academic_calendar_activity_types ( id BIGSERIAL PRIMARY KEY, code VARCHAR(10) UNIQUE NOT NULL, @@ -338,7 +327,7 @@ CREATE TABLE IF NOT EXISTS academic_calendars ( academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, specialty_id BIGINT NOT NULL REFERENCES specialties(id), specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), - study_form_id BIGINT NOT NULL REFERENCES calendar_study_forms(id), + study_form_id BIGINT NOT NULL REFERENCES education_forms(id), course_count INT NOT NULL DEFAULT 4, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -358,7 +347,7 @@ SELECT study_form.id, 4 FROM academic_years ay -CROSS JOIN calendar_study_forms study_form +CROSS JOIN education_forms study_form JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01') JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' WHERE ay.title = '2025-2026' @@ -569,7 +558,6 @@ COMMENT ON TABLE specialty_profiles IS 'Профили обучения внут COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; COMMENT ON TABLE academic_years IS 'Учебные годы'; COMMENT ON TABLE semesters IS 'Семестры учебного года'; -COMMENT ON TABLE calendar_study_forms IS 'Формы обучения календарного учебного графика'; COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; @@ -666,10 +654,6 @@ COMMENT ON COLUMN specialty_profiles.specialty_id IS 'ID специальнос COMMENT ON COLUMN specialty_profiles.name IS 'Название профиля обучения'; COMMENT ON COLUMN specialty_profiles.description IS 'Описание профиля обучения'; -COMMENT ON COLUMN calendar_study_forms.id IS 'ID формы обучения графика'; -COMMENT ON COLUMN calendar_study_forms.name IS 'Название формы обучения графика'; -COMMENT ON COLUMN calendar_study_forms.description IS 'Описание формы обучения графика'; - COMMENT ON COLUMN academic_calendar_activity_types.code IS 'Код активности из календарного графика'; COMMENT ON COLUMN academic_calendar_activity_types.name IS 'Название активности'; COMMENT ON COLUMN academic_calendar_activity_types.allow_schedule IS 'Разрешает ли код генерацию обычного расписания'; @@ -678,7 +662,7 @@ COMMENT ON COLUMN academic_calendars.title IS 'Название календар COMMENT ON COLUMN academic_calendars.academic_year_id IS 'ID учебного года'; COMMENT ON COLUMN academic_calendars.specialty_id IS 'ID специальности'; COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля обучения'; -COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения графика'; +COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms'; COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике'; COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика'; diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md index e8f86fa..9225edb 100644 --- a/docs/ACADEMIC_CALENDAR.md +++ b/docs/ACADEMIC_CALENDAR.md @@ -17,8 +17,9 @@ - `specialties` хранит код и название специальности, например `09.03.04`. - `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей. - `academic_calendars` хранит график профиля, формы обучения и учебного года. +- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет. - `academic_calendar_days` хранит дневную сетку графика по курсу и дате. -- `student_groups` связана со специальностью и профилем обучения. +- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения. - `student_group_calendar_assignments` назначает группе конкретный график на учебный год. Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам. @@ -60,8 +61,8 @@ 1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. 2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. -3. В разделе расписания администратор создаёт учебный год и семестры. -4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения и количество курсов. +3. В разделе календарного графика администратор создаёт учебный год и семестры. +4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. 5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. 6. В разделе групп администратор назначает календарный график группе на нужный учебный год. @@ -72,5 +73,6 @@ - Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке. - Дни вне учебного года или пустые периоды Excel хранятся кодом `=`. - График привязан к профилю обучения, а не только к коду специальности. +- Форма обучения графика должна совпадать с формой обучения группы при назначении графика. - Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры. - Excel-импорт в первой реализации не предусмотрен: редактор заполняет данные через сайт. diff --git a/docs/API.md b/docs/API.md index cfad4e2..8b6c51a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -212,7 +212,6 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | Метод | URL | Назначение | |-------|-----|------------| | `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | -| `GET` | `/api/admin/academic-calendars/study-forms` | Формы обучения графика | | `GET` | `/api/admin/academic-calendars/{id}` | Один график | | `POST` | `/api/admin/academic-calendars` | Создать график | | `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | @@ -232,6 +231,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 } ``` +`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. + **Ячейка сетки графика:** ```json { @@ -558,7 +559,7 @@ CRUD доступен по: ### `DELETE /api/education-forms/{id}` -Удаление формы обучения. **Невозможно**, если к ней привязаны группы. +Удаление формы обучения. **Невозможно**, если к ней привязаны группы или календарные учебные графики. --- diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 637d3b5..4beea74 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -35,10 +35,10 @@ ### Формы обучения (Education Forms) -Уровни/формы обучения для привязки к группам. +Уровни/формы обучения для привязки к группам и календарным учебным графикам. - Предзаполнены: Бакалавриат, Магистратура, Специалитет -- Нельзя удалить форму обучения, если к ней привязаны группы +- Нельзя удалить форму обучения, если к ней привязаны группы или календарные графики ### Учебные группы (Student Groups) diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c7ab763..f03926c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -148,12 +148,6 @@ erDiagram DATE end_date } - calendar_study_forms { - BIGSERIAL id PK - VARCHAR name UK - TEXT description - } - academic_calendar_activity_types { BIGSERIAL id PK VARCHAR code UK @@ -244,7 +238,7 @@ erDiagram semesters ||--o{ schedule_rules : "semester_id" specialties ||--o{ academic_calendars : "specialty_id" specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" - calendar_study_forms ||--o{ academic_calendars : "study_form_id" + education_forms ||--o{ academic_calendars : "study_form_id" academic_calendars ||--o{ academic_calendar_days : "calendar_id" academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" @@ -309,6 +303,8 @@ erDiagram | `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) | | `description` | TEXT | Описание | +Эта таблица используется и группами (`student_groups.education_form_id`), и календарными учебными графиками (`academic_calendars.study_form_id`). Отдельного справочника форм обучения для календарей нет. + #### `student_groups` — Учебные группы | Колонка | Тип | Описание | |---------|-----|----------| @@ -420,13 +416,6 @@ erDiagram | `start_date` | DATE | Дата начала, от неё считается неделя 1 | | `end_date` | DATE | Дата окончания | -#### `calendar_study_forms` — Формы обучения календарного графика -| Колонка | Тип | Описание | -|---------|-----|----------| -| `id` | BIGSERIAL PK | ID | -| `name` | VARCHAR(100) UNIQUE | Форма обучения графика, seed: `очная` | -| `description` | TEXT | Описание | - #### `academic_calendar_activity_types` — Коды активностей графика | Колонка | Тип | Описание | |---------|-----|----------| @@ -446,7 +435,7 @@ erDiagram | `academic_year_id` | BIGINT FK → academic_years | Учебный год | | `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | -| `study_form_id` | BIGINT FK → calendar_study_forms | Форма обучения графика | +| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника | | `course_count` | INT CHECK(> 0) | Количество курсов в сетке | | `created_at` | TIMESTAMP | Дата создания | | `updated_at` | TIMESTAMP | Дата обновления | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6261df7..6b2a1b6 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -42,7 +42,9 @@ frontend/ │ │ ├── subjects.js # Управление дисциплинами │ │ ├── equipments.js # Управление оборудованием │ │ ├── edu-forms.js # Формы обучения -│ │ ├── schedule.js # Конструктор правил, сетки пар и учебного графика +│ │ ├── profiles.js # Профили обучения специальностей +│ │ ├── schedule.js # Конструктор правил расписания и сетки пар +│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── database.js # Управление тенантами │ │ └── departments-data.js # Создание кафедры/специальности │ ├── views/ # HTML-шаблоны представлений @@ -52,7 +54,9 @@ frontend/ │ │ ├── subjects.html │ │ ├── equipments.html │ │ ├── edu-forms.html +│ │ ├── profiles.html │ │ ├── schedule.html +│ │ ├── academic-calendar.html │ │ ├── database.html │ │ └── departments-data.html │ │ @@ -85,6 +89,7 @@ frontend/
Пользователи Группы Расписание занятий +Календарный график ``` При клике на пункт меню `main.js`: @@ -100,10 +105,12 @@ frontend/ | `users` | CRUD пользователей | `/api/users` | | `groups` | CRUD групп | `/api/groups` | | `edu-forms` | Формы обучения | `/api/education-forms` | +| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Конструктор правил динамического расписания, сетка пар, учебные годы, семестры и Excel-подобный редактор календарного учебного графика | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/lesson-types` | +| `schedule` | Конструктор правил динамического расписания и сетка пар | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` | +| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `auditorium-workload` | Загруженность аудиторий: аудитории в строках, временные слоты в столбцах | mock-данные | | `database` | Тенанты | `/api/database` | | `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | @@ -112,8 +119,11 @@ frontend/ - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `auditorium-workload` показывает матрицу загруженности: строки — аудитории, столбцы — временные слоты. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. В этом же модуле администратор создаёт календарные графики, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. -- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}`, `PUT/DELETE /api/specialties/{id}` и CRUD профилей `/api/specialties/{id}/profiles`. +- Вкладка `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}`. ### Страница настроек (`/admin/settings/`) diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 1cb7e41..bc05ef2 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -199,40 +199,138 @@ font-size: 1rem; } -.calendar-course-header span, -.calendar-week-label { +.calendar-course-header span { color: var(--text-secondary); font-size: 0.78rem; } -.calendar-days-grid { +.calendar-semesters-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); - gap: 0.5rem; + gap: 0.75rem; padding: 0.75rem; } -.calendar-day { - display: grid; - gap: 0.4rem; - min-height: 92px; - padding: 0.55rem; - background: var(--bg-card); +.calendar-semester-block { border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); + background: var(--bg-card); + overflow: hidden; } -.calendar-day-meta { +.calendar-semester-header { display: flex; + align-items: center; justify-content: space-between; - gap: 0.35rem; + gap: 1rem; + min-height: 36px; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-semester-header h4 { + margin: 0; + font-size: 0.86rem; +} + +.calendar-semester-header span { color: var(--text-secondary); - font-size: 0.78rem; - font-weight: 600; + font-size: 0.72rem; + white-space: nowrap; +} + +.calendar-semester-table-wrap { + overflow-x: auto; + background: var(--bg-primary); +} + +.calendar-semester-table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 0.68rem; +} + +.calendar-semester-table th, +.calendar-semester-table td { + border: 1px solid var(--bg-card-border); +} + +.calendar-semester-table thead th { + position: sticky; + top: 0; + z-index: 1; + height: 22px; + min-width: 26px; + padding: 0 0.2rem; + background: var(--bg-input); + color: var(--text-secondary); + font-weight: 700; + text-align: center; +} + +.calendar-semester-table .calendar-day-head { + position: sticky; + left: 0; + z-index: 2; + width: 36px; + min-width: 36px; + background: var(--bg-input); + color: var(--text-secondary); + font-weight: 700; + text-align: center; +} + +.calendar-day { + position: relative; + width: 26px; + min-width: 26px; + height: 24px; + min-height: 24px; + padding: 0; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 9%, var(--bg-card)); + cursor: pointer; + outline: none; + box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)); + text-align: center; + transition: background var(--transition), box-shadow var(--transition), transform var(--transition); +} + +.calendar-day:hover, +.calendar-day:focus-visible { + background: color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card)); + box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)), 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 36%, transparent); + transform: scale(1.08); + z-index: 3; +} + +.calendar-day-code { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--text-primary); + font-size: 0.68rem; + line-height: 1; + font-weight: 800; } .calendar-day-activity { - min-width: 0; + display: none !important; +} + +.calendar-empty-day { + width: 26px; + min-width: 26px; + height: 24px; + background: color-mix(in srgb, var(--bg-input) 65%, transparent); +} + +.calendar-empty-semester { + padding: 0.75rem; + color: var(--text-secondary); + font-size: 0.82rem; } .calendar-totals { @@ -254,6 +352,71 @@ font-weight: 600; } +.calendar-day-dialog-card { + max-width: 560px; +} + +.calendar-day-dialog-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0.75rem 0; +} + +.calendar-day-dialog-grid div { + padding: 0.75rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.calendar-day-dialog-grid span { + display: block; + color: var(--text-secondary); + font-size: 0.78rem; + margin-bottom: 0.25rem; +} + +.calendar-day-dialog-grid strong { + font-size: 0.95rem; +} + +.calendar-day-dialog-activity { + display: flex; + gap: 0.8rem; + align-items: center; + margin: 0.8rem 0 1rem; + padding: 0.75rem; + border-radius: var(--radius-sm); + background: var(--bg-input); + border: 1px solid var(--bg-card-border); +} + +.calendar-day-dialog-code { + width: 46px; + min-width: 46px; + height: 46px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 20%, transparent); + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 55%, transparent); + font-weight: 800; +} + +.calendar-day-dialog-activity p { + margin: 0.2rem 0 0; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.profiles-context { + color: var(--text-secondary); + font-weight: 600; + margin: -0.25rem 0 0.75rem; +} + .muted { color: var(--text-secondary); } @@ -266,6 +429,12 @@ .calendar-fill-panel { grid-template-columns: repeat(2, minmax(180px, 1fr)); } + + .calendar-semester-header { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } } @media (max-width: 640px) { @@ -276,6 +445,10 @@ .calendar-fill-panel { grid-template-columns: 1fr; } + + .calendar-day-dialog-grid { + grid-template-columns: 1fr; + } } /* Hide Number Arrows */ diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 3881f85..361f729 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -60,6 +60,14 @@ Создание кафедры/специальности + + + + + + + Профили обучения + @@ -110,6 +118,16 @@ Расписание занятий + + + + + + + + + Календарный график + diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 596d309..dabb058 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -28,6 +28,8 @@ import {initSchedule} from "./views/schedule.js"; import {initDatabase} from "./views/database.js"; import {initDepartmentsData} from "./views/departments-data.js"; import {initAuditoriumWorkload} from "./views/auditorium-workload.js"; +import {initProfiles} from "./views/profiles.js"; +import {initAcademicCalendar} from "./views/academic-calendar.js"; // Configuration const ROUTES = { @@ -38,9 +40,11 @@ const ROUTES = { 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 }, }; let currentTab = null; diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js new file mode 100644 index 0000000..eaaaad6 --- /dev/null +++ b/frontend/admin/js/views/academic-calendar.js @@ -0,0 +1,869 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +const DAY_OPTIONS = [ + { value: 1, label: 'Понедельник' }, + { value: 2, label: 'Вторник' }, + { value: 3, label: 'Среда' }, + { value: 4, label: 'Четверг' }, + { value: 5, label: 'Пятница' }, + { value: 6, label: 'Суббота' }, + { value: 7, label: 'Воскресенье' } +]; + +const SEMESTER_LABELS = { + autumn: 'осенний', + spring: 'весенний' +}; + +export async function initAcademicCalendar() { + const academicYearForm = document.getElementById('academic-year-form'); + const academicYearFormTitle = document.getElementById('academic-year-form-title'); + const academicYearIdInput = document.getElementById('academic-year-id'); + const academicYearTitleInput = document.getElementById('academic-year-title'); + const academicYearStartInput = document.getElementById('academic-year-start'); + const academicYearEndInput = document.getElementById('academic-year-end'); + const academicYearResetButton = document.getElementById('academic-year-reset'); + const academicYearsTbody = document.getElementById('academic-years-tbody'); + const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); + + const semesterForm = document.getElementById('semester-form'); + const semesterFormTitle = document.getElementById('semester-form-title'); + const semesterIdInput = document.getElementById('semester-id'); + const semesterYearSelect = document.getElementById('semester-year'); + const semesterTypeSelect = document.getElementById('semester-type'); + const semesterStartInput = document.getElementById('semester-start'); + const semesterEndInput = document.getElementById('semester-end'); + const semesterResetButton = document.getElementById('semester-reset'); + + const calendarForm = document.getElementById('academic-calendar-form'); + const calendarFormTitle = document.getElementById('academic-calendar-form-title'); + const calendarIdInput = document.getElementById('academic-calendar-id'); + const calendarTitleInput = document.getElementById('academic-calendar-title'); + const calendarYearSelect = document.getElementById('academic-calendar-year'); + const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); + const calendarProfileSelect = document.getElementById('academic-calendar-profile'); + const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); + const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); + const calendarResetButton = document.getElementById('academic-calendar-reset'); + const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); + const calendarsTbody = document.getElementById('academic-calendars-tbody'); + + const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); + const editorLoadButton = document.getElementById('calendar-editor-load'); + const editorSaveButton = document.getElementById('calendar-editor-save'); + const fillCourseSelect = document.getElementById('calendar-fill-course'); + const fillStartInput = document.getElementById('calendar-fill-start'); + const fillEndInput = document.getElementById('calendar-fill-end'); + const fillActivitySelect = document.getElementById('calendar-fill-activity'); + const fillApplyButton = document.getElementById('calendar-fill-apply'); + const calendarGrid = document.getElementById('calendar-grid'); + const calendarTotals = document.getElementById('calendar-totals'); + const calendarDayDialog = document.getElementById('calendar-day-dialog'); + const calendarDayDialogClose = document.getElementById('calendar-day-dialog-close'); + const calendarDayDialogDate = document.getElementById('calendar-day-dialog-date'); + const calendarDayDialogCourse = document.getElementById('calendar-day-dialog-course'); + const calendarDayDialogWeek = document.getElementById('calendar-day-dialog-week'); + const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day'); + const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary'); + const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity'); + + let academicYears = []; + let semesters = []; + let specialties = []; + let profilesBySpecialty = new Map(); + let studyForms = []; + let activityTypes = []; + let calendars = []; + let activeCalendarDay = null; + + bindEvents(); + + try { + await Promise.all([loadBaseLists(), loadYears(), loadStudyForms(), loadActivityTypes()]); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error'); + } + + function bindEvents() { + academicYearsRefreshButton?.addEventListener('click', loadYears); + calendarsRefreshButton?.addEventListener('click', loadCalendars); + academicYearResetButton?.addEventListener('click', resetAcademicYearForm); + semesterResetButton?.addEventListener('click', resetSemesterForm); + calendarResetButton?.addEventListener('click', resetCalendarForm); + calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); + editorLoadButton?.addEventListener('click', loadCalendarGrid); + editorSaveButton?.addEventListener('click', saveCalendarGrid); + fillApplyButton?.addEventListener('click', applyCalendarFill); + + calendarGrid?.addEventListener('change', (event) => { + if (event.target.matches('.calendar-day-activity')) { + updateCalendarDayVisual(event.target.closest('.calendar-day')); + renderCalendarTotals(); + } + }); + calendarGrid?.addEventListener('click', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) openCalendarDayDialog(day); + }); + calendarGrid?.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const day = event.target.closest('.calendar-day'); + if (!day) return; + event.preventDefault(); + openCalendarDayDialog(day); + }); + calendarDayDialogClose?.addEventListener('click', closeCalendarDayDialog); + calendarDayDialog?.addEventListener('click', (event) => { + if (event.target === calendarDayDialog) closeCalendarDayDialog(); + }); + calendarDayDialogActivitySelect?.addEventListener('change', () => { + if (!activeCalendarDay) return; + const select = activeCalendarDay.querySelector('.calendar-day-activity'); + select.value = calendarDayDialogActivitySelect.value; + updateCalendarDayVisual(activeCalendarDay); + updateCalendarDayDialogSummary(activeCalendarDay); + renderCalendarTotals(); + }); + + academicYearForm?.addEventListener('submit', saveAcademicYear); + semesterForm?.addEventListener('submit', saveSemester); + calendarForm?.addEventListener('submit', saveCalendar); + + academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick); + calendarsTbody?.addEventListener('click', handleCalendarTableClick); + } + + async function loadBaseLists() { + specialties = await api.get('/api/specialties'); + await loadProfiles(); + populateSpecialtySelects(); + } + + async function loadProfiles() { + const pairs = await Promise.all(specialties.map(async (speciality) => { + const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); + return [String(speciality.id), profiles]; + })); + profilesBySpecialty = new Map(pairs); + } + + async function loadYears() { + academicYearsTbody.innerHTML = '
Загрузка...
'; + try { + academicYears = await api.get('/api/admin/calendar/years'); + semesters = academicYears.flatMap(year => year.semesters || []); + renderAcademicYears(); + populateYearSelects(); + } catch (error) { + academicYearsTbody.innerHTML = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; + throw error; + } + } + + function renderAcademicYears() { + if (!academicYears.length) { + academicYearsTbody.innerHTML = '
Учебные годы не созданы
'; + return; + } + academicYearsTbody.innerHTML = academicYears.map(year => ` +
+ ${escapeHtml(year.title)} + ${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)} + ${renderSemesterList(year.semesters || [])} + + + + +
+ `).join(''); + } + + function renderSemesterList(items) { + if (!items.length) return '
Нет семестров'; + return items.map(semester => ` +
+ ${escapeHtml(semesterLabel(semester))} + +
+ `).join(''); + } + + async function saveAcademicYear(event) { + event.preventDefault(); + hideAlert('academic-year-alert'); + const payload = { + title: academicYearTitleInput.value.trim(), + startDate: academicYearStartInput.value, + endDate: academicYearEndInput.value + }; + try { + if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); + const id = academicYearIdInput.value; + await (id + ? api.put('/api/admin/calendar/years/' + id, payload) + : api.post('/api/admin/calendar/years', payload)); + showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); + resetAcademicYearForm(); + await loadYears(); + await loadCalendars(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); + } + } + + async function handleAcademicYearTableClick(event) { + const editYearButton = event.target.closest('.btn-edit-year'); + const deleteYearButton = event.target.closest('.btn-delete-year'); + const editSemesterButton = event.target.closest('.btn-edit-semester'); + + if (editYearButton) { + const year = academicYears.find(item => item.id == editYearButton.dataset.id); + if (!year) return; + academicYearFormTitle.textContent = 'Редактирование учебного года'; + academicYearIdInput.value = year.id; + academicYearTitleInput.value = year.title || ''; + academicYearStartInput.value = year.startDate || ''; + academicYearEndInput.value = year.endDate || ''; + hideAlert('academic-year-alert'); + academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (editSemesterButton) { + const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); + if (!semester) return; + semesterFormTitle.textContent = 'Редактирование семестра'; + semesterIdInput.value = semester.id; + semesterYearSelect.value = semester.academicYearId || ''; + semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); + semesterStartInput.value = semester.startDate || ''; + semesterEndInput.value = semester.endDate || ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteYearButton) { + if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; + try { + await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); + showAlert('academic-year-alert', 'Учебный год удалён', 'success'); + await loadYears(); + await loadCalendars(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); + } + } + } + + function resetAcademicYearForm() { + academicYearForm.reset(); + academicYearFormTitle.textContent = 'Учебный год'; + academicYearIdInput.value = ''; + hideAlert('academic-year-alert'); + } + + async function saveSemester(event) { + event.preventDefault(); + hideAlert('semester-alert'); + const yearId = semesterYearSelect.value; + const payload = { + semesterType: semesterTypeSelect.value, + startDate: semesterStartInput.value, + endDate: semesterEndInput.value + }; + try { + if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { + throw new Error('Заполните учебный год, тип и даты семестра'); + } + const id = semesterIdInput.value; + await (id + ? api.put('/api/admin/calendar/semesters/' + id, payload) + : api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload)); + showAlert('semester-alert', 'Семестр сохранён', 'success'); + resetSemesterForm(); + await loadYears(); + } catch (error) { + showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error'); + } + } + + function resetSemesterForm() { + semesterForm.reset(); + semesterFormTitle.textContent = 'Семестр'; + semesterIdInput.value = ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + } + + async function loadStudyForms() { + studyForms = await api.get('/api/education-forms'); + populateStudyFormSelect(); + } + + async function loadActivityTypes() { + activityTypes = await api.get('/api/admin/calendar/activity-types'); + populateActivitySelects(); + } + + async function loadCalendars() { + calendarsTbody.innerHTML = 'Загрузка...'; + try { + calendars = await api.get('/api/admin/academic-calendars'); + renderCalendars(); + populateEditorCalendarSelect(); + } catch (error) { + calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + throw error; + } + } + + function renderCalendars() { + if (!calendars.length) { + calendarsTbody.innerHTML = 'Календарные графики не созданы'; + return; + } + calendarsTbody.innerHTML = calendars.map(calendar => ` + + ${escapeHtml(calendar.title)} + ${escapeHtml(calendar.academicYearTitle || '-')} + ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} + ${escapeHtml(calendar.specialtyProfileName || '-')} + ${escapeHtml(calendar.studyFormName || '-')} + ${escapeHtml(String(calendar.courseCount || '-'))} + + + + + + + `).join(''); + } + + async function saveCalendar(event) { + event.preventDefault(); + hideAlert('academic-calendar-alert'); + const payload = { + id: calendarIdInput.value ? Number(calendarIdInput.value) : null, + title: calendarTitleInput.value.trim(), + academicYearId: Number(calendarYearSelect.value), + specialtyId: Number(calendarSpecialtySelect.value), + specialtyProfileId: Number(calendarProfileSelect.value), + studyFormId: Number(calendarStudyFormSelect.value), + courseCount: Number(calendarCourseCountInput.value) + }; + try { + if (!payload.title || !payload.academicYearId || !payload.specialtyId + || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { + throw new Error('Заполните все поля календарного графика'); + } + const id = calendarIdInput.value; + const saved = id + ? await api.put('/api/admin/academic-calendars/' + id, payload) + : await api.post('/api/admin/academic-calendars', payload); + showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); + resetCalendarForm(); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error'); + } + } + + async function handleCalendarTableClick(event) { + const editButton = event.target.closest('.btn-edit-calendar'); + const gridButton = event.target.closest('.btn-open-calendar-grid'); + const deleteButton = event.target.closest('.btn-delete-calendar'); + + if (editButton) { + const calendar = calendars.find(item => item.id == editButton.dataset.id); + if (!calendar) return; + calendarFormTitle.textContent = 'Редактирование календарного графика'; + calendarIdInput.value = calendar.id; + calendarTitleInput.value = calendar.title || ''; + calendarYearSelect.value = calendar.academicYearId || ''; + calendarSpecialtySelect.value = calendar.specialtyId || ''; + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); + calendarStudyFormSelect.value = calendar.studyFormId || ''; + calendarCourseCountInput.value = calendar.courseCount || 4; + syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); + hideAlert('academic-calendar-alert'); + calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (gridButton) { + editorCalendarSelect.value = gridButton.dataset.id; + syncSelects(editorCalendarSelect); + await loadCalendarGrid(); + calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteButton) { + if (!confirm('Удалить календарный график?')) return; + try { + await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); + showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error'); + } + } + } + + function resetCalendarForm() { + calendarForm.reset(); + calendarFormTitle.textContent = 'Календарный учебный график'; + calendarIdInput.value = ''; + calendarCourseCountInput.value = 4; + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); + syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); + hideAlert('academic-calendar-alert'); + } + + async function loadCalendarGrid() { + hideAlert('calendar-editor-alert'); + const calendar = selectedEditorCalendar(); + if (!calendar) { + showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); + return; + } + try { + const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); + const gridRows = rows.length ? rows : buildDefaultGrid(calendar); + renderCalendarGrid(gridRows); + showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); + } catch (error) { + showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); + } + } + + async function saveCalendarGrid() { + hideAlert('calendar-editor-alert'); + const calendar = selectedEditorCalendar(); + if (!calendar) { + showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); + return; + } + const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ + calendarId: calendar.id, + courseNumber: Number(day.dataset.course), + date: day.dataset.date, + weekNumber: Number(day.dataset.week), + dayOfWeek: Number(day.dataset.day), + activityTypeId: Number(day.querySelector('.calendar-day-activity').value) + })); + if (!rows.length) { + showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error'); + return; + } + try { + await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); + showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); + await loadCalendarGrid(); + } catch (error) { + showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error'); + } + } + + function applyCalendarFill() { + hideAlert('calendar-editor-alert'); + const activityId = fillActivitySelect.value; + const start = fillStartInput.value; + const end = fillEndInput.value; + if (!activityId || !start || !end) { + showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); + return; + } + if (end < start) { + showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); + return; + } + const course = fillCourseSelect.value; + let changed = 0; + calendarGrid.querySelectorAll('.calendar-day').forEach(day => { + const sameCourse = !course || day.dataset.course === course; + const inRange = day.dataset.date >= start && day.dataset.date <= end; + if (sameCourse && inRange) { + day.querySelector('.calendar-day-activity').value = activityId; + updateCalendarDayVisual(day); + changed += 1; + } + }); + renderCalendarTotals(); + showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); + } + + function renderCalendarGrid(rows) { + const grouped = groupBy(rows, row => row.courseNumber); + calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` +
+
+

${course} курс

+ ${courseRows.length} дней +
+
+ ${renderCourseSemesters(Number(course), courseRows)} +
+
+ `).join(''); + populateFillCourseSelect(); + renderCalendarTotals(); + } + + function renderCourseSemesters(course, courseRows) { + const calendar = selectedEditorCalendar(); + const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); + return semestersForCourse.map(block => ` +
+
+

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

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

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

+
+ `; + } + + function updateCalendarDayVisual(day) { + if (!day) return; + const select = day.querySelector('.calendar-day-activity'); + const activity = activityById(select.value); + const code = activity?.code || '-'; + const color = activity?.colorCode || '#64748b'; + day.style.setProperty('--activity-color', color); + day.querySelector('.calendar-day-code').textContent = code; + day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); + day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`); + } + + function buildDefaultGrid(calendar) { + const rows = []; + const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); + for (let course = 1; course <= calendar.courseCount; course += 1) { + dates.forEach((date, index) => { + rows.push({ + id: null, + calendarId: calendar.id, + courseNumber: course, + date: date.iso, + weekNumber: Math.floor(index / 7) + 1, + dayOfWeek: date.dayOfWeek, + activityTypeId: theoryActivityId(), + activityCode: 'Т' + }); + }); + } + return rows; + } + + function semesterBlocksForCourse(calendar, course, courseRows) { + const sortedRows = [...courseRows].sort((a, b) => String(a.date).localeCompare(String(b.date))); + const year = academicYears.find(item => String(item.id) === String(calendar?.academicYearId)); + const yearSemesters = [...(year?.semesters || [])] + .sort((a, b) => String(a.startDate).localeCompare(String(b.startDate))); + + if (!yearSemesters.length) { + return [{ + semesterNumber: `${course * 2 - 1}-${course * 2}`, + caption: dateRangeCaption(sortedRows), + rows: sortedRows + }]; + } + + return yearSemesters.map((semester, index) => { + const nextSemester = yearSemesters[index + 1]; + const startDate = index === 0 + ? minIso(calendar.academicYearStartDate, semester.startDate) + : semester.startDate; + const endDate = nextSemester + ? previousIsoDate(nextSemester.startDate) + : maxIso(calendar.academicYearEndDate, semester.endDate); + const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate); + return { + semesterNumber: course * 2 - 1 + index, + caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`, + rows + }; + }); + } + + function renderCalendarTotals() { + const counts = new Map(); + calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { + const activity = activityById(select.value); + const key = activity ? activity.code : '-'; + counts.set(key, (counts.get(key) || 0) + 1); + }); + calendarTotals.innerHTML = Array.from(counts.entries()) + .sort(([a], [b]) => a.localeCompare(b, 'ru')) + .map(([code, count]) => { + const activity = activityTypes.find(item => item.code === code); + return `${escapeHtml(code)}: ${count}`; + }).join(''); + } + + function populateYearSelects() { + const yearOptions = '' + + academicYears.map(year => ``).join(''); + semesterYearSelect.innerHTML = yearOptions; + calendarYearSelect.innerHTML = yearOptions; + syncSelects(semesterYearSelect, calendarYearSelect); + } + + function populateSpecialtySelects() { + calendarSpecialtySelect.innerHTML = '' + + specialties.map(speciality => ``).join(''); + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); + syncSelects(calendarSpecialtySelect); + } + + function populateProfileSelect(select, specialtyId, selectedProfileId = null) { + const profiles = profilesBySpecialty.get(String(specialtyId)) || []; + select.innerHTML = profiles.length + ? '' + profiles.map(profile => + `` + ).join('') + : ''; + if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { + select.value = selectedProfileId; + } + syncSelects(select); + } + + function populateStudyFormSelect() { + calendarStudyFormSelect.innerHTML = '' + + studyForms.map(form => ``).join(''); + syncSelects(calendarStudyFormSelect); + } + + function populateActivitySelects() { + const activityOptions = '' + + activityTypes.map(type => ``).join(''); + fillActivitySelect.innerHTML = activityOptions; + syncSelects(fillActivitySelect); + } + + function populateEditorCalendarSelect() { + const current = editorCalendarSelect.value; + editorCalendarSelect.innerHTML = '' + + calendars.map(calendar => ``).join(''); + if (current && calendars.some(calendar => String(calendar.id) === String(current))) { + editorCalendarSelect.value = current; + } + syncSelects(editorCalendarSelect); + } + + function populateFillCourseSelect() { + const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); + fillCourseSelect.innerHTML = '' + + Array.from(courses).sort((a, b) => Number(a) - Number(b)) + .map(course => ``).join(''); + syncSelects(fillCourseSelect); + } + + function options(items, selectedValue) { + const selected = selectedValue == null ? '' : String(selectedValue); + return '' + items.map(item => { + const value = String(item.value); + return ``; + }).join(''); + } + + function toActivityOption(type) { + return { value: type.id, label: `${type.code} - ${type.name}` }; + } + + function selectedEditorCalendar() { + return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); + } + + function theoryActivityId() { + return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; + } + + function activityIdByCode(code) { + return activityTypes.find(type => type.code === code)?.id || null; + } + + function activityById(id) { + return activityTypes.find(type => String(type.id) === String(id)); + } + + function semesterLabel(semester) { + const type = normalizeSemesterType(semester.semesterType); + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; + } + + function normalizeSemesterType(type) { + return String(type || '').toLowerCase(); + } + + function specialityLabel(speciality) { + const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; + const name = speciality.specialityName || speciality.name || ''; + return name ? `${code} - ${name}` : String(code); + } + + function dayLabel(value) { + return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; + } + + function shortDayLabel(value) { + const label = dayLabel(value); + return label === '-' ? '-' : label.slice(0, 2); + } + + function formatShortDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}`; + } + + function formatLongDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; + } + + function dateRangeCaption(rows) { + if (!rows.length) return ''; + const dates = rows.map(row => row.date).sort(); + return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; + } + + function minIso(a, b) { + return String(a) <= String(b) ? a : b; + } + + function maxIso(a, b) { + return String(a) >= String(b) ? a : b; + } + + function previousIsoDate(value) { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() - 1); + return date.toISOString().slice(0, 10); + } + + function enumerateDates(startIso, endIso) { + const result = []; + const current = new Date(`${startIso}T00:00:00Z`); + const end = new Date(`${endIso}T00:00:00Z`); + while (current <= end) { + const iso = current.toISOString().slice(0, 10); + const jsDay = current.getUTCDay(); + result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); + current.setUTCDate(current.getUTCDate() + 1); + } + return result; + } + + function groupBy(items, keyFn) { + const grouped = new Map(); + items.forEach(item => { + const key = keyFn(item); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(item); + }); + return grouped; + } + + function syncSelects(...selects) { + selects.filter(Boolean).forEach(select => { + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } +} diff --git a/frontend/admin/js/views/departments-data.js b/frontend/admin/js/views/departments-data.js index 45eca7f..2e2e64f 100644 --- a/frontend/admin/js/views/departments-data.js +++ b/frontend/admin/js/views/departments-data.js @@ -7,22 +7,15 @@ export async function initDepartmentsData() { const createDeptForm = document.getElementById('create-department-form'); const createSpecForm = document.getElementById('create-specialty-form'); - const createProfileForm = document.getElementById('create-profile-form'); - const profileSpecialtySelect = document.getElementById('profile-specialty'); - const profilesTbody = document.getElementById('profiles-tbody'); const editDeptForm = document.getElementById('edit-department-form'); const editSpecForm = document.getElementById('edit-specialty-form'); - const editProfileForm = document.getElementById('edit-profile-form'); const editDeptModal = document.getElementById('modal-edit-department'); const editSpecModal = document.getElementById('modal-edit-specialty'); - const editProfileModal = document.getElementById('modal-edit-profile'); const editDeptClose = document.getElementById('modal-edit-department-close'); const editSpecClose = document.getElementById('modal-edit-specialty-close'); - const editProfileClose = document.getElementById('modal-edit-profile-close'); let departments = []; let specialties = []; - let profiles = []; async function loadData() { // Load Departments @@ -37,11 +30,8 @@ export async function initDepartmentsData() { try { specialties = await api.get('/api/specialties'); renderSpecialties(); - renderProfileSpecialtySelect(); - await loadProfiles(); } catch (e) { specTbody.innerHTML = '-'; - profilesTbody.innerHTML = '-'; } } @@ -81,52 +71,6 @@ export async function initDepartmentsData() { `).join(''); } - function renderProfileSpecialtySelect() { - const current = profileSpecialtySelect.value; - profileSpecialtySelect.innerHTML = '' + - specialties.map(s => ``).join(''); - if (current && specialties.some(s => String(s.id) === String(current))) { - profileSpecialtySelect.value = current; - } else if (specialties.length) { - profileSpecialtySelect.value = String(specialties[0].id); - } - syncSelect(profileSpecialtySelect); - } - - async function loadProfiles() { - const specialtyId = profileSpecialtySelect.value; - if (!specialtyId) { - profiles = []; - profilesTbody.innerHTML = 'Выберите специальность'; - return; - } - profilesTbody.innerHTML = 'Загрузка...'; - try { - profiles = await api.get(`/api/specialties/${specialtyId}/profiles`); - renderProfiles(); - } catch (error) { - profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function renderProfiles() { - if (!profiles.length) { - profilesTbody.innerHTML = 'Профили не созданы'; - return; - } - profilesTbody.innerHTML = profiles.map(profile => ` - - ${profile.id} - ${escapeHtml(profile.name)} - ${escapeHtml(profile.description || '-')} - - - - - - `).join(''); - } - createDeptForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-dept-alert'); @@ -197,59 +141,6 @@ export async function initDepartmentsData() { } }); - profileSpecialtySelect.addEventListener('change', loadProfiles); - - createProfileForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('create-profile-alert'); - const specialtyId = profileSpecialtySelect.value; - const name = document.getElementById('profile-name').value.trim(); - const description = document.getElementById('profile-description').value.trim(); - - if (!specialtyId || !name) { - showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error'); - return; - } - - try { - await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description }); - showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success'); - createProfileForm.reset(); - profileSpecialtySelect.value = specialtyId; - syncSelect(profileSpecialtySelect); - await loadProfiles(); - } catch (error) { - showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error'); - } - }); - - profilesTbody.addEventListener('click', async (e) => { - const editBtn = e.target.closest('.btn-edit-profile'); - const deleteBtn = e.target.closest('.btn-delete-profile'); - - if (editBtn) { - const profile = profiles.find(item => item.id == editBtn.dataset.id); - if (!profile) return; - document.getElementById('edit-profile-id').value = profile.id; - document.getElementById('edit-profile-name').value = profile.name || ''; - document.getElementById('edit-profile-description').value = profile.description || ''; - hideAlert('edit-profile-alert'); - editProfileModal.classList.add('open'); - return; - } - - if (deleteBtn) { - if (!confirm('Удалить профиль обучения?')) return; - try { - await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteBtn.dataset.id}`); - showAlert('create-profile-alert', 'Профиль удалён', 'success'); - await loadProfiles(); - } catch (error) { - showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); - } - } - }); - specTbody.addEventListener('click', async (e) => { const editBtn = e.target.closest('.btn-edit-specialty'); const deleteBtn = e.target.closest('.btn-delete-specialty'); @@ -322,45 +213,14 @@ export async function initDepartmentsData() { } }); - editProfileForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('edit-profile-alert'); - const specialtyId = profileSpecialtySelect.value; - const id = document.getElementById('edit-profile-id').value; - const name = document.getElementById('edit-profile-name').value.trim(); - const description = document.getElementById('edit-profile-description').value.trim(); - - if (!specialtyId || !id || !name) { - showAlert('edit-profile-alert', 'Заполните название профиля', 'error'); - return; - } - - try { - await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description }); - editProfileModal.classList.remove('open'); - showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success'); - await loadProfiles(); - } catch (error) { - showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error'); - } - }); - editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open')); editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open')); - editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open')); editDeptModal.addEventListener('click', (e) => { if (e.target === editDeptModal) editDeptModal.classList.remove('open'); }); editSpecModal.addEventListener('click', (e) => { if (e.target === editSpecModal) editSpecModal.classList.remove('open'); }); - editProfileModal.addEventListener('click', (e) => { - if (e.target === editProfileModal) editProfileModal.classList.remove('open'); - }); await loadData(); - - function syncSelect(select) { - select.dispatchEvent(new Event('change', { bubbles: true })); - } } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index d06cf62..70606ba 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -225,6 +225,7 @@ export async function initGroups() { String(calendar.academicYearId) === String(yearId) && String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode) && String(calendar.specialtyProfileId) === String(group.specialtyProfileId) + && String(calendar.studyFormId) === String(group.educationFormId) ) : []; calendarSelect.innerHTML = matching.length diff --git a/frontend/admin/js/views/profiles.js b/frontend/admin/js/views/profiles.js new file mode 100644 index 0000000..299a768 --- /dev/null +++ b/frontend/admin/js/views/profiles.js @@ -0,0 +1,192 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +export async function initProfiles() { + const createProfileForm = document.getElementById('create-profile-form'); + const profileSpecialtySelect = document.getElementById('profile-specialty'); + const profileNameInput = document.getElementById('profile-name'); + const profileDescriptionInput = document.getElementById('profile-description'); + const profilesTbody = document.getElementById('profiles-tbody'); + const profilesContext = document.getElementById('profiles-context'); + const refreshButton = document.getElementById('profiles-refresh'); + const editProfileForm = document.getElementById('edit-profile-form'); + const editProfileModal = document.getElementById('modal-edit-profile'); + const editProfileClose = document.getElementById('modal-edit-profile-close'); + + let specialties = []; + let profiles = []; + + await loadSpecialties(); + + profileSpecialtySelect.addEventListener('change', loadProfiles); + refreshButton.addEventListener('click', loadProfiles); + + createProfileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + hideAlert('create-profile-alert'); + + const specialtyId = profileSpecialtySelect.value; + const name = profileNameInput.value.trim(); + const description = profileDescriptionInput.value.trim(); + + if (!specialtyId || !name) { + showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error'); + return; + } + + try { + await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description }); + showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success'); + createProfileForm.reset(); + profileSpecialtySelect.value = specialtyId; + syncSelect(profileSpecialtySelect); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error'); + } + }); + + profilesTbody.addEventListener('click', async (event) => { + const editButton = event.target.closest('.btn-edit-profile'); + const deleteButton = event.target.closest('.btn-delete-profile'); + + if (editButton) { + const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id)); + if (!profile) return; + + document.getElementById('edit-profile-id').value = profile.id; + document.getElementById('edit-profile-name').value = profile.name || ''; + document.getElementById('edit-profile-description').value = profile.description || ''; + hideAlert('edit-profile-alert'); + editProfileModal.classList.add('open'); + return; + } + + if (deleteButton) { + if (!confirm('Удалить профиль обучения?')) return; + try { + await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`); + showAlert('create-profile-alert', 'Профиль удалён', 'success'); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); + } + } + }); + + editProfileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + hideAlert('edit-profile-alert'); + + const specialtyId = profileSpecialtySelect.value; + const id = document.getElementById('edit-profile-id').value; + const name = document.getElementById('edit-profile-name').value.trim(); + const description = document.getElementById('edit-profile-description').value.trim(); + + if (!specialtyId || !id || !name) { + showAlert('edit-profile-alert', 'Заполните название профиля', 'error'); + return; + } + + try { + await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description }); + editProfileModal.classList.remove('open'); + showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success'); + await loadProfiles(); + } catch (error) { + showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error'); + } + }); + + editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open')); + editProfileModal.addEventListener('click', (event) => { + if (event.target === editProfileModal) { + editProfileModal.classList.remove('open'); + } + }); + + async function loadSpecialties() { + profilesTbody.innerHTML = 'Загрузка...'; + try { + specialties = await api.get('/api/specialties'); + renderSpecialtySelect(); + await loadProfiles(); + } catch (error) { + profileSpecialtySelect.innerHTML = ''; + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + syncSelect(profileSpecialtySelect); + } + } + + function renderSpecialtySelect() { + if (!specialties.length) { + profileSpecialtySelect.innerHTML = ''; + syncSelect(profileSpecialtySelect); + return; + } + + const current = profileSpecialtySelect.value; + profileSpecialtySelect.innerHTML = '' + + specialties.map(specialty => { + const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id; + const name = specialty.specialityName || specialty.name || 'Без названия'; + return ``; + }).join(''); + + if (current && specialties.some(specialty => String(specialty.id) === String(current))) { + profileSpecialtySelect.value = current; + } else { + profileSpecialtySelect.value = String(specialties[0].id); + } + syncSelect(profileSpecialtySelect); + } + + async function loadProfiles() { + const specialtyId = profileSpecialtySelect.value; + const specialty = specialties.find(item => String(item.id) === String(specialtyId)); + profilesContext.textContent = specialty ? specialtyLabel(specialty) : ''; + + if (!specialtyId) { + profiles = []; + profilesTbody.innerHTML = 'Выберите специальность'; + return; + } + + profilesTbody.innerHTML = 'Загрузка...'; + try { + profiles = await api.get(`/api/specialties/${specialtyId}/profiles`); + renderProfiles(); + } catch (error) { + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderProfiles() { + if (!profiles.length) { + profilesTbody.innerHTML = 'Профили не созданы'; + return; + } + + profilesTbody.innerHTML = profiles.map(profile => ` + + ${profile.id} + ${escapeHtml(profile.name)} + ${escapeHtml(profile.description || '-')} + + + + + + `).join(''); + } + + function specialtyLabel(specialty) { + const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id; + const name = specialty.specialityName || specialty.name || 'Без названия'; + return `${code} · ${name}`; + } + + function syncSelect(select) { + select.dispatchEvent(new Event('change', { bubbles: true })); + } +} diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index ee38e92..d0f0fa3 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -48,49 +48,6 @@ export async function initSchedule() { const timeSlotsTbody = document.getElementById('time-slots-tbody'); const refreshSlotsButton = document.getElementById('time-slots-refresh'); - const academicYearForm = document.getElementById('academic-year-form'); - const academicYearFormTitle = document.getElementById('academic-year-form-title'); - const academicYearIdInput = document.getElementById('academic-year-id'); - const academicYearTitleInput = document.getElementById('academic-year-title'); - const academicYearStartInput = document.getElementById('academic-year-start'); - const academicYearEndInput = document.getElementById('academic-year-end'); - const academicYearResetButton = document.getElementById('academic-year-reset'); - const academicYearsTbody = document.getElementById('academic-years-tbody'); - const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); - - const semesterForm = document.getElementById('semester-form'); - const semesterFormTitle = document.getElementById('semester-form-title'); - const semesterIdInput = document.getElementById('semester-id'); - const semesterYearSelect = document.getElementById('semester-year'); - const semesterTypeSelect = document.getElementById('semester-type'); - const semesterStartInput = document.getElementById('semester-start'); - const semesterEndInput = document.getElementById('semester-end'); - const semesterResetButton = document.getElementById('semester-reset'); - - const calendarForm = document.getElementById('academic-calendar-form'); - const calendarFormTitle = document.getElementById('academic-calendar-form-title'); - const calendarIdInput = document.getElementById('academic-calendar-id'); - const calendarTitleInput = document.getElementById('academic-calendar-title'); - const calendarYearSelect = document.getElementById('academic-calendar-year'); - const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); - const calendarProfileSelect = document.getElementById('academic-calendar-profile'); - const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); - const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); - const calendarResetButton = document.getElementById('academic-calendar-reset'); - const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); - const calendarsTbody = document.getElementById('academic-calendars-tbody'); - - const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); - const editorLoadButton = document.getElementById('calendar-editor-load'); - const editorSaveButton = document.getElementById('calendar-editor-save'); - const fillCourseSelect = document.getElementById('calendar-fill-course'); - const fillStartInput = document.getElementById('calendar-fill-start'); - const fillEndInput = document.getElementById('calendar-fill-end'); - const fillActivitySelect = document.getElementById('calendar-fill-activity'); - const fillApplyButton = document.getElementById('calendar-fill-apply'); - const calendarGrid = document.getElementById('calendar-grid'); - const calendarTotals = document.getElementById('calendar-totals'); - let rules = []; let subjects = []; let groups = []; @@ -98,22 +55,15 @@ export async function initSchedule() { let classrooms = []; let lessonTypes = []; let timeSlots = []; - let academicYears = []; let semesters = []; - let specialties = []; - let profilesBySpecialty = new Map(); - let studyForms = []; - let activityTypes = []; - let calendars = []; - let currentGridRows = []; bindEvents(); try { - await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]); + await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]); renderRuleGroups([]); renderRuleSlots([{}]); - await Promise.all([loadRules(), loadCalendars()]); + await loadRules(); } catch (error) { showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); } @@ -121,18 +71,9 @@ export async function initSchedule() { function bindEvents() { refreshRulesButton?.addEventListener('click', loadRules); refreshSlotsButton?.addEventListener('click', loadTimeSlots); - academicYearsRefreshButton?.addEventListener('click', loadYears); - calendarsRefreshButton?.addEventListener('click', loadCalendars); ruleResetButton?.addEventListener('click', resetRuleForm); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); timeSlotResetButton?.addEventListener('click', resetTimeSlotForm); - academicYearResetButton?.addEventListener('click', resetAcademicYearForm); - semesterResetButton?.addEventListener('click', resetSemesterForm); - calendarResetButton?.addEventListener('click', resetCalendarForm); - calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); - editorLoadButton?.addEventListener('click', loadCalendarGrid); - editorSaveButton?.addEventListener('click', saveCalendarGrid); - fillApplyButton?.addEventListener('click', applyCalendarFill); ruleSlotsContainer.addEventListener('click', (event) => { const removeButton = event.target.closest('.schedule-slot-remove'); @@ -142,44 +83,27 @@ export async function initSchedule() { renderRuleSlots(slots.length ? slots : [{}]); }); - calendarGrid.addEventListener('change', (event) => { - if (event.target.matches('.calendar-day-activity')) { - renderCalendarTotals(); - } - }); - ruleForm.addEventListener('submit', saveRule); timeSlotForm.addEventListener('submit', saveTimeSlot); - academicYearForm.addEventListener('submit', saveAcademicYear); - semesterForm.addEventListener('submit', saveSemester); - calendarForm.addEventListener('submit', saveCalendar); - rulesTbody.addEventListener('click', handleRuleTableClick); timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick); - academicYearsTbody.addEventListener('click', handleAcademicYearTableClick); - calendarsTbody.addEventListener('click', handleCalendarTableClick); } async function loadBaseLists() { - [subjects, groups, teachers, classrooms, lessonTypes, specialties] = await Promise.all([ + [subjects, groups, teachers, classrooms, lessonTypes] = await Promise.all([ api.get('/api/subjects'), api.get('/api/groups'), api.get('/api/users/teachers'), api.get('/api/classrooms'), - api.get('/api/lesson-types'), - api.get('/api/specialties') + api.get('/api/lesson-types') ]); - await loadProfiles(); populateSubjectSelects(); - populateSpecialtySelects(); } - async function loadProfiles() { - const pairs = await Promise.all(specialties.map(async (speciality) => { - const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); - return [String(speciality.id), profiles]; - })); - profilesBySpecialty = new Map(pairs); + async function loadYears() { + const academicYears = await api.get('/api/admin/calendar/years'); + semesters = academicYears.flatMap(year => year.semesters || []); + populateSemesterSelects(); } async function loadRules() { @@ -418,6 +342,7 @@ export async function initSchedule() { showAlert('time-slot-alert', 'Временной слот сохранён', 'success'); resetTimeSlotForm(); await loadTimeSlots(); + renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]); } catch (error) { showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error'); } @@ -426,6 +351,7 @@ export async function initSchedule() { async function handleTimeSlotTableClick(event) { const editButton = event.target.closest('.btn-edit-time-slot'); const deleteButton = event.target.closest('.btn-delete-time-slot'); + if (editButton) { const slot = timeSlots.find(item => item.id == editButton.dataset.id); if (!slot) return; @@ -439,12 +365,14 @@ export async function initSchedule() { timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } + if (deleteButton) { if (!confirm('Удалить временной слот?')) return; try { await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id); showAlert('time-slot-alert', 'Временной слот удалён', 'success'); await loadTimeSlots(); + renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]); } catch (error) { showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error'); } @@ -458,426 +386,10 @@ export async function initSchedule() { hideAlert('time-slot-alert'); } - async function loadYears() { - academicYearsTbody.innerHTML = 'Загрузка...'; - try { - academicYears = await api.get('/api/admin/calendar/years'); - semesters = academicYears.flatMap(year => year.semesters || []); - renderAcademicYears(); - populateYearSelects(); - populateSemesterSelects(); - } catch (error) { - academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function renderAcademicYears() { - if (!academicYears.length) { - academicYearsTbody.innerHTML = 'Учебные годы не созданы'; - return; - } - academicYearsTbody.innerHTML = academicYears.map(year => ` - - ${escapeHtml(year.title)} - ${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)} - ${renderSemesterList(year.semesters || [])} - - - - - - `).join(''); - } - - function renderSemesterList(items) { - if (!items.length) return 'Нет семестров'; - return items.map(semester => ` -
- ${escapeHtml(semesterLabel(semester))} - -
- `).join(''); - } - - async function saveAcademicYear(event) { - event.preventDefault(); - hideAlert('academic-year-alert'); - const payload = { - title: academicYearTitleInput.value.trim(), - startDate: academicYearStartInput.value, - endDate: academicYearEndInput.value - }; - try { - if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); - const id = academicYearIdInput.value; - await (id - ? api.put('/api/admin/calendar/years/' + id, payload) - : api.post('/api/admin/calendar/years', payload)); - showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); - resetAcademicYearForm(); - await loadYears(); - await loadCalendars(); - } catch (error) { - showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); - } - } - - async function handleAcademicYearTableClick(event) { - const editYearButton = event.target.closest('.btn-edit-year'); - const deleteYearButton = event.target.closest('.btn-delete-year'); - const editSemesterButton = event.target.closest('.btn-edit-semester'); - if (editYearButton) { - const year = academicYears.find(item => item.id == editYearButton.dataset.id); - if (!year) return; - academicYearFormTitle.textContent = 'Редактирование учебного года'; - academicYearIdInput.value = year.id; - academicYearTitleInput.value = year.title || ''; - academicYearStartInput.value = year.startDate || ''; - academicYearEndInput.value = year.endDate || ''; - hideAlert('academic-year-alert'); - academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (editSemesterButton) { - const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); - if (!semester) return; - semesterFormTitle.textContent = 'Редактирование семестра'; - semesterIdInput.value = semester.id; - semesterYearSelect.value = semester.academicYearId || ''; - semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); - semesterStartInput.value = semester.startDate || ''; - semesterEndInput.value = semester.endDate || ''; - syncSelects(semesterYearSelect, semesterTypeSelect); - hideAlert('semester-alert'); - semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (deleteYearButton) { - if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; - try { - await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); - showAlert('academic-year-alert', 'Учебный год удалён', 'success'); - await loadYears(); - await loadCalendars(); - } catch (error) { - showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); - } - } - } - - function resetAcademicYearForm() { - academicYearForm.reset(); - academicYearFormTitle.textContent = 'Учебный год'; - academicYearIdInput.value = ''; - hideAlert('academic-year-alert'); - } - - async function saveSemester(event) { - event.preventDefault(); - hideAlert('semester-alert'); - const yearId = semesterYearSelect.value; - const payload = { - semesterType: semesterTypeSelect.value, - startDate: semesterStartInput.value, - endDate: semesterEndInput.value - }; - try { - if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) throw new Error('Заполните учебный год, тип и даты семестра'); - const id = semesterIdInput.value; - await (id - ? api.put('/api/admin/calendar/semesters/' + id, payload) - : api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload)); - showAlert('semester-alert', 'Семестр сохранён', 'success'); - resetSemesterForm(); - await loadYears(); - } catch (error) { - showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error'); - } - } - - function resetSemesterForm() { - semesterForm.reset(); - semesterFormTitle.textContent = 'Семестр'; - semesterIdInput.value = ''; - syncSelects(semesterYearSelect, semesterTypeSelect); - hideAlert('semester-alert'); - } - - async function loadStudyForms() { - studyForms = await api.get('/api/admin/academic-calendars/study-forms'); - populateStudyFormSelect(); - } - - async function loadActivityTypes() { - activityTypes = await api.get('/api/admin/calendar/activity-types'); - populateActivitySelects(); - } - - async function loadCalendars() { - calendarsTbody.innerHTML = 'Загрузка...'; - try { - calendars = await api.get('/api/admin/academic-calendars'); - renderCalendars(); - populateEditorCalendarSelect(); - } catch (error) { - calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function renderCalendars() { - if (!calendars.length) { - calendarsTbody.innerHTML = 'Календарные графики не созданы'; - return; - } - calendarsTbody.innerHTML = calendars.map(calendar => ` - - ${escapeHtml(calendar.title)} - ${escapeHtml(calendar.academicYearTitle || '-')} - ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} - ${escapeHtml(calendar.specialtyProfileName || '-')} - ${escapeHtml(calendar.studyFormName || '-')} - ${escapeHtml(String(calendar.courseCount || '-'))} - - - - - - - `).join(''); - } - - async function saveCalendar(event) { - event.preventDefault(); - hideAlert('academic-calendar-alert'); - const payload = { - id: calendarIdInput.value ? Number(calendarIdInput.value) : null, - title: calendarTitleInput.value.trim(), - academicYearId: Number(calendarYearSelect.value), - specialtyId: Number(calendarSpecialtySelect.value), - specialtyProfileId: Number(calendarProfileSelect.value), - studyFormId: Number(calendarStudyFormSelect.value), - courseCount: Number(calendarCourseCountInput.value) - }; - try { - if (!payload.title || !payload.academicYearId || !payload.specialtyId - || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { - throw new Error('Заполните все поля календарного графика'); - } - const id = calendarIdInput.value; - const saved = id - ? await api.put('/api/admin/academic-calendars/' + id, payload) - : await api.post('/api/admin/academic-calendars', payload); - showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); - resetCalendarForm(); - await loadCalendars(); - } catch (error) { - showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error'); - } - } - - async function handleCalendarTableClick(event) { - const editButton = event.target.closest('.btn-edit-calendar'); - const gridButton = event.target.closest('.btn-open-calendar-grid'); - const deleteButton = event.target.closest('.btn-delete-calendar'); - if (editButton) { - const calendar = calendars.find(item => item.id == editButton.dataset.id); - if (!calendar) return; - calendarFormTitle.textContent = 'Редактирование календарного графика'; - calendarIdInput.value = calendar.id; - calendarTitleInput.value = calendar.title || ''; - calendarYearSelect.value = calendar.academicYearId || ''; - calendarSpecialtySelect.value = calendar.specialtyId || ''; - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); - calendarStudyFormSelect.value = calendar.studyFormId || ''; - calendarCourseCountInput.value = calendar.courseCount || 4; - syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); - hideAlert('academic-calendar-alert'); - calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (gridButton) { - editorCalendarSelect.value = gridButton.dataset.id; - syncSelects(editorCalendarSelect); - await loadCalendarGrid(); - calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (deleteButton) { - if (!confirm('Удалить календарный график?')) return; - try { - await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); - showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); - await loadCalendars(); - } catch (error) { - showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error'); - } - } - } - - function resetCalendarForm() { - calendarForm.reset(); - calendarFormTitle.textContent = 'Календарный учебный график'; - calendarIdInput.value = ''; - calendarCourseCountInput.value = 4; - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); - syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); - hideAlert('academic-calendar-alert'); - } - - async function loadCalendarGrid() { - hideAlert('calendar-editor-alert'); - const calendar = selectedEditorCalendar(); - if (!calendar) { - showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); - return; - } - try { - const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); - currentGridRows = rows.length ? rows : buildDefaultGrid(calendar); - renderCalendarGrid(currentGridRows); - showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); - } catch (error) { - showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); - } - } - - async function saveCalendarGrid() { - hideAlert('calendar-editor-alert'); - const calendar = selectedEditorCalendar(); - if (!calendar) { - showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); - return; - } - const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ - calendarId: calendar.id, - courseNumber: Number(day.dataset.course), - date: day.dataset.date, - weekNumber: Number(day.dataset.week), - dayOfWeek: Number(day.dataset.day), - activityTypeId: Number(day.querySelector('.calendar-day-activity').value) - })); - if (!rows.length) { - showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error'); - return; - } - try { - await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); - showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); - await loadCalendarGrid(); - } catch (error) { - showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error'); - } - } - - function applyCalendarFill() { - hideAlert('calendar-editor-alert'); - const activityId = fillActivitySelect.value; - const start = fillStartInput.value; - const end = fillEndInput.value; - if (!activityId || !start || !end) { - showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); - return; - } - if (end < start) { - showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); - return; - } - const course = fillCourseSelect.value; - let changed = 0; - calendarGrid.querySelectorAll('.calendar-day').forEach(day => { - const sameCourse = !course || day.dataset.course === course; - const inRange = day.dataset.date >= start && day.dataset.date <= end; - if (sameCourse && inRange) { - day.querySelector('.calendar-day-activity').value = activityId; - changed += 1; - } - }); - renderCalendarTotals(); - showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); - } - - function renderCalendarGrid(rows) { - const grouped = groupBy(rows, row => row.courseNumber); - calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` -
-
-

${course} курс

- ${courseRows.length} дней -
-
- ${courseRows.map(renderCalendarDay).join('')} -
-
- `).join(''); - populateFillCourseSelect(); - renderCalendarTotals(); - } - - function renderCalendarDay(row) { - const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); - const dayName = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'][Number(row.dayOfWeek) - 1] || ''; - return ` -
-
- ${formatShortDate(row.date)} - ${dayName} -
- - ${row.weekNumber} нед. -
- `; - } - - function buildDefaultGrid(calendar) { - const rows = []; - const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); - for (let course = 1; course <= calendar.courseCount; course += 1) { - dates.forEach((date, index) => { - rows.push({ - id: null, - calendarId: calendar.id, - courseNumber: course, - date: date.iso, - weekNumber: Math.floor(index / 7) + 1, - dayOfWeek: date.dayOfWeek, - activityTypeId: theoryActivityId(), - activityCode: 'Т' - }); - }); - } - return rows; - } - - function renderCalendarTotals() { - const counts = new Map(); - calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { - const activity = activityById(select.value); - const key = activity ? activity.code : '-'; - counts.set(key, (counts.get(key) || 0) + 1); - }); - calendarTotals.innerHTML = Array.from(counts.entries()) - .sort(([a], [b]) => a.localeCompare(b, 'ru')) - .map(([code, count]) => { - const activity = activityTypes.find(item => item.code === code); - return `${escapeHtml(code)}: ${count}`; - }).join(''); - } - function populateSubjectSelects() { ruleSubjectSelect.innerHTML = '' + subjects.map(subject => ``).join(''); - } - - function populateYearSelects() { - const yearOptions = '' + - academicYears.map(year => ``).join(''); - semesterYearSelect.innerHTML = yearOptions; - calendarYearSelect.innerHTML = yearOptions; - syncSelects(semesterYearSelect, calendarYearSelect); + syncSelects(ruleSubjectSelect); } function populateSemesterSelects() { @@ -887,57 +399,6 @@ export async function initSchedule() { syncSelects(ruleSemesterSelect); } - function populateSpecialtySelects() { - calendarSpecialtySelect.innerHTML = '' + - specialties.map(speciality => ``).join(''); - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); - syncSelects(calendarSpecialtySelect); - } - - function populateProfileSelect(select, specialtyId, selectedProfileId = null) { - const profiles = profilesBySpecialty.get(String(specialtyId)) || []; - select.innerHTML = profiles.length - ? '' + profiles.map(profile => - `` - ).join('') - : ''; - if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { - select.value = selectedProfileId; - } - syncSelects(select); - } - - function populateStudyFormSelect() { - calendarStudyFormSelect.innerHTML = '' + - studyForms.map(form => ``).join(''); - syncSelects(calendarStudyFormSelect); - } - - function populateActivitySelects() { - const activityOptions = '' + - activityTypes.map(type => ``).join(''); - fillActivitySelect.innerHTML = activityOptions; - syncSelects(fillActivitySelect); - } - - function populateEditorCalendarSelect() { - const current = editorCalendarSelect.value; - editorCalendarSelect.innerHTML = '' + - calendars.map(calendar => ``).join(''); - if (current && calendars.some(calendar => String(calendar.id) === String(current))) { - editorCalendarSelect.value = current; - } - syncSelects(editorCalendarSelect); - } - - function populateFillCourseSelect() { - const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); - fillCourseSelect.innerHTML = '' + - Array.from(courses).sort((a, b) => Number(a) - Number(b)) - .map(course => ``).join(''); - syncSelects(fillCourseSelect); - } - function options(items, selectedValue) { const selected = selectedValue == null ? '' : String(selectedValue); return '' + items.map(item => { @@ -946,10 +407,6 @@ export async function initSchedule() { }).join(''); } - function toActivityOption(type) { - return { value: type.id, label: `${type.code} - ${type.name}` }; - } - function toTimeSlotOption(slot) { return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; } @@ -967,22 +424,6 @@ export async function initSchedule() { return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; } - function selectedEditorCalendar() { - return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); - } - - function theoryActivityId() { - return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; - } - - function activityIdByCode(code) { - return activityTypes.find(type => type.code === code)?.id || null; - } - - function activityById(id) { - return activityTypes.find(type => String(type.id) === String(id)); - } - function semesterLabel(semester) { const type = normalizeSemesterType(semester.semesterType); return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; @@ -997,45 +438,11 @@ export async function initSchedule() { return String(type || '').toLowerCase(); } - function specialityLabel(speciality) { - const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; - const name = speciality.specialityName || speciality.name || ''; - return name ? `${code} - ${name}` : String(code); - } - function trimTime(value) { if (!value) return ''; return String(value).slice(0, 5); } - function formatShortDate(value) { - const [year, month, day] = String(value).split('-'); - return `${day}.${month}`; - } - - function enumerateDates(startIso, endIso) { - const result = []; - const current = new Date(`${startIso}T00:00:00Z`); - const end = new Date(`${endIso}T00:00:00Z`); - while (current <= end) { - const iso = current.toISOString().slice(0, 10); - const jsDay = current.getUTCDay(); - result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); - current.setUTCDate(current.getUTCDate() + 1); - } - return result; - } - - function groupBy(items, keyFn) { - const grouped = new Map(); - items.forEach(item => { - const key = keyFn(item); - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key).push(item); - }); - return grouped; - } - function syncSelects(...selects) { selects.filter(Boolean).forEach(select => { select.dispatchEvent(new Event('change', { bubbles: true })); diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html new file mode 100644 index 0000000..b84b862 --- /dev/null +++ b/frontend/admin/views/academic-calendar.html @@ -0,0 +1,225 @@ +
+

Учебный год

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

Учебные годы

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

Семестр

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

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

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

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

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

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

+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+ + diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html index 897ee96..dd31814 100644 --- a/frontend/admin/views/departments-data.html +++ b/frontend/admin/views/departments-data.html @@ -40,47 +40,6 @@
-
-

Профили обучения

-
-
-
- - -
-
- - -
-
- - -
- -
- -
-
- - - - - - - - - - - - - - -
IDПрофильОписаниеДействия
Выберите специальность
-
-
-

Создание специальности

@@ -165,25 +124,3 @@
- - diff --git a/frontend/admin/views/profiles.html b/frontend/admin/views/profiles.html new file mode 100644 index 0000000..b2a40ac --- /dev/null +++ b/frontend/admin/views/profiles.html @@ -0,0 +1,72 @@ +
+
+

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

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

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

+ +
+
+
+ + + + + + + + + + + + + + +
IDПрофильОписаниеДействия
Загрузка...
+
+
+ + diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index af7209e..57825e5 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -130,197 +130,3 @@ - -
-

Учебный год

-
- -
-
- - -
-
- - -
-
- - -
- - -
- -
-
- -
-
-

Учебные годы

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

Семестр

-
- -
-
- - -
-
- - -
-
- - -
-
- - -
- - -
- -
-
- -
-
-

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

- -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-
-

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

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

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

-
-
- - -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
-
From 3cb311f469b2fb695dab557025e5b0303329c091 Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 1 May 2026 20:55:49 +0300 Subject: [PATCH 05/16] =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D1=83=20=D0=B2?= =?UTF-8?q?=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=BB?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/ClassroomController.java | 16 +- .../ScheduleRuleAdminController.java | 6 + .../controller/TimeSlotAdminController.java | 217 ++++++- .../com/magistr/app/dto/ClassroomRequest.java | 18 + .../magistr/app/dto/ClassroomResponse.java | 23 +- .../app/dto/TimeSlotDateAssignmentDto.java | 11 + .../java/com/magistr/app/dto/TimeSlotDto.java | 4 + .../com/magistr/app/dto/TimeSlotScopeDto.java | 12 + .../java/com/magistr/app/model/Classroom.java | 21 + .../java/com/magistr/app/model/TimeSlot.java | 12 + .../app/model/TimeSlotDateAssignment.java | 45 ++ .../com/magistr/app/model/TimeSlotScope.java | 86 +++ .../TimeSlotDateAssignmentRepository.java | 19 + .../app/repository/TimeSlotRepository.java | 9 +- .../repository/TimeSlotScopeRepository.java | 18 + .../app/service/ScheduleGeneratorService.java | 36 +- .../main/resources/db/migration/V1__init.sql | 62 +- docs/API.md | 38 +- docs/BUSINESS_LOGIC.md | 17 +- docs/DATABASE.md | 50 +- docs/FRONTEND.md | 18 +- frontend/admin/css/auditorium-workload.css | 109 +++- frontend/admin/js/views/academic-calendar.js | 19 +- .../admin/js/views/auditorium-workload.js | 534 +++++++++++++----- frontend/admin/js/views/schedule.js | 110 +--- frontend/admin/settings/css/main.css | 402 ++++++++++++- frontend/admin/settings/index.html | 18 +- frontend/admin/settings/js/main.js | 36 +- .../admin/settings/js/views/time-slots.js | 358 ++++++++++++ frontend/admin/settings/views/time-slots.html | 124 ++++ frontend/admin/views/auditorium-workload.html | 10 +- frontend/admin/views/schedule.html | 55 -- 32 files changed, 2130 insertions(+), 383 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/dto/TimeSlotDateAssignmentDto.java create mode 100644 backend/src/main/java/com/magistr/app/dto/TimeSlotScopeDto.java create mode 100644 backend/src/main/java/com/magistr/app/model/TimeSlotDateAssignment.java create mode 100644 backend/src/main/java/com/magistr/app/model/TimeSlotScope.java create mode 100644 backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java create mode 100644 frontend/admin/settings/js/views/time-slots.js create mode 100644 frontend/admin/settings/views/time-slots.html diff --git a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java index 8084f41..ae38ebb 100755 --- a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java +++ b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java @@ -47,6 +47,8 @@ public class ClassroomController { Classroom classroom = new Classroom(); classroom.setName(request.getName().trim()); classroom.setCapacity(request.getCapacity()); + classroom.setBuilding(cleanText(request.getBuilding())); + classroom.setFloor(request.getFloor()); classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true); if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) { @@ -80,6 +82,14 @@ public class ClassroomController { classroom.setCapacity(request.getCapacity()); } + if (request.getBuilding() != null) { + classroom.setBuilding(cleanText(request.getBuilding())); + } + + if (request.getFloor() != null) { + classroom.setFloor(request.getFloor()); + } + if (request.getIsAvailable() != null) { classroom.setIsAvailable(request.getIsAvailable()); } @@ -103,7 +113,11 @@ public class ClassroomController { } private ClassroomResponse mapToResponse(Classroom c) { - return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(), + return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(), new java.util.ArrayList<>(c.getEquipments())); } + + private String cleanText(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index 3787a44..cda7293 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -16,6 +16,8 @@ import java.util.*; @RequestMapping("/api/admin/schedule-rules") public class ScheduleRuleAdminController { + private static final String APPLY_MODE_DEFAULT = "DEFAULT"; + private final ScheduleRuleRepository scheduleRuleRepository; private final SubjectRepository subjectRepository; private final SemesterRepository semesterRepository; @@ -169,6 +171,10 @@ public class ScheduleRuleAdminController { } TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId()) .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); + if (timeSlot.getTimeSlotScope() == null + || !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) { + throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты"); + } User teacher = userRepository.findById(slotDto.teacherId()) .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); Classroom classroom = classroomRepository.findById(slotDto.classroomId()) 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 987308d..5fd72a5 100644 --- a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java @@ -1,40 +1,159 @@ package com.magistr.app.controller; +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.repository.TimeSlotDateAssignmentRepository; import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.TimeSlotScopeRepository; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.Duration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; @RestController @RequestMapping("/api/admin/time-slots") public class TimeSlotAdminController { - private final TimeSlotRepository timeSlotRepository; + private static final String APPLY_MODE_DEFAULT = "DEFAULT"; + private static final String APPLY_MODE_WEEKDAY = "WEEKDAY"; + private static final String APPLY_MODE_MANUAL = "MANUAL"; - public TimeSlotAdminController(TimeSlotRepository timeSlotRepository) { + private final TimeSlotRepository timeSlotRepository; + private final TimeSlotScopeRepository timeSlotScopeRepository; + private final TimeSlotDateAssignmentRepository dateAssignmentRepository; + + public TimeSlotAdminController(TimeSlotRepository timeSlotRepository, + TimeSlotScopeRepository timeSlotScopeRepository, + TimeSlotDateAssignmentRepository dateAssignmentRepository) { this.timeSlotRepository = timeSlotRepository; + this.timeSlotScopeRepository = timeSlotScopeRepository; + this.dateAssignmentRepository = dateAssignmentRepository; } @GetMapping public List getAll() { - return timeSlotRepository.findAllByOrderByOrderNumberAsc().stream() + return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream() .map(this::toDto) .toList(); } + @GetMapping("/effective") + public ResponseEntity getEffective(@RequestParam LocalDate date) { + return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList()); + } + + @GetMapping("/scopes") + public List getScopes() { + return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream() + .map(this::toScopeDto) + .toList(); + } + + @PostMapping("/scopes") + public ResponseEntity createScope(@RequestBody TimeSlotScopeDto request) { + if (request.name() == null || request.name().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно")); + } + + TimeSlotScope scope = new TimeSlotScope(); + scope.setCode("manual_" + UUID.randomUUID().toString().replace("-", "")); + scope.setName(request.name().trim()); + scope.setApplyMode(APPLY_MODE_MANUAL); + scope.setDayOfWeek(null); + scope.setSystemScope(false); + scope.setDisplayOrder(nextManualDisplayOrder()); + return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope))); + } + + @PutMapping("/scopes/{id}") + public ResponseEntity updateScope(@PathVariable Long id, @RequestBody TimeSlotScopeDto request) { + TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null); + if (scope == null) { + return ResponseEntity.notFound().build(); + } + if (Boolean.TRUE.equals(scope.getSystemScope())) { + return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя изменять")); + } + if (request.name() == null || request.name().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно")); + } + scope.setName(request.name().trim()); + return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope))); + } + + @DeleteMapping("/scopes/{id}") + public ResponseEntity deleteScope(@PathVariable Long id) { + TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null); + if (scope == null) { + return ResponseEntity.notFound().build(); + } + if (Boolean.TRUE.equals(scope.getSystemScope())) { + return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя удалить")); + } + timeSlotScopeRepository.delete(scope); + return ResponseEntity.ok(Map.of("message", "Сетка времени удалена")); + } + + @GetMapping("/date-assignments") + public List getDateAssignments(@RequestParam(required = false) LocalDate startDate, + @RequestParam(required = false) LocalDate endDate) { + List assignments = startDate != null && endDate != null + ? dateAssignmentRepository.findByDateBetweenOrderByDateAsc(startDate, endDate) + : dateAssignmentRepository.findAllByOrderByDateAsc(); + return assignments.stream().map(this::toAssignmentDto).toList(); + } + + @PostMapping("/date-assignments") + public ResponseEntity saveDateAssignment(@RequestBody TimeSlotDateAssignmentDto request) { + if (request.date() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Дата обязательна")); + } + if (request.scopeId() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени обязательна")); + } + TimeSlotScope scope = timeSlotScopeRepository.findById(request.scopeId()).orElse(null); + if (scope == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени не найдена")); + } + if (!APPLY_MODE_MANUAL.equals(scope.getApplyMode())) { + return ResponseEntity.badRequest().body(Map.of("message", "Вручную можно применять только пользовательские сетки")); + } + + TimeSlotDateAssignment assignment = dateAssignmentRepository.findByDate(request.date()) + .orElseGet(TimeSlotDateAssignment::new); + assignment.setDate(request.date()); + assignment.setTimeSlotScope(scope); + return ResponseEntity.ok(toAssignmentDto(dateAssignmentRepository.save(assignment))); + } + + @DeleteMapping("/date-assignments/{id}") + public ResponseEntity deleteDateAssignment(@PathVariable Long id) { + if (!dateAssignmentRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + dateAssignmentRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Ручное назначение удалено")); + } + @PostMapping public ResponseEntity create(@RequestBody TimeSlotDto request) { String validationError = validate(request); if (validationError != null) { return ResponseEntity.badRequest().body(Map.of("message", validationError)); } - if (timeSlotRepository.existsByOrderNumber(request.orderNumber())) { - return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует")); + if (hasDuplicate(request, null)) { + return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары")); } TimeSlot timeSlot = new TimeSlot(); @@ -53,9 +172,8 @@ public class TimeSlotAdminController { if (validationError != null) { return ResponseEntity.badRequest().body(Map.of("message", validationError)); } - if (timeSlotRepository.existsByOrderNumber(request.orderNumber()) - && !timeSlot.getOrderNumber().equals(request.orderNumber())) { - return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует")); + if (hasDuplicate(request, id)) { + return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары")); } apply(timeSlot, request); @@ -67,14 +185,21 @@ public class TimeSlotAdminController { if (!timeSlotRepository.existsById(id)) { return ResponseEntity.notFound().build(); } - timeSlotRepository.deleteById(id); - return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); + try { + timeSlotRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); + } catch (DataIntegrityViolationException e) { + return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания")); + } } private String validate(TimeSlotDto request) { if (request.orderNumber() == null || request.orderNumber() <= 0) { return "Номер пары должен быть больше нуля"; } + if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) { + return "Выберите сетку времени"; + } if (request.startTime() == null || request.endTime() == null) { return "Время начала и окончания обязательно"; } @@ -86,6 +211,8 @@ public class TimeSlotAdminController { private void apply(TimeSlot timeSlot, TimeSlotDto request) { timeSlot.setOrderNumber(request.orderNumber()); + timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId()) + .orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена"))); timeSlot.setStartTime(request.startTime()); timeSlot.setEndTime(request.endTime()); int duration = request.durationMinutes() != null && request.durationMinutes() > 0 @@ -94,13 +221,83 @@ public class TimeSlotAdminController { timeSlot.setDurationMinutes(duration); } + private boolean hasDuplicate(TimeSlotDto request, Long currentId) { + return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber()) + .filter(existing -> currentId == null || !existing.getId().equals(currentId)) + .isPresent(); + } + + private List effectiveSlots(LocalDate date) { + TimeSlotScope defaultScope = defaultScope(); + TimeSlotScope effectiveScope = effectiveScope(date); + List defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId()); + if (effectiveScope.getId().equals(defaultScope.getId())) { + return defaultSlots; + } + + Map slotsByOrder = new LinkedHashMap<>(); + defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot)); + timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId()) + .forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot)); + return new ArrayList<>(slotsByOrder.values()); + } + + private TimeSlotScope effectiveScope(LocalDate date) { + return dateAssignmentRepository.findByDate(date) + .map(TimeSlotDateAssignment::getTimeSlotScope) + .or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek( + APPLY_MODE_WEEKDAY, + date.getDayOfWeek().getValue())) + .orElseGet(this::defaultScope); + } + + private TimeSlotScope defaultScope() { + return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT) + .orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена")); + } + + private int nextManualDisplayOrder() { + return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream() + .map(TimeSlotScope::getDisplayOrder) + .filter(value -> value != null) + .max(Integer::compareTo) + .orElse(100) + 10; + } + private TimeSlotDto toDto(TimeSlot timeSlot) { + TimeSlotScope scope = timeSlot.getTimeSlotScope(); return new TimeSlotDto( timeSlot.getId(), timeSlot.getOrderNumber(), + scope == null ? null : scope.getId(), + scope == null ? null : scope.getName(), + scope == null ? null : scope.getApplyMode(), + scope == null ? null : scope.getDayOfWeek(), timeSlot.getStartTime(), timeSlot.getEndTime(), timeSlot.getDurationMinutes() ); } + + private TimeSlotScopeDto toScopeDto(TimeSlotScope scope) { + return new TimeSlotScopeDto( + scope.getId(), + scope.getCode(), + scope.getName(), + scope.getApplyMode(), + scope.getDayOfWeek(), + scope.getSystemScope(), + scope.getDisplayOrder() + ); + } + + private TimeSlotDateAssignmentDto toAssignmentDto(TimeSlotDateAssignment assignment) { + TimeSlotScope scope = assignment.getTimeSlotScope(); + return new TimeSlotDateAssignmentDto( + assignment.getId(), + assignment.getDate(), + scope.getId(), + scope.getName() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java b/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java index 72927e1..d608ca4 100755 --- a/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java @@ -5,6 +5,8 @@ import java.util.List; public class ClassroomRequest { private String name; private Integer capacity; + private String building; + private Integer floor; private Boolean isAvailable; private List equipmentIds; @@ -24,6 +26,22 @@ public class ClassroomRequest { this.capacity = capacity; } + public String getBuilding() { + return building; + } + + public void setBuilding(String building) { + this.building = building; + } + + public Integer getFloor() { + return floor; + } + + public void setFloor(Integer floor) { + this.floor = floor; + } + public Boolean getIsAvailable() { return isAvailable; } diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java index 35b46d5..06667dd 100755 --- a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java @@ -7,16 +7,21 @@ public class ClassroomResponse { private Long id; private String name; private Integer capacity; + private String building; + private Integer floor; private Boolean isAvailable; private List equipments; public ClassroomResponse() { } - public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List equipments) { + public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor, + Boolean isAvailable, List equipments) { this.id = id; this.name = name; this.capacity = capacity; + this.building = building; + this.floor = floor; this.isAvailable = isAvailable; this.equipments = equipments; } @@ -45,6 +50,22 @@ public class ClassroomResponse { this.capacity = capacity; } + public String getBuilding() { + return building; + } + + public void setBuilding(String building) { + this.building = building; + } + + public Integer getFloor() { + return floor; + } + + public void setFloor(Integer floor) { + this.floor = floor; + } + public Boolean getIsAvailable() { return isAvailable; } diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotDateAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotDateAssignmentDto.java new file mode 100644 index 0000000..0448211 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotDateAssignmentDto.java @@ -0,0 +1,11 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record TimeSlotDateAssignmentDto( + Long id, + LocalDate date, + Long scopeId, + String scopeName +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java index 33df851..36a586b 100644 --- a/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java @@ -5,6 +5,10 @@ import java.time.LocalTime; public record TimeSlotDto( Long id, Integer orderNumber, + Long scopeId, + String scopeName, + String scopeApplyMode, + Integer scopeDayOfWeek, LocalTime startTime, LocalTime endTime, Integer durationMinutes diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotScopeDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotScopeDto.java new file mode 100644 index 0000000..52a653c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotScopeDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record TimeSlotScopeDto( + Long id, + String code, + String name, + String applyMode, + Integer dayOfWeek, + Boolean systemScope, + Integer displayOrder +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/Classroom.java b/backend/src/main/java/com/magistr/app/model/Classroom.java index 67b2840..a7f7653 100755 --- a/backend/src/main/java/com/magistr/app/model/Classroom.java +++ b/backend/src/main/java/com/magistr/app/model/Classroom.java @@ -18,6 +18,11 @@ public class Classroom { @Column(nullable = false) private Integer capacity; + @Column(length = 50) + private String building; + + private Integer floor; + @Column(name = "is_available", nullable = false) private Boolean isAvailable = true; @@ -52,6 +57,22 @@ public class Classroom { this.capacity = capacity; } + public String getBuilding() { + return building; + } + + public void setBuilding(String building) { + this.building = building; + } + + public Integer getFloor() { + return floor; + } + + public void setFloor(Integer floor) { + this.floor = floor; + } + public Boolean getIsAvailable() { return isAvailable; } diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlot.java b/backend/src/main/java/com/magistr/app/model/TimeSlot.java index f1a3402..6da1c50 100644 --- a/backend/src/main/java/com/magistr/app/model/TimeSlot.java +++ b/backend/src/main/java/com/magistr/app/model/TimeSlot.java @@ -15,6 +15,10 @@ public class TimeSlot { @Column(name = "order_number", nullable = false) private Integer orderNumber; + @ManyToOne(optional = false) + @JoinColumn(name = "time_slot_scope_id", nullable = false) + private TimeSlotScope timeSlotScope; + @Column(name = "start_time", nullable = false) private LocalTime startTime; @@ -40,6 +44,14 @@ public class TimeSlot { this.orderNumber = orderNumber; } + public TimeSlotScope getTimeSlotScope() { + return timeSlotScope; + } + + public void setTimeSlotScope(TimeSlotScope timeSlotScope) { + this.timeSlotScope = timeSlotScope; + } + public LocalTime getStartTime() { return startTime; } diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlotDateAssignment.java b/backend/src/main/java/com/magistr/app/model/TimeSlotDateAssignment.java new file mode 100644 index 0000000..ac41e6a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TimeSlotDateAssignment.java @@ -0,0 +1,45 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "time_slot_date_assignments") +public class TimeSlotDateAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "assignment_date", nullable = false, unique = true) + private LocalDate date; + + @ManyToOne(optional = false) + @JoinColumn(name = "time_slot_scope_id", nullable = false) + private TimeSlotScope timeSlotScope; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public TimeSlotScope getTimeSlotScope() { + return timeSlotScope; + } + + public void setTimeSlotScope(TimeSlotScope timeSlotScope) { + this.timeSlotScope = timeSlotScope; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlotScope.java b/backend/src/main/java/com/magistr/app/model/TimeSlotScope.java new file mode 100644 index 0000000..3a6e3c7 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TimeSlotScope.java @@ -0,0 +1,86 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "time_slot_scopes") +public class TimeSlotScope { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String code; + + @Column(nullable = false, length = 120) + private String name; + + @Column(name = "apply_mode", nullable = false, length = 20) + private String applyMode; + + @Column(name = "day_of_week") + private Integer dayOfWeek; + + @Column(name = "system_scope", nullable = false) + private Boolean systemScope = false; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder = 100; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getApplyMode() { + return applyMode; + } + + public void setApplyMode(String applyMode) { + this.applyMode = applyMode; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public Boolean getSystemScope() { + return systemScope; + } + + public void setSystemScope(Boolean systemScope) { + this.systemScope = systemScope; + } + + public Integer getDisplayOrder() { + return displayOrder; + } + + public void setDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java new file mode 100644 index 0000000..4fd642c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotDateAssignmentRepository.java @@ -0,0 +1,19 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TimeSlotDateAssignment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface TimeSlotDateAssignmentRepository extends JpaRepository { + + Optional findByDate(LocalDate date); + + List findAllByOrderByDateAsc(); + + List findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate); + + boolean existsByTimeSlotScopeId(Long scopeId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java index 7576c1a..dc364e9 100644 --- a/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java @@ -4,10 +4,15 @@ import com.magistr.app.model.TimeSlot; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface TimeSlotRepository extends JpaRepository { - List findAllByOrderByOrderNumberAsc(); + List findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc(); - boolean existsByOrderNumber(Integer orderNumber); + List findAllByTimeSlotScopeIdOrderByOrderNumberAsc(Long scopeId); + + Optional findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber); + + boolean existsByTimeSlotScopeId(Long scopeId); } diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java new file mode 100644 index 0000000..a03a02c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotScopeRepository.java @@ -0,0 +1,18 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TimeSlotScope; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TimeSlotScopeRepository extends JpaRepository { + + List findAllByOrderByDisplayOrderAscNameAsc(); + + Optional findByCode(String code); + + Optional findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode); + + Optional findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek); +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 4574678..e8e4473 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -4,6 +4,9 @@ import com.magistr.app.dto.RenderedLessonDto; import com.magistr.app.model.*; import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.ScheduleRuleRepository; +import com.magistr.app.repository.TimeSlotDateAssignmentRepository; +import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.TimeSlotScopeRepository; import com.magistr.app.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,21 +25,31 @@ public class ScheduleGeneratorService { private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class); private static final int ACADEMIC_HOURS_PER_SLOT = 2; private static final long MAX_RANGE_DAYS = 120; + private static final String APPLY_MODE_WEEKDAY = "WEEKDAY"; private final ScheduleRuleRepository scheduleRuleRepository; private final GroupRepository groupRepository; private final UserRepository userRepository; private final AcademicDateService academicDateService; + private final TimeSlotRepository timeSlotRepository; + private final TimeSlotScopeRepository timeSlotScopeRepository; + private final TimeSlotDateAssignmentRepository dateAssignmentRepository; private final Map consumedHoursCache = new ConcurrentHashMap<>(); public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, GroupRepository groupRepository, UserRepository userRepository, - AcademicDateService academicDateService) { + AcademicDateService academicDateService, + TimeSlotRepository timeSlotRepository, + TimeSlotScopeRepository timeSlotScopeRepository, + TimeSlotDateAssignmentRepository dateAssignmentRepository) { this.scheduleRuleRepository = scheduleRuleRepository; this.groupRepository = groupRepository; this.userRepository = userRepository; this.academicDateService = academicDateService; + this.timeSlotRepository = timeSlotRepository; + this.timeSlotScopeRepository = timeSlotScopeRepository; + this.dateAssignmentRepository = dateAssignmentRepository; } public List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) { @@ -247,7 +260,7 @@ public class ScheduleGeneratorService { List groups, int consumedBeforeLesson, int remainingAfterLesson) { - TimeSlot timeSlot = slot.getTimeSlot(); + TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date); List sortedGroups = groups.stream() .sorted(Comparator.comparing(StudentGroup::getName)) .toList(); @@ -283,6 +296,25 @@ public class ScheduleGeneratorService { ); } + private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) { + if (baseSlot == null || baseSlot.getOrderNumber() == null) { + return baseSlot; + } + return effectiveScope(date) + .flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber( + scope.getId(), + baseSlot.getOrderNumber())) + .orElse(baseSlot); + } + + private Optional effectiveScope(LocalDate date) { + return dateAssignmentRepository.findByDate(date) + .map(TimeSlotDateAssignment::getTimeSlotScope) + .or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek( + APPLY_MODE_WEEKDAY, + date.getDayOfWeek().getValue())); + } + public static String dayName(DayOfWeek dayOfWeek) { String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU")); return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1); diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 43f45e6..95a5285 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -236,20 +236,55 @@ CREATE TABLE IF NOT EXISTS teacher_lesson_types ( -- Динамическое расписание: календарь, слоты и правила -- ========================================== +CREATE TABLE IF NOT EXISTS time_slot_scopes ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + apply_mode VARCHAR(20) NOT NULL, + day_of_week INT NULL, + system_scope BOOLEAN NOT NULL DEFAULT FALSE, + display_order INT NOT NULL DEFAULT 100, + CONSTRAINT chk_time_slot_scopes_mode CHECK (apply_mode IN ('DEFAULT', 'WEEKDAY', 'MANUAL')), + CONSTRAINT chk_time_slot_scopes_day CHECK (day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7), + CONSTRAINT chk_time_slot_scopes_weekday CHECK ( + (apply_mode = 'WEEKDAY' AND day_of_week IS NOT NULL) + OR (apply_mode <> 'WEEKDAY' AND day_of_week IS NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_default + ON time_slot_scopes(apply_mode) + WHERE apply_mode = 'DEFAULT'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday + ON time_slot_scopes(day_of_week) + WHERE apply_mode = 'WEEKDAY'; + +INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES + ('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10), + ('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20) +ON CONFLICT (code) DO NOTHING; + CREATE TABLE IF NOT EXISTS time_slots ( id BIGSERIAL PRIMARY KEY, + time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE, order_number INT NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, duration_minutes INT NOT NULL, CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0), CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time), - CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0), - CONSTRAINT uq_time_slots_order UNIQUE (order_number), - CONSTRAINT uq_time_slots_range UNIQUE (start_time, end_time) + CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0) ); -INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VALUES +CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order + ON time_slots(time_slot_scope_id, order_number); + +CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number); + +INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes) +SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes +FROM (VALUES (1, '08:00', '09:30', 90), (2, '09:40', '11:10', 90), (3, '11:40', '13:10', 90), @@ -257,7 +292,19 @@ INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VA (5, '15:00', '16:30', 90), (6, '16:50', '18:20', 90), (7, '18:30', '20:00', 90) -ON CONFLICT (start_time, end_time) DO NOTHING; +) AS data(order_number, start_time, end_time, duration_minutes) +CROSS JOIN time_slot_scopes scope +WHERE scope.code IN ('default', 'saturday') +ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS time_slot_date_assignments ( + id BIGSERIAL PRIMARY KEY, + assignment_date DATE NOT NULL UNIQUE, + time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_time_slot_date_assignments_scope + ON time_slot_date_assignments(time_slot_scope_id); CREATE TABLE IF NOT EXISTS academic_years ( id BIGSERIAL PRIMARY KEY, @@ -522,7 +569,8 @@ FROM (VALUES ('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн') ) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format) JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key -JOIN time_slots ts ON ts.order_number = data.time_slot_order +JOIN time_slot_scopes scope ON scope.code = 'default' +JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order JOIN users teacher ON teacher.username = data.teacher_username JOIN classrooms classroom ON classroom.name = data.classroom_name JOIN lesson_types lt ON lt.name = data.lesson_type_name; @@ -555,7 +603,9 @@ COMMENT ON TABLE users IS 'Пользователи системы (студен COMMENT ON TABLE departments IS 'Кафедры'; COMMENT ON TABLE specialties IS 'Специальности'; COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности'; +COMMENT ON TABLE time_slot_scopes IS 'Именованные сетки времени: базовая, автоматическая субботняя и ручные сетки'; COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; +COMMENT ON TABLE time_slot_date_assignments IS 'Ручное назначение сетки времени на конкретную дату'; COMMENT ON TABLE academic_years IS 'Учебные годы'; COMMENT ON TABLE semesters IS 'Семестры учебного года'; COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; diff --git a/docs/API.md b/docs/API.md index 8b6c51a..da8d84b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -116,7 +116,7 @@ | `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | | `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | -Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. +Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/api/admin/time-slots`. **Пример:** ```http @@ -160,25 +160,51 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 ### `GET /api/admin/time-slots` -Список временных слотов занятий. CRUD доступен по: +Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной. | Метод | URL | Назначение | |-------|-----|------------| | `GET` | `/api/admin/time-slots` | Список слотов | +| `GET` | `/api/admin/time-slots/effective?date=2026-05-02` | Эффективные слоты для даты | | `POST` | `/api/admin/time-slots` | Создать слот | | `PUT` | `/api/admin/time-slots/{id}` | Обновить слот | | `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот | +| `GET` | `/api/admin/time-slots/scopes` | Список сеток времени | +| `POST` | `/api/admin/time-slots/scopes` | Создать ручную сетку | +| `PUT` | `/api/admin/time-slots/scopes/{id}` | Переименовать ручную сетку | +| `DELETE` | `/api/admin/time-slots/scopes/{id}` | Удалить ручную сетку | +| `GET` | `/api/admin/time-slots/date-assignments` | Ручные назначения дат | +| `POST` | `/api/admin/time-slots/date-assignments` | Применить ручную сетку к дате | +| `DELETE` | `/api/admin/time-slots/date-assignments/{id}` | Убрать ручное назначение | **Тело создания/обновления:** ```json { "orderNumber": 1, + "scopeId": 1, "startTime": "08:00:00", "endTime": "09:30:00", "durationMinutes": 90 } ``` +**Создание ручной сетки:** +```json +{ + "name": "Праздничная сетка" +} +``` + +**Ручное применение сетки к дате:** +```json +{ + "date": "2026-05-08", + "scopeId": 3 +} +``` + +Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments` и имеют приоритет над автоматической субботней сеткой. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации. + ### Учебные годы, семестры и коды календарного графика | Метод | URL | Назначение | @@ -281,6 +307,8 @@ CRUD доступен по: | `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | | `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило | +`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. + ### `GET /api/lesson-types` Справочник типов занятий для конструктора правил расписания. @@ -436,7 +464,7 @@ CRUD доступен по: } ``` -Назначаемый график должен относиться к тому же учебному году, специальности и профилю, что и группа. +Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. --- @@ -453,6 +481,8 @@ CRUD доступен по: "id": 1, "name": "101 Ленинская", "capacity": 120, + "building": "Главный корпус", + "floor": 2, "isAvailable": true, "equipments": [ { "id": 1, "name": "Проектор" }, @@ -470,6 +500,8 @@ CRUD доступен по: { "name": "404 Лаборатория", "capacity": 30, + "building": "Лабораторный корпус", + "floor": 4, "isAvailable": true, "equipmentIds": [1, 2, 3] } diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 4beea74..61ce347 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -94,29 +94,34 @@ 5. Пропускает день, если код активности не разрешает обычные пары. 6. Загружает правила группы или преподавателя. 7. Симулирует уже проведённые занятия от `active_from_date`. -8. Останавливает вывод правила, когда достигнут `total_academic_hours`. +8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую. +9. Останавливает вывод правила, когда достигнут `total_academic_hours`. Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. -### Временны́е слоты +### Временные слоты -Сетка пар хранится в `time_slots` и настраивается для каждого тенанта. При миграции создаются базовые слоты: +Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. + +Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание. + +При миграции одинаковые стартовые слоты создаются для базовой и субботней сетки: | № | Время | |---|-------| | 1 | 08:00 – 09:30 | | 2 | 09:40 – 11:10 | | 3 | 11:40 – 13:10 | -| 4 | 13:30 – 15:00 | +| 4 | 13:20 – 14:50 | | 5 | 15:00 – 16:30 | -| 6 | 16:40 – 18:10 | +| 6 | 16:50 – 18:20 | | 7 | 18:30 – 20:00 | ### Валидация правил расписания - **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. -- **Связанные сущности:** временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. +- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index f03926c..e5f22b5 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -19,7 +19,7 @@ erDiagram VARCHAR name BIGINT code UK } - + specialties { BIGSERIAL id PK VARCHAR name @@ -125,14 +125,31 @@ erDiagram BIGINT lesson_type_id FK,PK } + time_slot_scopes { + BIGSERIAL id PK + VARCHAR code UK + VARCHAR name + VARCHAR apply_mode + INT day_of_week + BOOLEAN system_scope + INT display_order + } + time_slots { BIGSERIAL id PK - INT order_number UK + BIGINT time_slot_scope_id FK + INT order_number TIME start_time TIME end_time INT duration_minutes } + time_slot_date_assignments { + BIGSERIAL id PK + DATE assignment_date UK + BIGINT time_slot_scope_id FK + } + academic_years { BIGSERIAL id PK VARCHAR title UK @@ -244,6 +261,8 @@ erDiagram academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" + time_slot_scopes ||--o{ time_slots : "time_slot_scope_id" + time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id" time_slots ||--o{ schedule_rule_slots : "time_slot_id" subgroups ||--o{ schedule_rule_slots : "subgroup_id" users ||--o{ schedule_rule_slots : "teacher_id" @@ -390,15 +409,38 @@ erDiagram | `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | | `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия | +#### `time_slot_scopes` — Сетки времени +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `code` | VARCHAR(50) UNIQUE | Системный код сетки | +| `name` | VARCHAR(120) | Название в интерфейсе | +| `apply_mode` | VARCHAR(20) | `DEFAULT`, `WEEKDAY`, `MANUAL` | +| `day_of_week` | INT NULL | День недели для автоматической сетки | +| `system_scope` | BOOLEAN | Защищает базовую и субботнюю сетки от удаления | +| `display_order` | INT | Порядок в списках | + +Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботняя сетка` (`WEEKDAY`, `day_of_week = 6`). Дополнительные сетки создаются как `MANUAL` и применяются только через ручные назначения дат. + #### `time_slots` — Временные слоты занятий | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `order_number` | INT UNIQUE | Номер пары в дне | +| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Сетка времени | +| `order_number` | INT | Номер пары в дне | | `start_time` | TIME | Время начала | | `end_time` | TIME | Время окончания | | `duration_minutes` | INT | Длительность в минутах | +Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может быть только один слот с номером пары. + +#### `time_slot_date_assignments` — Ручные назначения сеток времени +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `assignment_date` | DATE UNIQUE | Дата ручного применения | +| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Ручная сетка времени | + #### `academic_years` — Учебные годы | Колонка | Тип | Описание | |---------|-----|----------| @@ -481,7 +523,7 @@ erDiagram | `schedule_rule_id` | BIGINT FK → schedule_rules | Правило | | `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник | | `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | -| `time_slot_id` | BIGINT FK → time_slots | Временной слот | +| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот | | `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа | | `teacher_id` | BIGINT FK → users | Преподаватель | | `classroom_id` | BIGINT FK → classrooms | Аудитория | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6b2a1b6..58177b2 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -43,7 +43,7 @@ frontend/ │ │ ├── equipments.js # Управление оборудованием │ │ ├── edu-forms.js # Формы обучения │ │ ├── profiles.js # Профили обучения специальностей -│ │ ├── schedule.js # Конструктор правил расписания и сетки пар +│ │ ├── schedule.js # Конструктор правил расписания │ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── database.js # Управление тенантами │ │ └── departments-data.js # Создание кафедры/специальности @@ -66,9 +66,12 @@ frontend/ │ │ ├── main.css # CSS-переменные, базовые стили │ │ └── layout.css # Sidebar, topbar, content │ ├── js/ -│ │ └── main.js # Навигация по вкладкам настроек +│ │ ├── main.js # Навигация по вкладкам настроек +│ │ └── views/ +│ │ └── time-slots.js # Настройка временных слотов │ └── views/ -│ └── general.html # Общие настройки (заглушка) +│ ├── general.html # Общие настройки (заглушка) +│ └── time-slots.html # Базовая, субботняя и ручные сетки времени │ ├── teacher/ # 👩‍🏫 Интерфейс преподавателя │ └── index.html # Недельный просмотр динамического расписания преподавателя @@ -109,18 +112,18 @@ frontend/ | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Конструктор правил динамического расписания и сетка пар | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` | +| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | -| `auditorium-workload` | Загруженность аудиторий: аудитории в строках, временные слоты в столбцах | mock-данные | +| `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` | | `database` | Тенанты | `/api/database` | | `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | ### Особенности админских вкладок - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. -- Вкладка `auditorium-workload` показывает матрицу загруженности: строки — аудитории, столбцы — временные слоты. +- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор базовых слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. @@ -133,6 +136,7 @@ frontend/ - Кнопка «Назад в панель» для возврата в `/admin/` - Текущие вкладки: - **Общие настройки** — заглушка (в разработке) + - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление/удаление ручных сеток и назначение ручной сетки на конкретную дату. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам. --- diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index ad76478..eb01efb 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -22,7 +22,7 @@ vertical-align: top; position: relative; min-width: 150px; - height: 80px; + height: 86px; } .workload-table th { @@ -37,29 +37,85 @@ box-shadow: 0 1px 0 var(--bg-card-border); } +.workload-table th strong, +.workload-table th span, +.workload-table th small { + display: block; +} + +.workload-table th strong { + color: var(--text-primary); + font-size: 0.82rem; + line-height: 1.1; +} + +.workload-table th span { + margin-top: 0.2rem; + color: var(--text-secondary); + font-size: 0.76rem; + line-height: 1.1; +} + +.workload-table th small { + width: fit-content; + margin: 0.35rem auto 0; + padding: 0.1rem 0.35rem; + border-radius: 999px; + background: rgba(139, 92, 246, 0.16); + color: var(--accent-hover); + font-size: 0.68rem; + font-weight: 700; + line-height: 1.2; +} + .workload-table .time-cell, .workload-table .axis-cell { background: var(--bg-input); - color: var(--text-secondary); - font-weight: 500; - text-align: center; + color: var(--text-primary); + font-weight: 600; + text-align: left; vertical-align: middle; - width: 120px; - min-width: 120px; + width: 140px; + min-width: 140px; position: sticky; left: 0; z-index: 5; box-shadow: 1px 0 0 var(--bg-card-border); } +.workload-table .axis-cell strong, +.workload-table .axis-cell span, +.workload-table .axis-cell small { + display: block; +} + +.workload-table .axis-cell strong { + color: var(--text-primary); + font-size: 0.9rem; + line-height: 1.15; +} + +.workload-table .axis-cell span, +.workload-table .axis-cell small { + margin-top: 0.2rem; + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.15; +} + +.workload-table .axis-cell.room-unavailable { + color: var(--error); + background: color-mix(in srgb, var(--error) 8%, var(--bg-input)); +} + .workload-table .top-left-cell { position: sticky; top: 0; left: 0; z-index: 20; background: var(--bg-input); - min-width: 120px; - width: 120px; + min-width: 140px; + width: 140px; box-shadow: 1px 1px 0 var(--bg-card-border); } @@ -96,6 +152,36 @@ color: var(--text-secondary); } +.workload-summary { + margin: -0.75rem 0 1rem; + color: var(--text-secondary); + font-size: 0.86rem; +} + +.workload-filter-empty { + padding: 0.75rem; + color: var(--text-secondary); + font-size: 0.86rem; +} + +.workload-busy-cell { + background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary)); +} + +.workload-free-cell { + background: color-mix(in srgb, var(--success) 4%, var(--bg-primary)); +} + +.free-slot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 58px; + color: var(--text-secondary); + font-size: 0.78rem; +} + /* Карточки занятий внутри ячеек сетки */ .lesson-card { background: var(--bg-card); @@ -138,6 +224,13 @@ font-size: 0.75rem; } +.lesson-meta { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.2; +} + /* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index eaaaad6..ba6e222 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -2,13 +2,13 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; const DAY_OPTIONS = [ - { value: 1, label: 'Понедельник' }, - { value: 2, label: 'Вторник' }, - { value: 3, label: 'Среда' }, - { value: 4, label: 'Четверг' }, - { value: 5, label: 'Пятница' }, - { value: 6, label: 'Суббота' }, - { value: 7, label: 'Воскресенье' } + { value: 1, label: 'Понедельник', shortLabel: 'Пн' }, + { value: 2, label: 'Вторник', shortLabel: 'Вт' }, + { value: 3, label: 'Среда', shortLabel: 'Ср' }, + { value: 4, label: 'Четверг', shortLabel: 'Чт' }, + { value: 5, label: 'Пятница', shortLabel: 'Пт' }, + { value: 6, label: 'Суббота', shortLabel: 'Сб' }, + { value: 7, label: 'Воскресенье', shortLabel: 'Вс' } ]; const SEMESTER_LABELS = { @@ -548,7 +548,7 @@ export async function initAcademicCalendar() { ${DAY_OPTIONS.map(day => ` - ${escapeHtml(day.label.slice(0, 2))} + ${escapeHtml(day.shortLabel)} ${weeks.map(week => { const row = cellByWeekAndDay.get(`${week}:${day.value}`); return row ? renderCalendarDay(row) : ''; @@ -804,8 +804,7 @@ export async function initAcademicCalendar() { } function shortDayLabel(value) { - const label = dayLabel(value); - return label === '-' ? '-' : label.slice(0, 2); + return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-'; } function formatShortDate(value) { diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js index a608e24..d7f12d2 100644 --- a/frontend/admin/js/views/auditorium-workload.js +++ b/frontend/admin/js/views/auditorium-workload.js @@ -1,159 +1,417 @@ -import { initMultiSelect } from '../utils.js'; +import { api } from '../api.js'; +import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js'; -export function initAuditoriumWorkload() { - // Дата по умолчанию — текущий день. +const NO_BUILDING = '__no_building__'; + +const DAY_SHORT_LABELS = { + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + 7: 'Вс' +}; + +const CAPACITY_BUCKETS = [ + { id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 }, + { id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 }, + { id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 }, + { id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 } +]; + +export async function initAuditoriumWorkload() { const dateInput = document.getElementById('workload-date'); - if (dateInput) { + const refreshButton = document.getElementById('workload-refresh'); + const headerRow = document.getElementById('workload-header-row'); + const tbody = document.getElementById('workload-tbody'); + const summary = document.getElementById('workload-summary'); + + let classrooms = []; + let timeSlots = []; + let equipments = []; + let groups = []; + let lessonsByRoomSlot = new Map(); + let failedGroupLoads = 0; + + setDefaultDate(); + initFilters(); + bindEvents(); + await loadInitialData(); + + function setDefaultDate() { + if (!dateInput || dateInput.value) return; const today = new Date(); - const yyyy = today.getFullYear(); - const mm = String(today.getMonth() + 1).padStart(2, '0'); - const dd = String(today.getDate()).padStart(2, '0'); - dateInput.value = `${yyyy}-${mm}-${dd}`; + dateInput.value = [ + today.getFullYear(), + String(today.getMonth() + 1).padStart(2, '0'), + String(today.getDate()).padStart(2, '0') + ].join('-'); } - // Инициализация мультиселектов. - initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); - initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes'); - initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); + function initFilters() { + initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); + initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes'); + initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); + } - // Заполнение фильтров начальными данными. - populateFilters(); + function bindEvents() { + refreshButton?.addEventListener('click', loadScheduleForSelectedDate); + dateInput?.addEventListener('change', loadScheduleForSelectedDate); - // Отрисовка mock-данных в нужной ориентации сетки. - renderMockGrid(); -} + [ + ['building-checkboxes', 'building-text', 'Все корпуса'], + ['capacity-checkboxes', 'capacity-text', 'Любая вместимость'], + ['equipment-checkboxes', 'equipment-text', 'Любое оборудование'] + ].forEach(([containerId, textId, emptyText]) => { + const container = document.getElementById(containerId); + container?.addEventListener('change', () => { + updateFilterText(containerId, textId, emptyText); + renderGrid(); + }); + }); + } -function populateFilters() { - // Корпуса. - const buildingsContainer = document.getElementById('building-checkboxes'); - const buildings = [ - { id: 1, name: "Корпус 1 (Главный)" }, - { id: 2, name: "Корпус 2 (Физ-мат)" }, - { id: 3, name: "Корпус 3 (Гуманитарный)" } - ]; - buildingsContainer.innerHTML = buildings.map(item => ` - - `).join(''); + async function loadInitialData() { + setTableLoading('Загрузка аудиторий и расписания...'); + try { + [classrooms, equipments, groups] = await Promise.all([ + api.get('/api/classrooms'), + api.get('/api/equipments'), + api.get('/api/groups') + ]); + classrooms = [...classrooms].sort((a, b) => naturalCompare(a.name, b.name)); + populateFilters(); + await loadScheduleForSelectedDate(); + } catch (error) { + setTableError(error.message || 'Ошибка загрузки данных загруженности аудиторий'); + } + } - // Вместимость. - const capacityContainer = document.getElementById('capacity-checkboxes'); - const capacities = [ - { id: 'small', name: "До 30 мест" }, - { id: 'medium', name: "30 - 60 мест" }, - { id: 'large', name: "60 - 100 мест" }, - { id: 'xlarge', name: "Более 100 мест" } - ]; - capacityContainer.innerHTML = capacities.map(item => ` - - `).join(''); + async function loadScheduleForSelectedDate() { + const date = dateInput?.value; + if (!date) { + showAlert('workload-alert', 'Выберите дату', 'error'); + return; + } - // Оборудование. - const equipmentContainer = document.getElementById('equipment-checkboxes'); - const equipmentList = [ - { id: 1, name: "Проектор" }, - { id: 2, name: "Компьютерные места" }, - { id: 3, name: "Интерактивная доска" }, - { id: 4, name: "Микрофон" } - ]; - equipmentContainer.innerHTML = equipmentList.map(item => ` - - `).join(''); -} + hideAlert('workload-alert'); + setTableLoading('Загрузка расписания на выбранную дату...'); + timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`); -function renderMockGrid() { - // В будущем данные будут загружаться из API. - const timeslots = [ - "8:00-9:30", - "9:40-11:10", - "11:40-13:10", - "13:20-14:50", - "15:00-16:30", - "16:50-18:20", - "18:30-19:50", - "20:00-21:20" - ]; + if (!groups.length) { + lessonsByRoomSlot = new Map(); + renderGrid(); + showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error'); + return; + } - const auditoriums = [ - "201", "202", "204", "205", "206", "207", "208" - ]; + const params = group => new URLSearchParams({ + groupId: String(group.id), + startDate: date, + endDate: date + }).toString(); - // Mock-расписание по аудитории и времени. - // Ключ: "roomId_timeSlotId", значение: объект занятия - const mockSchedule = { - "201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." }, - "201_9:40-11:10": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." }, - "201_11:40-13:10": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." }, - "201_13:20-14:50": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." }, - - "202_9:40-11:10": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." }, - "202_13:20-14:50": { subject: "Математика", group: "ИВТ-21-1", teacher: "Рутина Л.П." }, - "202_15:00-16:30": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." }, - "202_16:50-18:20": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." }, + const responses = await Promise.allSettled( + groups.map(group => api.get(`/api/schedule?${params(group)}`)) + ); + failedGroupLoads = responses.filter(response => response.status === 'rejected').length; + const lessons = responses + .filter(response => response.status === 'fulfilled') + .flatMap(response => response.value || []); - "205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." }, - }; + lessonsByRoomSlot = groupLessons(lessons); + renderGrid(); - // В столбцах отображаются временные слоты, в строках — аудитории. - const headerRow = document.getElementById('workload-header-row'); - headerRow.innerHTML = ` - - Время - Аудитория - - `; - - timeslots.forEach(time => { - const th = document.createElement('th'); - th.textContent = time; - headerRow.appendChild(th); - }); + if (failedGroupLoads > 0) { + showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error'); + } + } - // Строки таблицы. - const tbody = document.getElementById('workload-tbody'); - tbody.innerHTML = ''; - - auditoriums.forEach((room) => { - const tr = document.createElement('tr'); - - // Ячейка аудитории. - const tdRoom = document.createElement('td'); - tdRoom.className = 'axis-cell'; - tdRoom.textContent = room; - tr.appendChild(tdRoom); + function populateFilters() { + renderCheckboxes( + 'building-checkboxes', + buildingOptions(), + item => item.value, + item => item.label + ); + renderCheckboxes( + 'capacity-checkboxes', + CAPACITY_BUCKETS, + item => item.id, + item => item.name + ); + renderCheckboxes( + 'equipment-checkboxes', + equipmentOptions(), + item => item.id, + item => item.name + ); - // Ячейки временных слотов для аудитории. - timeslots.forEach(time => { - const td = document.createElement('td'); - - const scheduleKey = `${room}_${time}`; - const lesson = mockSchedule[scheduleKey]; - - if (lesson) { - // Карточка занятия. - td.innerHTML = ` -
-
${lesson.subject}
-
${lesson.group}
-
${lesson.teacher}
-
- `; + updateFilterText('building-checkboxes', 'building-text', 'Все корпуса'); + updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость'); + updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование'); + } + + function buildingOptions() { + const values = new Map(); + classrooms.forEach(room => { + const key = buildingKey(room); + values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building); + }); + return Array.from(values.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => naturalCompare(a.label, b.label)); + } + + function equipmentOptions() { + const source = equipments.length + ? equipments + : classrooms.flatMap(room => room.equipments || []); + const values = new Map(); + source.forEach(item => { + if (item?.id && item?.name) values.set(String(item.id), item.name); + }); + return Array.from(values.entries()) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => naturalCompare(a.name, b.name)); + } + + function renderCheckboxes(containerId, items, valueFn, labelFn) { + const container = document.getElementById(containerId); + if (!container) return; + if (!items.length) { + container.innerHTML = '
Нет данных
'; + return; + } + container.innerHTML = items.map(item => ` + + `).join(''); + } + + function groupLessons(lessons) { + const unique = new Map(); + lessons.forEach(lesson => { + if (!lesson.classroomId || !lesson.timeSlotId) return; + const key = [ + lesson.date, + lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', + lesson.classroomId, + lesson.timeSlotId, + lesson.subjectId || '', + lesson.teacherId || '' + ].join(':'); + const existing = unique.get(key); + if (existing) { + existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames); + existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds); + return; } - - tr.appendChild(td); + unique.set(key, { + ...lesson, + groupNames: [...(lesson.groupNames || [])], + groupIds: [...(lesson.groupIds || [])] + }); }); - tbody.appendChild(tr); - }); + const grouped = new Map(); + unique.forEach(lesson => { + const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(lesson); + }); + grouped.forEach(items => { + items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); + }); + return grouped; + } + + function renderGrid() { + const visibleClassrooms = filteredClassrooms(); + renderHeader(); + tbody.innerHTML = ''; + + if (!visibleClassrooms.length) { + tbody.innerHTML = `Нет аудиторий по выбранным фильтрам`; + renderSummary(0, 0, 0); + return; + } + + visibleClassrooms.forEach(room => { + 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); + + timeSlots.forEach(slot => { + const td = document.createElement('td'); + const lessons = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []; + td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell'; + td.innerHTML = lessons.length + ? lessons.map(renderLessonCard).join('') + : 'Свободно'; + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + + const occupiedCells = countOccupiedCells(visibleClassrooms); + renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length); + } + + function renderHeader() { + headerRow.innerHTML = ` + + Время + Аудитория + + `; + + timeSlots.forEach(slot => { + const th = document.createElement('th'); + th.innerHTML = ` + ${escapeHtml(slot.orderNumber || '')} пара + ${escapeHtml(timeSlotLabel(slot))} + ${slot.scopeApplyMode === 'DEFAULT' ? '' : `${escapeHtml(scopeShort(slot))}`} + `; + headerRow.appendChild(th); + }); + } + + function renderLessonCard(lesson) { + return ` +
+
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
+
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
+
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
+
${escapeHtml([lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}
+
+ `; + } + + function filteredClassrooms() { + const selectedBuildings = selectedValues('building-checkboxes'); + const selectedCapacities = selectedValues('capacity-checkboxes'); + const selectedEquipment = selectedValues('equipment-checkboxes'); + + return classrooms.filter(room => { + const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room)); + const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS + .filter(bucket => selectedCapacities.includes(bucket.id)) + .some(bucket => bucket.match(room.capacity)); + const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id => + (room.equipments || []).some(item => String(item.id) === String(id)) + ); + return buildingMatches && capacityMatches && equipmentMatches; + }); + } + + function selectedValues(containerId) { + const container = document.getElementById(containerId); + if (!container) return []; + return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) + .map(input => input.value); + } + + function updateFilterText(containerId, textId, emptyText) { + const container = document.getElementById(containerId); + const text = document.getElementById(textId); + if (!container || !text) return; + const checked = Array.from(container.querySelectorAll('input[type="checkbox"]:checked')); + if (!checked.length) { + text.textContent = emptyText; + } else if (checked.length === 1) { + text.textContent = checked[0].closest('.checkbox-item')?.textContent.trim() || emptyText; + } else { + text.textContent = `Выбрано: ${checked.length}`; + } + } + + function setTableLoading(message) { + renderHeader(); + tbody.innerHTML = `${escapeHtml(message)}`; + if (summary) summary.textContent = ''; + } + + function setTableError(message) { + renderHeader(); + tbody.innerHTML = `${escapeHtml(message)}`; + showAlert('workload-alert', message, 'error'); + } + + function renderSummary(roomCount, 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}.`; + } + + function countOccupiedCells(rooms) { + return rooms.reduce((count, room) => count + timeSlots.filter(slot => + (lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0 + ).length, 0); + } + + function roomSlotKey(roomId, timeSlotId) { + return `${roomId}:${timeSlotId}`; + } + + function buildingKey(room) { + return room.building ? String(room.building) : NO_BUILDING; + } + + function mergeNames(first = [], second = []) { + return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b)); + } + + function mergeIds(first = [], second = []) { + return Array.from(new Set([...first, ...second].filter(id => id != null))); + } + + function timeSlotLabel(slot) { + return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`; + } + + function selectedIsoDay() { + if (!dateInput?.value) return null; + const date = new Date(`${dateInput.value}T00:00:00`); + const day = date.getDay(); + return day === 0 ? 7 : day; + } + + function scopeShort(slot) { + if (slot.scopeApplyMode === 'WEEKDAY') { + return dayShort(slot.scopeDayOfWeek); + } + return 'Ручн.'; + } + + function dayShort(dayOfWeek) { + return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; + } + + function trimTime(value) { + return value ? String(value).slice(0, 5) : ''; + } + + function formatDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; + } + + function naturalCompare(a, b) { + return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); + } } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index d0f0fa3..1d7ef60 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -37,17 +37,6 @@ export async function initSchedule() { const rulesTbody = document.getElementById('schedule-tbody'); const refreshRulesButton = document.getElementById('schedule-refresh'); - const timeSlotForm = document.getElementById('time-slot-form'); - const timeSlotFormTitle = document.getElementById('time-slot-form-title'); - const timeSlotIdInput = document.getElementById('time-slot-id'); - const timeSlotOrderInput = document.getElementById('time-slot-order'); - const timeSlotStartInput = document.getElementById('time-slot-start'); - const timeSlotEndInput = document.getElementById('time-slot-end'); - const timeSlotDurationInput = document.getElementById('time-slot-duration'); - const timeSlotResetButton = document.getElementById('time-slot-reset'); - const timeSlotsTbody = document.getElementById('time-slots-tbody'); - const refreshSlotsButton = document.getElementById('time-slots-refresh'); - let rules = []; let subjects = []; let groups = []; @@ -70,10 +59,8 @@ export async function initSchedule() { function bindEvents() { refreshRulesButton?.addEventListener('click', loadRules); - refreshSlotsButton?.addEventListener('click', loadTimeSlots); ruleResetButton?.addEventListener('click', resetRuleForm); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); - timeSlotResetButton?.addEventListener('click', resetTimeSlotForm); ruleSlotsContainer.addEventListener('click', (event) => { const removeButton = event.target.closest('.schedule-slot-remove'); @@ -84,9 +71,7 @@ export async function initSchedule() { }); ruleForm.addEventListener('submit', saveRule); - timeSlotForm.addEventListener('submit', saveTimeSlot); rulesTbody.addEventListener('click', handleRuleTableClick); - timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick); } async function loadBaseLists() { @@ -293,97 +278,10 @@ export async function initSchedule() { } async function loadTimeSlots() { - timeSlotsTbody.innerHTML = 'Загрузка...'; - try { - timeSlots = await api.get('/api/admin/time-slots'); - renderTimeSlots(); - } catch (error) { - timeSlotsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function renderTimeSlots() { - if (!timeSlots.length) { - timeSlotsTbody.innerHTML = 'Сетка пар не настроена'; - return; - } - timeSlotsTbody.innerHTML = timeSlots.map(slot => ` - - ${slot.orderNumber} - ${escapeHtml(trimTime(slot.startTime))} - ${escapeHtml(trimTime(slot.endTime))} - ${escapeHtml(String(slot.durationMinutes ?? '-'))} - - - - - - `).join(''); - } - - async function saveTimeSlot(event) { - event.preventDefault(); - hideAlert('time-slot-alert'); - const payload = { - id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null, - orderNumber: Number(timeSlotOrderInput.value), - startTime: timeSlotStartInput.value, - endTime: timeSlotEndInput.value, - durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null - }; - try { - if (!payload.orderNumber || !payload.startTime || !payload.endTime) { - throw new Error('Заполните номер пары и время'); - } - const id = timeSlotIdInput.value; - await (id - ? api.put('/api/admin/time-slots/' + id, payload) - : api.post('/api/admin/time-slots', payload)); - showAlert('time-slot-alert', 'Временной слот сохранён', 'success'); - resetTimeSlotForm(); - await loadTimeSlots(); - renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]); - } catch (error) { - showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error'); - } - } - - async function handleTimeSlotTableClick(event) { - const editButton = event.target.closest('.btn-edit-time-slot'); - const deleteButton = event.target.closest('.btn-delete-time-slot'); - - if (editButton) { - const slot = timeSlots.find(item => item.id == editButton.dataset.id); - if (!slot) return; - timeSlotFormTitle.textContent = 'Редактирование временного слота'; - timeSlotIdInput.value = slot.id; - timeSlotOrderInput.value = slot.orderNumber || ''; - timeSlotStartInput.value = trimTime(slot.startTime); - timeSlotEndInput.value = trimTime(slot.endTime); - timeSlotDurationInput.value = slot.durationMinutes || ''; - hideAlert('time-slot-alert'); - timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - - if (deleteButton) { - if (!confirm('Удалить временной слот?')) return; - try { - await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id); - showAlert('time-slot-alert', 'Временной слот удалён', 'success'); - await loadTimeSlots(); - renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]); - } catch (error) { - showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error'); - } - } - } - - function resetTimeSlotForm() { - timeSlotForm.reset(); - timeSlotFormTitle.textContent = 'Новый временной слот'; - timeSlotIdInput.value = ''; - hideAlert('time-slot-alert'); + const slots = await api.get('/api/admin/time-slots'); + timeSlots = slots + .filter(slot => slot.scopeApplyMode === 'DEFAULT') + .sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0)); } function populateSubjectSelects() { diff --git a/frontend/admin/settings/css/main.css b/frontend/admin/settings/css/main.css index f491eef..71e9ef8 100644 --- a/frontend/admin/settings/css/main.css +++ b/frontend/admin/settings/css/main.css @@ -1,4 +1,4 @@ -/* ===== Reset & Base ===== */ +/* ===== Сброс и база ===== */ *, *::before, *::after { @@ -34,7 +34,7 @@ --transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } -/* ===== Light Theme ===== */ +/* ===== Светлая тема ===== */ [data-theme="light"] { --bg-primary: #f8fafc; --bg-sidebar: rgba(255, 255, 255, 0.7); @@ -69,13 +69,13 @@ body { transition: background 0.4s ease, color 0.4s ease; } -/* ===== Animations ===== */ +/* ===== Анимации ===== */ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } -/* ===== Theme Toggle ===== */ +/* ===== Переключатель темы ===== */ .theme-toggle { width: 42px; height: 42px; @@ -116,7 +116,7 @@ body { right: 1.25rem; } -/* ===== Settings Placeholder ===== */ +/* ===== Заглушка настроек ===== */ .settings-placeholder { display: flex; flex-direction: column; @@ -151,3 +151,395 @@ body { max-width: 400px; line-height: 1.6; } + +/* ===== Рабочие области настроек ===== */ +.settings-section { + display: flex; + flex-direction: column; + gap: 1.25rem; + animation: fadeIn 0.25s ease both; +} + +.settings-header-row, +.card-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.settings-header-row h2, +.settings-card h3 { + margin: 0; + color: var(--text-primary); +} + +.settings-header-row h2 { + font-size: 1.35rem; + font-weight: 700; +} + +.settings-header-row p { + margin-top: 0.25rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.settings-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); + gap: 1.25rem; + align-items: start; +} + +.settings-grid-tables { + grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.9fr); +} + +.settings-card { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-card); + padding: 1rem; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.settings-card-compact { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.settings-form { + display: grid; + grid-template-columns: minmax(160px, 1.3fr) minmax(100px, 0.7fr) repeat(3, minmax(120px, 1fr)); + gap: 0.85rem; + margin-top: 1rem; + align-items: end; +} + +.settings-form-compact { + grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; +} + +.settings-form-assignment { + grid-template-columns: minmax(150px, 0.8fr) minmax(190px, 1fr) auto; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.4rem; + color: var(--text-secondary); + font-size: 0.82rem; + font-weight: 600; +} + +.form-field input, +.form-field select { + width: 100%; + min-height: 42px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + padding: 0 0.75rem; + font: inherit; + outline: none; + transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); +} + +.form-field input:focus, +.form-field select:focus { + border-color: var(--accent); + background: var(--bg-input-focus); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); +} + +.btn-primary, +.btn-secondary, +.btn-delete { + min-height: 40px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 0 0.9rem; + color: var(--text-primary); + font: inherit; + font-weight: 700; + cursor: pointer; + transition: transform var(--transition), background var(--transition), border-color var(--transition); + white-space: nowrap; +} + +.btn-primary { + background: var(--accent); +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--bg-input); + border-color: var(--bg-card-border); +} + +.btn-secondary:hover { + background: var(--bg-hover); + transform: translateY(-1px); +} + +.btn-delete { + background: rgba(239, 68, 68, 0.13); + border-color: rgba(239, 68, 68, 0.25); + color: #fecaca; +} + +[data-theme="light"] .btn-delete { + color: #991b1b; +} + +.btn-delete:hover { + background: rgba(239, 68, 68, 0.2); + transform: translateY(-1px); +} + +.btn-primary:disabled, +.btn-secondary:disabled, +.btn-delete:disabled { + cursor: not-allowed; + opacity: 0.45; + transform: none; +} + +.form-alert { + grid-column: 1 / -1; + min-height: 0; + border-radius: var(--radius-sm); + font-size: 0.9rem; +} + +.form-alert.success, +.form-alert.error { + padding: 0.75rem 0.9rem; +} + +.form-alert.success { + color: var(--success); + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.24); +} + +.form-alert.error { + color: var(--error); + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.24); +} + +.scope-summary { + display: flex; + align-items: center; + gap: 0.7rem; + margin-top: 1rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + padding: 0.75rem; + background: rgba(255, 255, 255, 0.02); + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.45; +} + +.table-wrap { + overflow-x: auto; +} + +.settings-table { + width: 100%; + border-collapse: collapse; + min-width: 680px; +} + +.settings-table-compact { + min-width: 420px; +} + +.settings-table th, +.settings-table td { + border-bottom: 1px solid var(--bg-card-border); + padding: 0.7rem 0.75rem; + text-align: left; + vertical-align: middle; +} + +.settings-table th { + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0; +} + +.settings-table td { + color: var(--text-primary); + font-size: 0.92rem; +} + +.settings-table tr:last-child td { + border-bottom: none; +} + +.loading-row { + text-align: center; + color: var(--text-secondary); + padding: 1.25rem !important; +} + +.scope-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + min-height: 28px; + border-radius: 999px; + background: rgba(236, 72, 153, 0.14); + color: #f9a8d4; + border: 1px solid rgba(236, 72, 153, 0.25); + font-weight: 800; +} + +.scope-badge-base { + min-width: 86px; + background: rgba(139, 92, 246, 0.15); + color: var(--accent-hover); + border-color: rgba(139, 92, 246, 0.28); +} + +.actions-cell { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.custom-select-wrapper { + position: relative; + width: 100%; +} + +.custom-select-trigger { + width: 100%; + min-height: 42px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + padding: 0 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + cursor: pointer; + transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); +} + +.custom-select-trigger:hover, +.custom-select-trigger:focus, +.custom-select-wrapper.open .custom-select-trigger { + border-color: var(--accent); + background: var(--bg-input-focus); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); +} + +.custom-select-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.custom-select-icon { + display: flex; + color: var(--text-secondary); + transition: transform var(--transition); +} + +.custom-select-wrapper.open .custom-select-icon { + transform: rotate(180deg); +} + +.custom-select-menu { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 0.35rem; + list-style: none; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: rgba(10, 10, 15, 0.96); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.24); + opacity: 0; + visibility: hidden; + transform: translateY(-4px); + transition: opacity var(--transition), transform var(--transition), visibility var(--transition); + z-index: 50; +} + +[data-theme="light"] .custom-select-menu { + background: rgba(255, 255, 255, 0.98); +} + +.custom-select-wrapper.open .custom-select-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.custom-select-item { + padding: 0.65rem 0.7rem; + border-radius: calc(var(--radius-sm) - 2px); + color: var(--text-primary); + cursor: pointer; + font-size: 0.9rem; +} + +.custom-select-item:hover:not(.disabled), +.custom-select-item.selected { + background: var(--bg-hover); + color: var(--accent-hover); +} + +.custom-select-item.disabled { + cursor: not-allowed; + opacity: 0.45; +} + +@media (max-width: 1180px) { + .settings-grid, + .settings-form { + grid-template-columns: 1fr; + } + + .settings-form .btn-primary { + width: fit-content; + } +} + +@media (max-width: 640px) { + .settings-header-row, + .card-header-row { + align-items: flex-start; + flex-direction: column; + } + + .settings-header-row .btn-primary, + .card-header-row .btn-secondary, + .settings-form .btn-primary { + width: 100%; + } +} diff --git a/frontend/admin/settings/index.html b/frontend/admin/settings/index.html index 398526a..3c10116 100644 --- a/frontend/admin/settings/index.html +++ b/frontend/admin/settings/index.html @@ -14,7 +14,7 @@ - +
- + - +
- +
diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js index 61a1ac3..f9a8a90 100644 --- a/frontend/admin/settings/js/main.js +++ b/frontend/admin/settings/js/main.js @@ -1,27 +1,35 @@ -// Settings page main.js +// Основной модуль страницы настроек import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js'; -// Auth check +// Проверка авторизации const token = localStorage.getItem('token'); const role = localStorage.getItem('role'); if (!token || role !== 'ADMIN') { window.location.href = '/'; } -// Global initialization for Custom Selects +// Глобальная инициализация кастомных списков document.addEventListener('DOMContentLoaded', () => { initAllCustomDropdowns(document.body); startDropdownAutoObserver(); }); -// Configuration +// Настройка вкладок const ROUTES = { general: { title: 'Общие настройки', file: 'views/general.html' }, + 'time-slots': { + title: 'Временные слоты', + file: 'views/time-slots.html', + init: async () => { + const module = await import('./views/time-slots.js'); + await module.initTimeSlotsSettings(); + } + }, }; let currentTab = null; -// DOM Elements +// DOM-элементы const appContent = document.getElementById('app-content'); const pageTitle = document.getElementById('page-title'); const navItems = document.querySelectorAll('.nav-item[data-tab]'); @@ -31,13 +39,13 @@ const menuToggle = document.getElementById('menu-toggle'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const main = document.querySelector('.main'); -// Init sidebar state from localStorage +// Состояние боковой панели из localStorage if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { sidebar.classList.add('collapsed'); main.classList.add('sidebar-collapsed'); } -// Menu Toggle (Hamburger) +// Кнопка меню menuToggle.addEventListener('click', () => { if (window.innerWidth <= 768) { sidebar.classList.toggle('open'); @@ -49,7 +57,7 @@ menuToggle.addEventListener('click', () => { } }); -// Sidebar Close (X button) +// Сворачивание боковой панели sidebarCloseBtn?.addEventListener('click', () => { if (window.innerWidth <= 768) { sidebar.classList.remove('open'); @@ -66,7 +74,7 @@ sidebarOverlay.addEventListener('click', () => { sidebarOverlay.classList.remove('open'); }); -// Navigation +// Навигация navItems.forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); @@ -85,10 +93,14 @@ async function switchTab(tab) { try { appContent.innerHTML = '
Загрузка...
'; const response = await fetch(ROUTES[tab].file); - if (!response.ok) throw new Error('Failed to load view'); + if (!response.ok) throw new Error('Не удалось загрузить вкладку'); const html = await response.text(); appContent.innerHTML = html; + initAllCustomDropdowns(appContent); + if (ROUTES[tab].init) { + await ROUTES[tab].init(); + } currentTab = tab; } catch (e) { @@ -96,10 +108,10 @@ async function switchTab(tab) { console.error(e); } - // Close mobile menu if open + // Закрываем мобильное меню после перехода sidebar.classList.remove('open'); sidebarOverlay.classList.remove('open'); } -// Load default tab +// Вкладка по умолчанию switchTab('general'); diff --git a/frontend/admin/settings/js/views/time-slots.js b/frontend/admin/settings/js/views/time-slots.js new file mode 100644 index 0000000..f3dca27 --- /dev/null +++ b/frontend/admin/settings/js/views/time-slots.js @@ -0,0 +1,358 @@ +import { api } from '../../../js/api.js'; +import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js'; + +const APPLY_MODE_LABELS = { + DEFAULT: 'применяется по умолчанию', + WEEKDAY: 'применяется автоматически', + MANUAL: 'применяется вручную' +}; + +const DAY_SHORT_LABELS = { + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + 7: 'Вс' +}; + +export async function initTimeSlotsSettings() { + const scopeSelect = document.getElementById('time-slot-scope-select'); + const scopeSummary = document.getElementById('time-slot-scope-summary'); + const scopeNameInput = document.getElementById('time-slot-scope-name'); + const scopeAddButton = document.getElementById('time-slot-scope-add'); + const scopeDeleteButton = document.getElementById('time-slot-scope-delete'); + + const assignmentForm = document.getElementById('time-slot-assignment-form'); + const assignmentDateInput = document.getElementById('time-slot-assignment-date'); + const assignmentScopeSelect = document.getElementById('time-slot-assignment-scope'); + const assignmentsTbody = document.getElementById('time-slot-assignments-tbody'); + + const form = document.getElementById('time-slot-form'); + const title = document.getElementById('time-slot-form-title'); + const idInput = document.getElementById('time-slot-id'); + const orderInput = document.getElementById('time-slot-order'); + const startInput = document.getElementById('time-slot-start'); + const endInput = document.getElementById('time-slot-end'); + const durationInput = document.getElementById('time-slot-duration'); + const resetButton = document.getElementById('time-slot-reset'); + const refreshButton = document.getElementById('time-slots-refresh'); + const tbody = document.getElementById('time-slots-tbody'); + + let scopes = []; + let slots = []; + let assignments = []; + let activeScopeId = null; + + scopeSelect?.addEventListener('change', () => { + activeScopeId = Number(scopeSelect.value); + resetForm(); + render(); + }); + scopeAddButton?.addEventListener('click', createScope); + scopeDeleteButton?.addEventListener('click', deleteActiveScope); + assignmentForm?.addEventListener('submit', saveAssignment); + form?.addEventListener('submit', saveSlot); + resetButton?.addEventListener('click', resetForm); + refreshButton?.addEventListener('click', loadAll); + startInput?.addEventListener('change', fillDurationIfEmpty); + endInput?.addEventListener('change', fillDurationIfEmpty); + tbody?.addEventListener('click', handleSlotTableClick); + assignmentsTbody?.addEventListener('click', handleAssignmentTableClick); + + await loadAll(); + + async function loadAll() { + tbody.innerHTML = 'Загрузка...'; + assignmentsTbody.innerHTML = 'Загрузка...'; + hideAlert('time-slot-alert'); + hideAlert('time-slot-assignment-alert'); + try { + [scopes, slots, assignments] = await Promise.all([ + api.get('/api/admin/time-slots/scopes'), + api.get('/api/admin/time-slots'), + api.get('/api/admin/time-slots/date-assignments') + ]); + if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) { + activeScopeId = defaultScope()?.id || scopes[0]?.id || null; + } + render(); + } catch (error) { + tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + assignmentsTbody.innerHTML = 'Нет данных'; + } + } + + function render() { + renderScopeSelects(); + renderScopeSummary(); + renderSlots(); + renderAssignments(); + } + + function renderScopeSelects() { + scopeSelect.innerHTML = scopes.map(scope => ` + + `).join(''); + + const manualScopes = scopes.filter(scope => scope.applyMode === 'MANUAL'); + assignmentScopeSelect.innerHTML = manualScopes.length + ? manualScopes.map(scope => ``).join('') + : ''; + assignmentScopeSelect.disabled = manualScopes.length === 0; + + const activeScope = selectedScope(); + scopeDeleteButton.disabled = !activeScope || activeScope.systemScope; + } + + function renderScopeSummary() { + const scope = selectedScope(); + if (!scope) { + scopeSummary.innerHTML = 'Сетки времени не настроены'; + return; + } + + const mode = APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode; + const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : ''; + const slotCount = slots.filter(slot => slot.scopeId === scope.id).length; + scopeSummary.innerHTML = ` + ${escapeHtml(scopeBadgeLabel(scope))} + ${escapeHtml(mode + day)}. Слотов: ${slotCount}. + `; + } + + function renderSlots() { + const scopeSlots = slots + .filter(slot => slot.scopeId === activeScopeId) + .sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0)); + + if (!scopeSlots.length) { + tbody.innerHTML = 'В выбранной сетке нет слотов'; + return; + } + + tbody.innerHTML = scopeSlots.map(slot => ` + + ${escapeHtml(String(slot.orderNumber ?? '-'))} + ${escapeHtml(trimTime(slot.startTime))} + ${escapeHtml(trimTime(slot.endTime))} + ${escapeHtml(String(slot.durationMinutes ?? '-'))} + + + + + + `).join(''); + } + + function renderAssignments() { + if (!assignments.length) { + assignmentsTbody.innerHTML = 'Ручных назначений нет'; + return; + } + + assignmentsTbody.innerHTML = assignments.map(assignment => ` + + ${escapeHtml(formatDate(assignment.date))} + ${escapeHtml(assignment.scopeName || '-')} + + + + + `).join(''); + } + + async function createScope() { + const name = scopeNameInput.value.trim(); + if (!name) { + showAlert('time-slot-alert', 'Введите название ручной сетки', 'error'); + return; + } + try { + const saved = await api.post('/api/admin/time-slots/scopes', { name }); + scopeNameInput.value = ''; + activeScopeId = saved.id; + showAlert('time-slot-alert', 'Сетка времени создана', 'success'); + await loadAll(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка создания сетки времени', 'error'); + } + } + + async function deleteActiveScope() { + const scope = selectedScope(); + if (!scope || scope.systemScope) return; + if (!confirm(`Удалить сетку "${scope.name}" вместе с её слотами и ручными назначениями?`)) return; + try { + await api.delete('/api/admin/time-slots/scopes/' + scope.id); + activeScopeId = defaultScope()?.id || null; + resetForm(); + showAlert('time-slot-alert', 'Сетка времени удалена', 'success'); + await loadAll(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка удаления сетки времени', 'error'); + } + } + + async function saveAssignment(event) { + event.preventDefault(); + hideAlert('time-slot-assignment-alert'); + + const payload = { + date: assignmentDateInput.value, + scopeId: assignmentScopeSelect.value ? Number(assignmentScopeSelect.value) : null + }; + + if (!payload.date || !payload.scopeId) { + showAlert('time-slot-assignment-alert', 'Выберите дату и ручную сетку', 'error'); + return; + } + + try { + await api.post('/api/admin/time-slots/date-assignments', payload); + assignmentForm.reset(); + showAlert('time-slot-assignment-alert', 'Сетка применена к дате', 'success'); + await loadAll(); + } catch (error) { + showAlert('time-slot-assignment-alert', error.message || 'Ошибка назначения сетки', 'error'); + } + } + + async function handleAssignmentTableClick(event) { + const deleteButton = event.target.closest('.btn-delete-assignment'); + if (!deleteButton) return; + if (!confirm('Убрать ручное применение сетки на эту дату?')) return; + try { + await api.delete('/api/admin/time-slots/date-assignments/' + deleteButton.dataset.id); + showAlert('time-slot-assignment-alert', 'Ручное назначение удалено', 'success'); + await loadAll(); + } catch (error) { + showAlert('time-slot-assignment-alert', error.message || 'Ошибка удаления назначения', 'error'); + } + } + + async function saveSlot(event) { + event.preventDefault(); + hideAlert('time-slot-alert'); + + const payload = { + id: idInput.value ? Number(idInput.value) : null, + orderNumber: Number(orderInput.value), + scopeId: activeScopeId, + startTime: startInput.value, + endTime: endInput.value, + durationMinutes: durationInput.value ? Number(durationInput.value) : null + }; + + try { + validateSlotPayload(payload); + const id = idInput.value; + await (id + ? api.put('/api/admin/time-slots/' + id, payload) + : api.post('/api/admin/time-slots', payload)); + showAlert('time-slot-alert', 'Временной слот сохранён', 'success'); + resetForm(); + await loadAll(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error'); + } + } + + async function handleSlotTableClick(event) { + const editButton = event.target.closest('.btn-edit-time-slot'); + const deleteButton = event.target.closest('.btn-delete-time-slot'); + + if (editButton) { + const slot = slots.find(item => String(item.id) === String(editButton.dataset.id)); + if (!slot) return; + activeScopeId = slot.scopeId; + title.textContent = 'Редактирование слота'; + idInput.value = slot.id; + orderInput.value = slot.orderNumber || ''; + startInput.value = trimTime(slot.startTime); + endInput.value = trimTime(slot.endTime); + durationInput.value = slot.durationMinutes || ''; + hideAlert('time-slot-alert'); + render(); + form.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteButton) { + if (!confirm('Удалить временной слот?')) return; + try { + await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id); + showAlert('time-slot-alert', 'Временной слот удалён', 'success'); + resetForm(); + await loadAll(); + } catch (error) { + showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error'); + } + } + } + + function validateSlotPayload(payload) { + if (!payload.scopeId) { + throw new Error('Выберите сетку времени'); + } + if (!payload.orderNumber || payload.orderNumber <= 0) { + throw new Error('Укажите номер пары'); + } + if (!payload.startTime || !payload.endTime) { + throw new Error('Укажите время начала и окончания'); + } + if (payload.startTime >= payload.endTime) { + throw new Error('Время начала должно быть раньше времени окончания'); + } + } + + function fillDurationIfEmpty() { + if (durationInput.value || !startInput.value || !endInput.value || startInput.value >= endInput.value) return; + const [startHour, startMinute] = startInput.value.split(':').map(Number); + const [endHour, endMinute] = endInput.value.split(':').map(Number); + durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute)); + } + + function resetForm() { + form.reset(); + idInput.value = ''; + title.textContent = 'Новый слот'; + hideAlert('time-slot-alert'); + } + + function selectedScope() { + return scopes.find(scope => scope.id === activeScopeId) || null; + } + + function defaultScope() { + return scopes.find(scope => scope.applyMode === 'DEFAULT') || null; + } +} + +function scopeOptionLabel(scope) { + const suffix = scope.applyMode === 'WEEKDAY' + ? `, ${dayShort(scope.dayOfWeek)}` + : ''; + return `${scope.name} - ${APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode}${suffix}`; +} + +function scopeBadgeLabel(scope) { + if (scope.applyMode === 'DEFAULT') return 'Базовая'; + if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek); + return 'Ручная'; +} + +function dayShort(dayOfWeek) { + return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; +} + +function trimTime(value) { + return value ? String(value).slice(0, 5) : ''; +} + +function formatDate(value) { + if (!value) return '-'; + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; +} diff --git a/frontend/admin/settings/views/time-slots.html b/frontend/admin/settings/views/time-slots.html new file mode 100644 index 0000000..0d9fd7f --- /dev/null +++ b/frontend/admin/settings/views/time-slots.html @@ -0,0 +1,124 @@ +
+
+
+

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

+

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

+
+ +
+ +
+
+
+

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

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

Ручное применение

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

Новый слот

+ +
+ +
+ + + + + + + + + + + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + +
ПараНачалоОкончаниеМинутДействия
Загрузка...
+
+
+ +
+
+ + + + + + + + + + + + + +
ДатаСетка
Загрузка...
+
+
+
+
diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html index abdc2a1..38a3bda 100644 --- a/frontend/admin/views/auditorium-workload.html +++ b/frontend/admin/views/auditorium-workload.html @@ -1,6 +1,7 @@

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

+
@@ -8,7 +9,7 @@
- Выберите корпуса... + Все корпуса @@ -23,7 +24,7 @@
- Выберите вместимость... + Любая вместимость @@ -38,7 +39,7 @@
- Выберите оборудование... + Любое оборудование @@ -55,6 +56,9 @@
+ +
+
diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 57825e5..cc5bc8e 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -75,58 +75,3 @@
- -
-
-

Новый временной слот

- -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-
-

Сетка пар

- -
-
- - - - - - - - - - - - - - - -
ПараНачалоОкончаниеМинутДействия
Загрузка...
-
-
From 44cfe8ab6ec0bf56876ff52425cb306a659341c2 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sat, 2 May 2026 01:34:21 +0300 Subject: [PATCH 06/16] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API.md | 2 +- docs/BUSINESS_LOGIC.md | 2 +- docs/FRONTEND.md | 4 +- frontend/admin/css/components.css | 31 +++++ frontend/admin/js/dropdown.js | 32 ++++- frontend/admin/js/views/academic-calendar.js | 115 ++++++++++++++- frontend/admin/settings/css/main.css | 91 +++++++++++- .../admin/settings/js/views/time-slots.js | 131 ++++++------------ frontend/admin/settings/views/time-slots.html | 123 ++++++++-------- frontend/admin/views/academic-calendar.html | 13 ++ 10 files changed, 390 insertions(+), 154 deletions(-) diff --git a/docs/API.md b/docs/API.md index da8d84b..2e1c6f7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -203,7 +203,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 } ``` -Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments` и имеют приоритет над автоматической субботней сеткой. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации. +Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments`, имеют приоритет над автоматической субботней сеткой и назначаются пользователем из модального окна ячейки редактора календарного графика. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации. ### Учебные годы, семестры и коды календарного графика diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 61ce347..16402f5 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -101,7 +101,7 @@ ### Временные слоты -Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. +Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. Ручное назначение выполняется из ячейки редактора календарного графика, потому что оно относится к конкретной учебной дате. Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 58177b2..3822004 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -124,7 +124,7 @@ frontend/ - Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор базовых слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила. -- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. +- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. @@ -136,7 +136,7 @@ frontend/ - Кнопка «Назад в панель» для возврата в `/admin/` - Текущие вкладки: - **Общие настройки** — заглушка (в разработке) - - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление/удаление ручных сеток и назначение ручной сетки на конкретную дату. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам. + - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика. --- diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index bc05ef2..774464e 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -316,6 +316,19 @@ font-weight: 800; } +.calendar-day-time-marker { + position: absolute; + right: 1px; + bottom: 1px; + padding: 0 2px; + border-radius: 3px; + background: var(--accent); + color: #fff; + font-size: 0.45rem; + font-weight: 800; + line-height: 1.15; +} + .calendar-day-activity { display: none !important; } @@ -411,6 +424,19 @@ font-size: 0.85rem; } +.calendar-day-dialog-time-grid { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--bg-card-border); +} + +.calendar-day-dialog-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.8rem; +} + .profiles-context { color: var(--text-secondary); font-weight: 600; @@ -471,6 +497,11 @@ input[type="number"] { font-family: inherit; } +.select-layer-active { + position: relative; + z-index: 300; +} + .custom-select-trigger { display: flex; align-items: center; diff --git a/frontend/admin/js/dropdown.js b/frontend/admin/js/dropdown.js index 4af5ab9..36ccf86 100644 --- a/frontend/admin/js/dropdown.js +++ b/frontend/admin/js/dropdown.js @@ -2,10 +2,17 @@ export class CustomSelect { constructor(originalSelect) { - if (originalSelect.classList.contains('custom-select-initialized')) return; + if (originalSelect.classList.contains('custom-select-initialized')) { + if (originalSelect.customSelectInstance) { + return originalSelect.customSelectInstance; + } + originalSelect.classList.remove('custom-select-initialized'); + } this.originalSelect = originalSelect; this.originalSelect.classList.add('custom-select-initialized'); + this.originalSelect.customSelectInstance = this; + this.layerParent = this.originalSelect.closest('.settings-card, .card'); // Hide original but keep it accessible for form submissions and JS this.originalSelect.style.display = 'none'; @@ -163,6 +170,7 @@ export class CustomSelect { this.closeAll(); // Close other open dropdowns if (!isOpen) { + this.layerParent?.classList.add('select-layer-active'); this.wrapper.classList.add('open'); // Scroll selected item into view const selectedItem = this.menu.querySelector('.selected'); @@ -178,10 +186,14 @@ export class CustomSelect { document.querySelectorAll('.custom-select-wrapper.open').forEach(wrapper => { wrapper.classList.remove('open'); }); + document.querySelectorAll('.select-layer-active').forEach(layer => { + layer.classList.remove('select-layer-active'); + }); } close() { this.wrapper.classList.remove('open'); + this.layerParent?.classList.remove('select-layer-active'); } handleItemClick(e, index) { @@ -198,12 +210,28 @@ export class CustomSelect { // Global initializer export function initAllCustomDropdowns(root = document) { - const selects = root.querySelectorAll('select:not(.custom-select-initialized)'); + const selects = root.querySelectorAll('select:not(.custom-select-initialized):not([data-native-select="true"])'); selects.forEach(select => { new CustomSelect(select); }); } +export function refreshCustomDropdowns(...selects) { + selects.filter(Boolean).forEach(select => { + if (select.dataset.nativeSelect === 'true') { + return; + } + if (select.customSelectInstance) { + select.customSelectInstance.rebuildMenu(); + return; + } + if (select.classList.contains('custom-select-initialized')) { + select.classList.remove('custom-select-initialized'); + } + new CustomSelect(select); + }); +} + // Observe DOM for automatically picking up new select elements export function startDropdownAutoObserver() { const observer = new MutationObserver((mutations) => { diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index ba6e222..9efd62f 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -67,6 +67,9 @@ export async function initAcademicCalendar() { const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day'); const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary'); const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity'); + const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope'); + const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save'); + const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear'); let academicYears = []; let semesters = []; @@ -75,12 +78,21 @@ export async function initAcademicCalendar() { let studyForms = []; let activityTypes = []; let calendars = []; + let timeSlotScopes = []; + let timeSlotDateAssignments = []; let activeCalendarDay = null; bindEvents(); try { - await Promise.all([loadBaseLists(), loadYears(), loadStudyForms(), loadActivityTypes()]); + await Promise.all([ + loadBaseLists(), + loadYears(), + loadStudyForms(), + loadActivityTypes(), + loadTimeSlotScopes(), + loadTimeSlotDateAssignments() + ]); await loadCalendars(); } catch (error) { showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error'); @@ -126,6 +138,8 @@ export async function initAcademicCalendar() { updateCalendarDayDialogSummary(activeCalendarDay); renderCalendarTotals(); }); + calendarDayDialogTimeSaveButton?.addEventListener('click', saveTimeSlotDateAssignment); + calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); academicYearForm?.addEventListener('submit', saveAcademicYear); semesterForm?.addEventListener('submit', saveSemester); @@ -309,6 +323,14 @@ export async function initAcademicCalendar() { populateActivitySelects(); } + async function loadTimeSlotScopes() { + timeSlotScopes = await api.get('/api/admin/time-slots/scopes'); + } + + async function loadTimeSlotDateAssignments() { + timeSlotDateAssignments = await api.get('/api/admin/time-slots/date-assignments'); + } + async function loadCalendars() { calendarsTbody.innerHTML = 'Загрузка...'; try { @@ -432,6 +454,7 @@ export async function initAcademicCalendar() { return; } try { + await loadTimeSlotDateAssignments(); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); const gridRows = rows.length ? rows : buildDefaultGrid(calendar); renderCalendarGrid(gridRows); @@ -573,12 +596,15 @@ export async function initAcademicCalendar() { data-date="${row.date}" data-week="${row.weekNumber}" data-day="${row.dayOfWeek}" + data-time-assignment-id="${timeAssignmentForDate(row.date)?.id || ''}" + data-time-scope-id="${timeAssignmentForDate(row.date)?.scopeId || ''}" role="button" tabindex="0" aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}" title="${escapeHtml(`${formatLongDate(row.date)}, ${dayName}, ${row.weekNumber} неделя, ${code} - ${activity.name || ''}`)}" style="--activity-color:${escapeHtml(color)}"> ${escapeHtml(code)} + ${timeAssignmentForDate(row.date) ? 'вр.' : ''} `; @@ -594,7 +620,9 @@ export async function initAcademicCalendar() { activityTypes.map(toActivityOption), day.querySelector('.calendar-day-activity').value ); + populateTimeScopeDialogSelect(day.dataset.date); syncSelects(calendarDayDialogActivitySelect); + syncSelects(calendarDayDialogTimeScopeSelect); updateCalendarDayDialogSummary(day); calendarDayDialog.classList.add('open'); } @@ -619,6 +647,77 @@ export async function initAcademicCalendar() { `; } + async function saveTimeSlotDateAssignment() { + if (!activeCalendarDay) return; + hideAlert('calendar-day-dialog-time-alert'); + const scopeId = calendarDayDialogTimeScopeSelect.value; + if (!scopeId) { + await clearTimeSlotDateAssignment(); + return; + } + try { + await api.post('/api/admin/time-slots/date-assignments', { + date: activeCalendarDay.dataset.date, + scopeId: Number(scopeId) + }); + await loadTimeSlotDateAssignments(); + updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Ручная сетка применена к дате', 'success'); + } catch (error) { + showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка применения временной сетки', 'error'); + } + } + + async function clearTimeSlotDateAssignment() { + if (!activeCalendarDay) return; + hideAlert('calendar-day-dialog-time-alert'); + const assignment = timeAssignmentForDate(activeCalendarDay.dataset.date); + if (!assignment) { + calendarDayDialogTimeScopeSelect.value = ''; + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Для даты нет ручной сетки', 'success'); + return; + } + try { + await api.delete('/api/admin/time-slots/date-assignments/' + assignment.id); + await loadTimeSlotDateAssignments(); + updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Ручная сетка убрана', 'success'); + } catch (error) { + showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка удаления ручной сетки', 'error'); + } + } + + function populateTimeScopeDialogSelect(date) { + const assignment = timeAssignmentForDate(date); + const manualScopes = timeSlotScopes.filter(scope => scope.applyMode === 'MANUAL'); + const automaticLabel = automaticTimeScopeLabel(date); + calendarDayDialogTimeScopeSelect.innerHTML = `` + + manualScopes.map(scope => ``).join(''); + calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; + calendarDayDialogTimeSaveButton.disabled = manualScopes.length === 0 && !assignment; + calendarDayDialogTimeClearButton.disabled = !assignment; + } + + function updateTimeAssignmentVisuals(date) { + const assignment = timeAssignmentForDate(date); + calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { + day.dataset.timeAssignmentId = assignment?.id || ''; + day.dataset.timeScopeId = assignment?.scopeId || ''; + const marker = day.querySelector('.calendar-day-time-marker'); + if (assignment && !marker) { + day.insertAdjacentHTML('beforeend', 'вр.'); + } + if (!assignment && marker) { + marker.remove(); + } + }); + } + function updateCalendarDayVisual(day) { if (!day) return; const select = day.querySelector('.calendar-day-activity'); @@ -784,6 +883,20 @@ export async function initAcademicCalendar() { return activityTypes.find(type => String(type.id) === String(id)); } + function timeAssignmentForDate(date) { + return timeSlotDateAssignments.find(item => item.date === date); + } + + function automaticTimeScopeLabel(date) { + const jsDate = new Date(`${date}T00:00:00`); + const jsDay = jsDate.getDay(); + const isoDay = jsDay === 0 ? 7 : jsDay; + const weekdayScope = timeSlotScopes.find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay); + const defaultScope = timeSlotScopes.find(scope => scope.applyMode === 'DEFAULT'); + const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка'; + return `По правилу даты (${scopeName})`; + } + function semesterLabel(semester) { const type = normalizeSemesterType(semester.semesterType); return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; diff --git a/frontend/admin/settings/css/main.css b/frontend/admin/settings/css/main.css index 71e9ef8..8406f80 100644 --- a/frontend/admin/settings/css/main.css +++ b/frontend/admin/settings/css/main.css @@ -223,6 +223,10 @@ body { grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; } +.settings-form-single { + grid-template-columns: minmax(240px, 1fr); +} + .settings-form-assignment { grid-template-columns: minmax(150px, 0.8fr) minmax(190px, 1fr) auto; } @@ -257,6 +261,10 @@ body { box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); } +.native-select-control { + cursor: pointer; +} + .btn-primary, .btn-secondary, .btn-delete { @@ -352,6 +360,13 @@ body { line-height: 1.45; } +.scope-header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.6rem; +} + .table-wrap { overflow-x: auto; } @@ -428,6 +443,11 @@ body { width: 100%; } +.select-layer-active { + position: relative; + z-index: 300; +} + .custom-select-trigger { width: 100%; min-height: 42px; @@ -487,7 +507,7 @@ body { visibility: hidden; transform: translateY(-4px); transition: opacity var(--transition), transform var(--transition), visibility var(--transition); - z-index: 50; + z-index: 200; } [data-theme="light"] .custom-select-menu { @@ -519,6 +539,71 @@ body { opacity: 0.45; } +.settings-modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: none; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(2, 6, 23, 0.64); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.settings-modal-overlay.open { + display: flex; +} + +.settings-modal-card { + width: min(100%, 460px); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + background: var(--bg-primary); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + padding: 1.25rem; +} + +.settings-modal-subtitle { + margin-top: 0.35rem; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.45; +} + +.settings-modal-close { + width: 36px; + height: 36px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + font-size: 1.3rem; + line-height: 1; + cursor: pointer; + transition: color var(--transition), border-color var(--transition), background var(--transition); +} + +.settings-modal-close:hover, +.settings-modal-close:focus { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.12); + color: var(--error); +} + +.settings-modal-form { + display: grid; + gap: 1rem; + margin-top: 1.2rem; +} + +.settings-modal-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + @media (max-width: 1180px) { .settings-grid, .settings-form { @@ -528,6 +613,10 @@ body { .settings-form .btn-primary { width: fit-content; } + + .scope-header-actions { + justify-content: flex-start; + } } @media (max-width: 640px) { diff --git a/frontend/admin/settings/js/views/time-slots.js b/frontend/admin/settings/js/views/time-slots.js index f3dca27..84932e3 100644 --- a/frontend/admin/settings/js/views/time-slots.js +++ b/frontend/admin/settings/js/views/time-slots.js @@ -20,15 +20,14 @@ const DAY_SHORT_LABELS = { export async function initTimeSlotsSettings() { const scopeSelect = document.getElementById('time-slot-scope-select'); const scopeSummary = document.getElementById('time-slot-scope-summary'); + const scopeOpenCreateButton = document.getElementById('time-slot-scope-open-create'); + const scopeModal = document.getElementById('time-slot-scope-modal'); + const scopeModalCloseButton = document.getElementById('time-slot-scope-modal-close'); + const scopeModalCancelButton = document.getElementById('time-slot-scope-modal-cancel'); + const scopeForm = document.getElementById('time-slot-scope-form'); const scopeNameInput = document.getElementById('time-slot-scope-name'); - const scopeAddButton = document.getElementById('time-slot-scope-add'); const scopeDeleteButton = document.getElementById('time-slot-scope-delete'); - const assignmentForm = document.getElementById('time-slot-assignment-form'); - const assignmentDateInput = document.getElementById('time-slot-assignment-date'); - const assignmentScopeSelect = document.getElementById('time-slot-assignment-scope'); - const assignmentsTbody = document.getElementById('time-slot-assignments-tbody'); - const form = document.getElementById('time-slot-form'); const title = document.getElementById('time-slot-form-title'); const idInput = document.getElementById('time-slot-id'); @@ -42,37 +41,40 @@ export async function initTimeSlotsSettings() { let scopes = []; let slots = []; - let assignments = []; let activeScopeId = null; scopeSelect?.addEventListener('change', () => { activeScopeId = Number(scopeSelect.value); resetForm(); - render(); + renderActiveScope(); }); - scopeAddButton?.addEventListener('click', createScope); + scopeOpenCreateButton?.addEventListener('click', openScopeModal); + scopeModalCloseButton?.addEventListener('click', closeScopeModal); + scopeModalCancelButton?.addEventListener('click', closeScopeModal); + scopeModal?.addEventListener('click', (event) => { + if (event.target === scopeModal) closeScopeModal(); + }); + scopeModal?.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeScopeModal(); + }); + scopeForm?.addEventListener('submit', createScope); scopeDeleteButton?.addEventListener('click', deleteActiveScope); - assignmentForm?.addEventListener('submit', saveAssignment); form?.addEventListener('submit', saveSlot); resetButton?.addEventListener('click', resetForm); refreshButton?.addEventListener('click', loadAll); startInput?.addEventListener('change', fillDurationIfEmpty); endInput?.addEventListener('change', fillDurationIfEmpty); tbody?.addEventListener('click', handleSlotTableClick); - assignmentsTbody?.addEventListener('click', handleAssignmentTableClick); await loadAll(); async function loadAll() { tbody.innerHTML = 'Загрузка...'; - assignmentsTbody.innerHTML = 'Загрузка...'; hideAlert('time-slot-alert'); - hideAlert('time-slot-assignment-alert'); try { - [scopes, slots, assignments] = await Promise.all([ + [scopes, slots] = await Promise.all([ api.get('/api/admin/time-slots/scopes'), - api.get('/api/admin/time-slots'), - api.get('/api/admin/time-slots/date-assignments') + api.get('/api/admin/time-slots') ]); if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) { activeScopeId = defaultScope()?.id || scopes[0]?.id || null; @@ -80,28 +82,30 @@ export async function initTimeSlotsSettings() { render(); } catch (error) { tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - assignmentsTbody.innerHTML = 'Нет данных'; } } function render() { renderScopeSelects(); + renderActiveScope(); + } + + function renderActiveScope() { + updateScopeActions(); renderScopeSummary(); renderSlots(); - renderAssignments(); } function renderScopeSelects() { scopeSelect.innerHTML = scopes.map(scope => ` - + `).join(''); + scopeSelect.value = activeScopeId ? String(activeScopeId) : ''; - const manualScopes = scopes.filter(scope => scope.applyMode === 'MANUAL'); - assignmentScopeSelect.innerHTML = manualScopes.length - ? manualScopes.map(scope => ``).join('') - : ''; - assignmentScopeSelect.disabled = manualScopes.length === 0; + updateScopeActions(); + } + function updateScopeActions() { const activeScope = selectedScope(); scopeDeleteButton.disabled = !activeScope || activeScope.systemScope; } @@ -146,37 +150,35 @@ export async function initTimeSlotsSettings() { `).join(''); } - function renderAssignments() { - if (!assignments.length) { - assignmentsTbody.innerHTML = 'Ручных назначений нет'; - return; - } - - assignmentsTbody.innerHTML = assignments.map(assignment => ` - - ${escapeHtml(formatDate(assignment.date))} - ${escapeHtml(assignment.scopeName || '-')} - - - - - `).join(''); + function openScopeModal() { + scopeForm.reset(); + hideAlert('time-slot-scope-alert'); + scopeModal.classList.add('open'); + scopeModal.setAttribute('aria-hidden', 'false'); + window.setTimeout(() => scopeNameInput?.focus(), 0); } - async function createScope() { + function closeScopeModal() { + scopeModal.classList.remove('open'); + scopeModal.setAttribute('aria-hidden', 'true'); + hideAlert('time-slot-scope-alert'); + } + + async function createScope(event) { + event.preventDefault(); const name = scopeNameInput.value.trim(); if (!name) { - showAlert('time-slot-alert', 'Введите название ручной сетки', 'error'); + showAlert('time-slot-scope-alert', 'Введите название ручной сетки', 'error'); return; } try { const saved = await api.post('/api/admin/time-slots/scopes', { name }); - scopeNameInput.value = ''; activeScopeId = saved.id; + closeScopeModal(); showAlert('time-slot-alert', 'Сетка времени создана', 'success'); await loadAll(); } catch (error) { - showAlert('time-slot-alert', error.message || 'Ошибка создания сетки времени', 'error'); + showAlert('time-slot-scope-alert', error.message || 'Ошибка создания сетки времени', 'error'); } } @@ -195,43 +197,6 @@ export async function initTimeSlotsSettings() { } } - async function saveAssignment(event) { - event.preventDefault(); - hideAlert('time-slot-assignment-alert'); - - const payload = { - date: assignmentDateInput.value, - scopeId: assignmentScopeSelect.value ? Number(assignmentScopeSelect.value) : null - }; - - if (!payload.date || !payload.scopeId) { - showAlert('time-slot-assignment-alert', 'Выберите дату и ручную сетку', 'error'); - return; - } - - try { - await api.post('/api/admin/time-slots/date-assignments', payload); - assignmentForm.reset(); - showAlert('time-slot-assignment-alert', 'Сетка применена к дате', 'success'); - await loadAll(); - } catch (error) { - showAlert('time-slot-assignment-alert', error.message || 'Ошибка назначения сетки', 'error'); - } - } - - async function handleAssignmentTableClick(event) { - const deleteButton = event.target.closest('.btn-delete-assignment'); - if (!deleteButton) return; - if (!confirm('Убрать ручное применение сетки на эту дату?')) return; - try { - await api.delete('/api/admin/time-slots/date-assignments/' + deleteButton.dataset.id); - showAlert('time-slot-assignment-alert', 'Ручное назначение удалено', 'success'); - await loadAll(); - } catch (error) { - showAlert('time-slot-assignment-alert', error.message || 'Ошибка удаления назначения', 'error'); - } - } - async function saveSlot(event) { event.preventDefault(); hideAlert('time-slot-alert'); @@ -350,9 +315,3 @@ function dayShort(dayOfWeek) { function trimTime(value) { return value ? String(value).slice(0, 5) : ''; } - -function formatDate(value) { - if (!value) return '-'; - const [year, month, day] = String(value).split('-'); - return `${day}.${month}.${year}`; -} diff --git a/frontend/admin/settings/views/time-slots.html b/frontend/admin/settings/views/time-slots.html index 0d9fd7f..a6800a1 100644 --- a/frontend/admin/settings/views/time-slots.html +++ b/frontend/admin/settings/views/time-slots.html @@ -11,38 +11,36 @@

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

- +
+ + +
-
+
- -
-
-

Ручное применение

-
- - - - -
+
+

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

+
+ Базовая + Используется по умолчанию для всех дат. +
+
+ Сб + Субботняя сетка применяется автоматически по субботам. +
+
+ Ручная + Ручные сетки назначаются в ячейке редактора календарного графика. +
@@ -80,45 +78,50 @@ -
-
-
- - - - - - - - - - - - - - - -
ПараНачалоОкончаниеМинутДействия
Загрузка...
-
-
+
+
+ + + + + + + + + + + + + + + +
ПараНачалоОкончаниеМинутДействия
Загрузка...
+
+
-
-
- - - - - - - - - - - - - -
ДатаСетка
Загрузка...
+
+ +
+ + +
+ + +
+ + +
+
diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html index b84b862..daf0a10 100644 --- a/frontend/admin/views/academic-calendar.html +++ b/frontend/admin/views/academic-calendar.html @@ -221,5 +221,18 @@
+
+
+ + +
+
+ + +
+ +
From b222f3adace52e4945a81a5247849a25b46d0208 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 6 May 2026 21:54:17 +0300 Subject: [PATCH 07/16] =?UTF-8?q?=D0=BD=D0=B0=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D1=83?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0?= =?UTF-8?q?=20=D1=87=D0=B0=D1=81=D0=BE=D0=B2=20=D0=BB=D0=B5=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=20=D0=BF=D0=BE=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleRuleAdminController.java | 92 ++++++++++++-- .../magistr/app/dto/RenderedLessonDto.java | 6 +- .../com/magistr/app/dto/ScheduleRuleDto.java | 9 +- .../app/model/ScheduleLessonCategory.java | 36 ++++++ .../com/magistr/app/model/ScheduleRule.java | 93 ++++++++++++-- .../app/service/ScheduleGeneratorService.java | 68 ++++++---- .../main/resources/db/migration/V1__init.sql | 88 ++++++++++--- docs/API.md | 18 ++- docs/BUSINESS_LOGIC.md | 10 +- docs/DATABASE.md | 20 ++- .../DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 7 +- docs/FRONTEND.md | 2 +- .../SCHEDULE_PROPOSAL.md | 2 +- SCHEDULE_TASKS.md => docs/SCHEDULE_TASKS.md | 1 + frontend/admin/css/components.css | 35 ++++++ frontend/admin/js/views/schedule.js | 116 ++++++++++++++++-- frontend/admin/views/schedule.html | 50 ++++++-- 17 files changed, 540 insertions(+), 113 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java rename DYNAMIC_SCHEDULE_IMPLEMENTATION.md => docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md (85%) rename SCHEDULE_PROPOSAL.md => docs/SCHEDULE_PROPOSAL.md (96%) rename SCHEDULE_TASKS.md => docs/SCHEDULE_TASKS.md (94%) 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 cda7293..40258a1 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -124,11 +124,9 @@ public class ScheduleRuleAdminController { if (request.semesterId() == null) { return "Семестр обязателен"; } - if (request.activeFromDate() == null) { - return "Дата начала правила обязательна"; - } - if (request.totalAcademicHours() == null || request.totalAcademicHours() <= 0) { - return "Количество академических часов должно быть больше нуля"; + String hoursValidationError = validateTypeHours(request); + if (hoursValidationError != null) { + return hoursValidationError; } if (request.groupIds() == null || request.groupIds().isEmpty()) { return "Нужно выбрать хотя бы одну группу"; @@ -151,15 +149,23 @@ public class ScheduleRuleAdminController { rule.setSubject(subject); rule.setSemester(semester); - rule.setActiveFromDate(request.activeFromDate()); - rule.setTotalAcademicHours(request.totalAcademicHours()); + rule.setLectureAcademicHours(request.lectureAcademicHours()); + rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours()); + rule.setPracticeAcademicHours(request.practiceAcademicHours()); + rule.setLectureStartWeek(request.lectureStartWeek()); + rule.setLaboratoryStartWeek(request.laboratoryStartWeek()); + rule.setPracticeStartWeek(request.practiceStartWeek()); rule.getGroups().clear(); rule.getGroups().addAll(groups); - rule.getSlots().clear(); + List slots = new ArrayList<>(); for (ScheduleRuleSlotDto slotDto : request.slots()) { - rule.getSlots().add(buildSlot(rule, slotDto)); + slots.add(buildSlot(rule, slotDto)); } + validateTypeCoverage(rule, slots); + + rule.getSlots().clear(); + rule.getSlots().addAll(slots); } private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) { @@ -181,6 +187,7 @@ public class ScheduleRuleAdminController { .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")); LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); + ScheduleLessonCategory.fromLessonType(lessonType); if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) { throw new IllegalArgumentException("Формат занятия обязателен"); } @@ -198,6 +205,65 @@ public class ScheduleRuleAdminController { return slot; } + private String validateTypeHours(ScheduleRuleDto request) { + if (isMissing(request.lectureAcademicHours()) + || isMissing(request.laboratoryAcademicHours()) + || isMissing(request.practiceAcademicHours())) { + return "Укажите количество часов для всех типов занятий"; + } + if (isNegative(request.lectureAcademicHours()) + || isNegative(request.laboratoryAcademicHours()) + || isNegative(request.practiceAcademicHours())) { + return "Количество часов по типам занятий не может быть отрицательным"; + } + if (isInvalidStartWeek(request.lectureStartWeek()) + || isInvalidStartWeek(request.laboratoryStartWeek()) + || isInvalidStartWeek(request.practiceStartWeek())) { + return "Неделя начала занятий должна быть больше нуля"; + } + int totalHours = valueOrZero(request.lectureAcademicHours()) + + valueOrZero(request.laboratoryAcademicHours()) + + valueOrZero(request.practiceAcademicHours()); + if (totalHours <= 0) { + return "Укажите часы хотя бы для одного типа занятий"; + } + return null; + } + + private void validateTypeCoverage(ScheduleRule rule, List slots) { + EnumSet categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class); + for (ScheduleRuleSlot slot : slots) { + categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType())); + } + + for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) { + int hours = rule.academicHoursFor(category); + boolean hasSlots = categoriesWithSlots.contains(category); + if (hours > 0 && !hasSlots) { + throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот"); + } + if (hours == 0 && hasSlots) { + throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа"); + } + } + } + + private boolean isMissing(Integer value) { + return value == null; + } + + private boolean isNegative(Integer value) { + return value < 0; + } + + private boolean isInvalidStartWeek(Integer value) { + return value == null || value <= 0; + } + + private int valueOrZero(Integer value) { + return value == null ? 0 : value; + } + private ScheduleRuleDto toDto(ScheduleRule rule) { List groups = rule.getGroups().stream() .sorted(Comparator.comparing(StudentGroup::getName)) @@ -217,8 +283,12 @@ public class ScheduleRuleAdminController { rule.getSemester().getId(), rule.getSemester().getAcademicYear().getTitle(), rule.getSemester().getSemesterType(), - rule.getActiveFromDate(), - rule.getTotalAcademicHours(), + rule.getLectureAcademicHours(), + rule.getLaboratoryAcademicHours(), + rule.getPracticeAcademicHours(), + rule.getLectureStartWeek(), + rule.getLaboratoryStartWeek(), + rule.getPracticeStartWeek(), groups.stream().map(StudentGroup::getId).toList(), groups.stream().map(StudentGroup::getName).toList(), slots diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 04db846..633219d 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -31,8 +31,8 @@ public record RenderedLessonDto( List groupIds, List groupNames, String activityType, - Integer totalAcademicHours, - Integer consumedAcademicHoursBeforeLesson, - Integer remainingAcademicHoursAfterLesson + Integer lessonTypeAcademicHours, + Integer consumedLessonTypeAcademicHoursBeforeLesson, + Integer remainingLessonTypeAcademicHoursAfterLesson ) { } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java index be3b0ed..95d5ad2 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java @@ -2,7 +2,6 @@ package com.magistr.app.dto; import com.magistr.app.model.SemesterType; -import java.time.LocalDate; import java.util.List; public record ScheduleRuleDto( @@ -12,8 +11,12 @@ public record ScheduleRuleDto( Long semesterId, String academicYearTitle, SemesterType semesterType, - LocalDate activeFromDate, - Integer totalAcademicHours, + Integer lectureAcademicHours, + Integer laboratoryAcademicHours, + Integer practiceAcademicHours, + Integer lectureStartWeek, + Integer laboratoryStartWeek, + Integer practiceStartWeek, List groupIds, List groupNames, List slots diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java new file mode 100644 index 0000000..65a9c12 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java @@ -0,0 +1,36 @@ +package com.magistr.app.model; + +import java.util.Locale; + +public enum ScheduleLessonCategory { + LECTURE("Лекции"), + LABORATORY("Лабораторные"), + PRACTICE("Практики"); + + private final String displayName; + + ScheduleLessonCategory(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public static ScheduleLessonCategory fromLessonType(LessonType lessonType) { + if (lessonType == null || lessonType.getLessonType() == null) { + throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); + } + String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU")); + if (name.contains("лекц")) { + return LECTURE; + } + if (name.contains("лаб")) { + return LABORATORY; + } + if (name.contains("практ")) { + return PRACTICE; + } + throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой"); + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java index 8cddb99..bd64c09 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -2,7 +2,6 @@ package com.magistr.app.model; import jakarta.persistence.*; -import java.time.LocalDate; import java.util.HashSet; import java.util.Set; @@ -22,11 +21,23 @@ public class ScheduleRule { @JoinColumn(name = "semester_id", nullable = false) private Semester semester; - @Column(name = "active_from_date", nullable = false) - private LocalDate activeFromDate; + @Column(name = "lecture_academic_hours", nullable = false) + private Integer lectureAcademicHours = 0; - @Column(name = "total_academic_hours", nullable = false) - private Integer totalAcademicHours; + @Column(name = "laboratory_academic_hours", nullable = false) + private Integer laboratoryAcademicHours = 0; + + @Column(name = "practice_academic_hours", nullable = false) + private Integer practiceAcademicHours = 0; + + @Column(name = "lecture_start_week", nullable = false) + private Integer lectureStartWeek = 1; + + @Column(name = "laboratory_start_week", nullable = false) + private Integer laboratoryStartWeek = 1; + + @Column(name = "practice_start_week", nullable = false) + private Integer practiceStartWeek = 1; @ManyToMany @JoinTable( @@ -63,20 +74,76 @@ public class ScheduleRule { this.semester = semester; } - public LocalDate getActiveFromDate() { - return activeFromDate; + public Integer getLectureAcademicHours() { + return lectureAcademicHours; } - public void setActiveFromDate(LocalDate activeFromDate) { - this.activeFromDate = activeFromDate; + public void setLectureAcademicHours(Integer lectureAcademicHours) { + this.lectureAcademicHours = lectureAcademicHours; } - public Integer getTotalAcademicHours() { - return totalAcademicHours; + public Integer getLaboratoryAcademicHours() { + return laboratoryAcademicHours; } - public void setTotalAcademicHours(Integer totalAcademicHours) { - this.totalAcademicHours = totalAcademicHours; + public void setLaboratoryAcademicHours(Integer laboratoryAcademicHours) { + this.laboratoryAcademicHours = laboratoryAcademicHours; + } + + public Integer getPracticeAcademicHours() { + return practiceAcademicHours; + } + + public void setPracticeAcademicHours(Integer practiceAcademicHours) { + this.practiceAcademicHours = practiceAcademicHours; + } + + public Integer getLectureStartWeek() { + return lectureStartWeek; + } + + public void setLectureStartWeek(Integer lectureStartWeek) { + this.lectureStartWeek = lectureStartWeek; + } + + public Integer getLaboratoryStartWeek() { + return laboratoryStartWeek; + } + + public void setLaboratoryStartWeek(Integer laboratoryStartWeek) { + this.laboratoryStartWeek = laboratoryStartWeek; + } + + public Integer getPracticeStartWeek() { + return practiceStartWeek; + } + + public void setPracticeStartWeek(Integer practiceStartWeek) { + this.practiceStartWeek = practiceStartWeek; + } + + public int academicHoursFor(ScheduleLessonCategory category) { + return switch (category) { + case LECTURE -> valueOrZero(lectureAcademicHours); + case LABORATORY -> valueOrZero(laboratoryAcademicHours); + case PRACTICE -> valueOrZero(practiceAcademicHours); + }; + } + + public int startWeekFor(ScheduleLessonCategory category) { + return switch (category) { + case LECTURE -> valueOrDefault(lectureStartWeek, 1); + case LABORATORY -> valueOrDefault(laboratoryStartWeek, 1); + case PRACTICE -> valueOrDefault(practiceStartWeek, 1); + }; + } + + private int valueOrZero(Integer value) { + return valueOrDefault(value, 0); + } + + private int valueOrDefault(Integer value, int defaultValue) { + return value == null ? defaultValue : value; } public Set getGroups() { 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 e8e4473..757df1c 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -157,10 +157,9 @@ public class ScheduleGeneratorService { StudentGroup targetGroup, Long targetTeacherId, List result) { - if (rule.getActiveFromDate() != null && date.isBefore(rule.getActiveFromDate())) { - return; - } - if (rule.getTotalAcademicHours() == null || rule.getTotalAcademicHours() <= 0) { + if (rule.getLectureAcademicHours() == null + || rule.getLaboratoryAcademicHours() == null + || rule.getPracticeAcademicHours() == null) { return; } @@ -176,6 +175,7 @@ public class ScheduleGeneratorService { } ScheduleParity dateParity = academicDateService.getParity(semester, date); + int weekNumber = academicDateService.getWeekNumber(semester, date); int dayOfWeek = date.getDayOfWeek().getValue(); List slots = rule.getSlots().stream() .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) @@ -190,16 +190,28 @@ public class ScheduleGeneratorService { return; } - int consumedHours = calculateConsumedHoursBeforeDate(rule, targetGroup, date); - int weekNumber = academicDateService.getWeekNumber(semester, date); + Map consumedHoursByCategory = new EnumMap<>(ScheduleLessonCategory.class); for (ScheduleRuleSlot slot : slots) { - if (consumedHours >= rule.getTotalAcademicHours()) { - break; + ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType()); + if (weekNumber < rule.startWeekFor(category)) { + continue; } - int remainingAfterLesson = Math.max(0, rule.getTotalAcademicHours() - consumedHours - ACADEMIC_HOURS_PER_SLOT); - result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, consumedHours, remainingAfterLesson)); - consumedHours += ACADEMIC_HOURS_PER_SLOT; + int totalHours = rule.academicHoursFor(category); + if (totalHours <= 0) { + continue; + } + int consumedHours = consumedHoursByCategory.computeIfAbsent( + category, + key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, key) + ); + if (consumedHours >= totalHours) { + continue; + } + int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT); + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, + totalHours, consumedHours, remainingAfterLesson)); + consumedHoursByCategory.put(category, consumedHours + ACADEMIC_HOURS_PER_SLOT); } } @@ -214,26 +226,30 @@ public class ScheduleGeneratorService { .toList(); } - private int calculateConsumedHoursBeforeDate(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) { + private int calculateConsumedHoursBeforeDate(ScheduleRule rule, + StudentGroup targetGroup, + LocalDate targetDate, + ScheduleLessonCategory category) { Long groupId = targetGroup == null ? null : targetGroup.getId(); - String cacheKey = rule.getId() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate; - return consumedHoursCache.computeIfAbsent(cacheKey, ignored -> calculateConsumedHours(rule, targetGroup, targetDate)); + String cacheKey = rule.getId() + ":" + category.name() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate; + return consumedHoursCache.computeIfAbsent(cacheKey, + ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category)); } - private int calculateConsumedHours(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) { - LocalDate start = rule.getActiveFromDate() == null - ? rule.getSemester().getStartDate() - : rule.getActiveFromDate(); - if (start.isBefore(rule.getSemester().getStartDate())) { - start = rule.getSemester().getStartDate(); - } - + private int calculateConsumedHours(ScheduleRule rule, + StudentGroup targetGroup, + LocalDate targetDate, + ScheduleLessonCategory category) { int consumed = 0; - for (LocalDate date = start; date.isBefore(targetDate); date = date.plusDays(1)) { + for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) { Semester semester = rule.getSemester(); if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) { continue; } + int weekNumber = academicDateService.getWeekNumber(semester, date); + if (weekNumber < rule.startWeekFor(category)) { + continue; + } if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) { continue; } @@ -243,9 +259,10 @@ public class ScheduleGeneratorService { long activeSlots = rule.getSlots().stream() .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) + .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) .count(); consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; - if (consumed >= rule.getTotalAcademicHours()) { + if (consumed >= rule.academicHoursFor(category)) { return consumed; } } @@ -258,6 +275,7 @@ public class ScheduleGeneratorService { int weekNumber, ScheduleParity dateParity, List groups, + int lessonTypeAcademicHours, int consumedBeforeLesson, int remainingAfterLesson) { TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date); @@ -290,7 +308,7 @@ public class ScheduleGeneratorService { sortedGroups.stream().map(StudentGroup::getId).toList(), sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), "Т", - rule.getTotalAcademicHours(), + lessonTypeAcademicHours, consumedBeforeLesson, remainingAfterLesson ); diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 95a5285..8beacef 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -461,9 +461,25 @@ CREATE TABLE IF NOT EXISTS schedule_rules ( id BIGSERIAL PRIMARY KEY, subject_id BIGINT NOT NULL REFERENCES subjects(id), semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, - active_from_date DATE NOT NULL, - total_academic_hours INT NOT NULL, - CONSTRAINT chk_schedule_rules_hours_positive CHECK (total_academic_hours > 0) + lecture_academic_hours INT NOT NULL, + laboratory_academic_hours INT NOT NULL, + practice_academic_hours INT NOT NULL, + lecture_start_week INT NOT NULL, + laboratory_start_week INT NOT NULL, + practice_start_week INT NOT NULL, + CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK ( + lecture_academic_hours >= 0 + AND laboratory_academic_hours >= 0 + AND practice_academic_hours >= 0 + ), + CONSTRAINT chk_schedule_rules_has_type_hours CHECK ( + lecture_academic_hours + laboratory_academic_hours + practice_academic_hours > 0 + ), + CONSTRAINT chk_schedule_rules_start_weeks_positive CHECK ( + lecture_start_week > 0 + AND laboratory_start_week > 0 + AND practice_start_week > 0 + ) ); CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id); @@ -504,28 +520,66 @@ SELECT rule_key, subject_id, semester_id, - active_from_date, - total_academic_hours + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week FROM ( SELECT data.rule_key, subj.id AS subject_id, sem.id AS semester_id, - data.active_from_date, - data.total_academic_hours + data.lecture_academic_hours, + data.laboratory_academic_hours, + data.practice_academic_hours, + data.lecture_start_week, + data.laboratory_start_week, + data.practice_start_week FROM (VALUES - ('stream_math', 'Высшая математика', '2025-2026', 'spring', DATE '2026-02-02', 72), - ('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', DATE '2026-02-02', 72), - ('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', DATE '2026-02-02', 72), - ('ib_philosophy', 'Философия', '2025-2026', 'spring', DATE '2026-02-02', 54) - ) AS data(rule_key, subject_name, year_title, semester_type, active_from_date, total_academic_hours) + ('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1), + ('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1), + ('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1), + ('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1) + ) AS data( + rule_key, + subject_name, + year_title, + semester_type, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week + ) JOIN subjects subj ON subj.name = data.subject_name JOIN academic_years ay ON ay.title = data.year_title JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type ) prepared_rules; -INSERT INTO schedule_rules (id, subject_id, semester_id, active_from_date, total_academic_hours) -SELECT schedule_rule_id, subject_id, semester_id, active_from_date, total_academic_hours +INSERT INTO schedule_rules ( + id, + subject_id, + semester_id, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week +) +SELECT + schedule_rule_id, + subject_id, + semester_id, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week FROM tmp_default_rules; INSERT INTO schedule_rule_groups (schedule_rule_id, group_id) @@ -615,6 +669,12 @@ COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения к COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; +COMMENT ON 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 TABLE education_forms IS 'Формы обучения'; COMMENT ON TABLE subgroups IS 'Подгруппы'; diff --git a/docs/API.md b/docs/API.md index 2e1c6f7..75f8bf9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,7 +101,7 @@ ## Динамическое расписание -Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. ### `GET /api/schedule` @@ -151,9 +151,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "groupIds": [1], "groupNames": ["ИВТ-21-1"], "activityType": "Т", - "totalAcademicHours": 72, - "consumedAcademicHoursBeforeLesson": 24, - "remainingAcademicHoursAfterLesson": 46 + "lessonTypeAcademicHours": 32, + "consumedLessonTypeAcademicHoursBeforeLesson": 12, + "remainingLessonTypeAcademicHoursAfterLesson": 18 } ] ``` @@ -279,8 +279,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 { "subjectId": 1, "semesterId": 1, - "activeFromDate": "2026-02-01", - "totalAcademicHours": 72, + "lectureAcademicHours": 32, + "laboratoryAcademicHours": 16, + "practiceAcademicHours": 24, + "lectureStartWeek": 1, + "laboratoryStartWeek": 3, + "practiceStartWeek": 2, "groupIds": [1, 2], "slots": [ { @@ -309,6 +313,8 @@ CRUD доступен по: `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. +Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. + ### `GET /api/lesson-types` Справочник типов занятий для конструктора правил расписания. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 16402f5..940eaa8 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -82,7 +82,7 @@ | `academic_calendar_days` | Дневная сетка по курсу и дате | | `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | | `time_slots` | Настраиваемая сетка пар для тенанта | -| `schedule_rules` | Лимит часов дисциплины в семестре | +| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | | `schedule_rule_groups` | Группы правила, включая потоковые лекции | | `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | @@ -93,9 +93,10 @@ 4. Определяет код активности по курсу группы и конкретной дате. 5. Пропускает день, если код активности не разрешает обычные пары. 6. Загружает правила группы или преподавателя. -7. Симулирует уже проведённые занятия от `active_from_date`. +7. Для каждого типа занятия проверяет свою неделю начала в семестре. 8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую. -9. Останавливает вывод правила, когда достигнут `total_academic_hours`. +9. Считает уже проведённые часы отдельно для лекций, лабораторных и практик. +10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. @@ -119,7 +120,8 @@ ### Валидация правил расписания -- **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа. +- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа. +- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index e5f22b5..f7f962a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -207,8 +207,12 @@ erDiagram BIGSERIAL id PK BIGINT subject_id FK BIGINT semester_id FK - DATE active_from_date - INT total_academic_hours + INT lecture_academic_hours + INT laboratory_academic_hours + INT practice_academic_hours + INT lecture_start_week + INT laboratory_start_week + INT practice_start_week } schedule_rule_groups { @@ -507,8 +511,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `id` | BIGSERIAL PK | ID | | `subject_id` | BIGINT FK → subjects | Дисциплина | | `semester_id` | BIGINT FK → semesters | Семестр | -| `active_from_date` | DATE | Дата начала действия правила | -| `total_academic_hours` | INT | Лимит академических часов | +| `lecture_academic_hours` | INT | Лимит академических часов лекций | +| `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ | +| `practice_academic_hours` | INT | Лимит академических часов практик | +| `lecture_start_week` | INT | Неделя семестра, с которой начинаются лекции | +| `laboratory_start_week` | INT | Неделя семестра, с которой начинаются лабораторные | +| `practice_start_week` | INT | Неделя семестра, с которой начинаются практики | #### `schedule_rule_groups` — Группы правила | Колонка | Тип | Описание | @@ -548,11 +556,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание, тестовые правила, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание с раздельными часами и стартовыми неделями по типам занятий, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов -Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend: +Для применения новой базовой схемы к уже существующим тенантам нужен полный сброс БД. Перезапуск backend без сброса не изменит уже применённую `V1__init.sql`. ```bash # Kubernetes diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md similarity index 85% rename from DYNAMIC_SCHEDULE_IMPLEMENTATION.md rename to docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 888fb93..79ed21f 100644 --- a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -4,7 +4,7 @@ ## Статус -Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. По требованию текущей ветки новая отдельная Flyway-миграция не создавалась; применение рассчитано на полный сброс БД. +Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД. Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы. @@ -23,7 +23,7 @@ | `academic_calendar_days` | Дневная сетка графика по курсу и дате | | `student_group_calendar_assignments` | Назначения графиков группам по учебному году | | `time_slots` | Настраиваемая сетка пар | -| `schedule_rules` | Правила проведения дисциплин | +| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | | `schedule_rule_groups` | Группы правила | | `schedule_rule_slots` | Шаблонные слоты правила | @@ -40,7 +40,7 @@ | `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | | `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | -`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. +`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. ## API @@ -71,6 +71,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 Админский экран `schedule` содержит: - конструктор правил динамического расписания; +- отдельные поля часов и недель начала для лекций, лабораторных и практик; - управление временными слотами; - учебные годы и семестры; - CRUD календарных графиков; diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 3822004..d887377 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -123,7 +123,7 @@ frontend/ - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор базовых слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. diff --git a/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md similarity index 96% rename from SCHEDULE_PROPOSAL.md rename to docs/SCHEDULE_PROPOSAL.md index 825b22a..3450083 100644 --- a/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -27,7 +27,7 @@ ### Слой правил расписания - `time_slots` задаёт сетку пар для тенанта. -- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов. +- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик. - `schedule_rule_groups` связывает правило с одной или несколькими группами. - `schedule_rule_slots` хранит шаблонные занятия правила. diff --git a/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md similarity index 94% rename from SCHEDULE_TASKS.md rename to docs/SCHEDULE_TASKS.md index aec40d0..3af1334 100644 --- a/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -11,6 +11,7 @@ - [x] Добавлены календарные графики, дневная сетка и назначения графиков группам. - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. +- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. - [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. - [x] Обновлены админские экраны специальностей, групп и расписания. - [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 774464e..1cf7c2f 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -124,6 +124,33 @@ border-radius: var(--radius-sm); } +.schedule-hours-panel { + margin-top: 1rem; +} + +.schedule-hours-grid { + display: grid; + grid-template-columns: repeat(3, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.schedule-hours-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + align-items: end; + padding: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.schedule-hours-title { + grid-column: 1 / -1; + color: var(--text-primary); + font-weight: 700; +} + .schedule-slots-panel { margin-top: 1rem; } @@ -448,6 +475,10 @@ } @media (max-width: 1180px) { + .schedule-hours-grid { + grid-template-columns: 1fr; + } + .schedule-slot-row { grid-template-columns: repeat(2, minmax(180px, 1fr)); } @@ -464,6 +495,10 @@ } @media (max-width: 640px) { + .schedule-hours-row { + grid-template-columns: 1fr; + } + .schedule-slot-row { grid-template-columns: 1fr; } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 1d7ef60..accc674 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -22,14 +22,43 @@ const SEMESTER_LABELS = { spring: 'весенний' }; +const LESSON_TYPE_LIMITS = [ + { + key: 'lecture', + label: 'Лекции', + hoursField: 'lectureAcademicHours', + startWeekField: 'lectureStartWeek', + hoursInputId: 'schedule-rule-lecture-hours', + startWeekInputId: 'schedule-rule-lecture-start-week' + }, + { + key: 'laboratory', + label: 'Лабораторные', + hoursField: 'laboratoryAcademicHours', + startWeekField: 'laboratoryStartWeek', + hoursInputId: 'schedule-rule-laboratory-hours', + startWeekInputId: 'schedule-rule-laboratory-start-week' + }, + { + key: 'practice', + label: 'Практики', + hoursField: 'practiceAcademicHours', + startWeekField: 'practiceStartWeek', + hoursInputId: 'schedule-rule-practice-hours', + startWeekInputId: 'schedule-rule-practice-start-week' + } +]; + export async function initSchedule() { const ruleForm = document.getElementById('schedule-rule-form'); const ruleFormTitle = document.getElementById('schedule-rule-form-title'); const ruleIdInput = document.getElementById('schedule-rule-id'); const ruleSubjectSelect = document.getElementById('schedule-rule-subject'); const ruleSemesterSelect = document.getElementById('schedule-rule-semester'); - const ruleActiveFromInput = document.getElementById('schedule-rule-active-from'); - const ruleHoursInput = document.getElementById('schedule-rule-hours'); + const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, { + hours: document.getElementById(config.hoursInputId), + startWeek: document.getElementById(config.startWeekInputId) + }])); const ruleGroupsContainer = document.getElementById('schedule-rule-groups'); const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); const ruleResetButton = document.getElementById('schedule-rule-reset'); @@ -92,18 +121,18 @@ export async function initSchedule() { } async function loadRules() { - rulesTbody.innerHTML = 'Загрузка...'; + rulesTbody.innerHTML = 'Загрузка...'; try { rules = await api.get('/api/admin/schedule-rules'); renderRules(); } catch (error) { - rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function renderRules() { if (!rules.length) { - rulesTbody.innerHTML = 'Правила расписания не созданы'; + rulesTbody.innerHTML = 'Правила расписания не созданы'; return; } rulesTbody.innerHTML = rules.map(rule => ` @@ -112,8 +141,7 @@ export async function initSchedule() { ${escapeHtml(rule.subjectName || '-')} ${escapeHtml(formatSemester(rule))} ${escapeHtml((rule.groupNames || []).join(', ') || '-')} - ${escapeHtml(rule.activeFromDate || '-')} - ${escapeHtml(String(rule.totalAcademicHours ?? '-'))} + ${renderRuleLoadSummary(rule)} ${renderRuleSlotsSummary(rule.slots)} @@ -140,6 +168,20 @@ export async function initSchedule() { }).join(''); } + function renderRuleLoadSummary(rule) { + const rows = LESSON_TYPE_LIMITS + .map(config => ({ + label: config.label, + hours: Number(rule[config.hoursField] || 0), + startWeek: Number(rule[config.startWeekField] || 1) + })) + .filter(item => item.hours > 0); + if (!rows.length) return 'Часы не указаны'; + return rows.map(item => ( + `
${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}
` + )).join(''); + } + async function handleRuleTableClick(event) { const editButton = event.target.closest('.btn-edit-rule'); const deleteButton = event.target.closest('.btn-delete-rule'); @@ -186,16 +228,15 @@ export async function initSchedule() { const slots = readRuleSlots(true); if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); - if (!ruleActiveFromInput.value) throw new Error('Укажите дату начала правила'); - if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) throw new Error('Укажите количество академических часов'); + const typeLimits = readTypeLimits(); + validateTypeLimits(typeLimits, slots); if (!groupIds.length) throw new Error('Выберите хотя бы одну группу'); if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия'); return { id: ruleIdInput.value ? Number(ruleIdInput.value) : null, subjectId: Number(ruleSubjectSelect.value), semesterId: Number(ruleSemesterSelect.value), - activeFromDate: ruleActiveFromInput.value, - totalAcademicHours: Number(ruleHoursInput.value), + ...typeLimits, groupIds, slots }; @@ -206,8 +247,7 @@ export async function initSchedule() { ruleIdInput.value = rule.id; ruleSubjectSelect.value = rule.subjectId || ''; ruleSemesterSelect.value = rule.semesterId || ''; - ruleActiveFromInput.value = rule.activeFromDate || ''; - ruleHoursInput.value = rule.totalAcademicHours || ''; + fillTypeLimits(rule); syncSelects(ruleSubjectSelect, ruleSemesterSelect); renderRuleGroups(rule.groupIds || []); renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]); @@ -219,6 +259,7 @@ export async function initSchedule() { ruleForm.reset(); ruleFormTitle.textContent = 'Новое правило расписания'; ruleIdInput.value = ''; + fillTypeLimits({}); renderRuleGroups([]); renderRuleSlots([{}]); syncSelects(ruleSubjectSelect, ruleSemesterSelect); @@ -277,6 +318,42 @@ export async function initSchedule() { }); } + function readTypeLimits() { + return LESSON_TYPE_LIMITS.reduce((payload, config) => { + const inputs = ruleTypeInputs[config.key]; + payload[config.hoursField] = Number(inputs.hours.value || 0); + payload[config.startWeekField] = Number(inputs.startWeek.value || 1); + return payload; + }, {}); + } + + function fillTypeLimits(rule) { + LESSON_TYPE_LIMITS.forEach(config => { + const inputs = ruleTypeInputs[config.key]; + inputs.hours.value = rule[config.hoursField] ?? 0; + inputs.startWeek.value = rule[config.startWeekField] ?? 1; + }); + } + + function validateTypeLimits(typeLimits, slots) { + const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean)); + let hasHours = false; + for (const config of LESSON_TYPE_LIMITS) { + const hours = typeLimits[config.hoursField]; + const startWeek = typeLimits[config.startWeekField]; + if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным'); + if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля'); + if (hours > 0) hasHours = true; + if (hours > 0 && !selectedTypeKeys.has(config.key)) { + throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`); + } + if (hours === 0 && selectedTypeKeys.has(config.key)) { + throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`); + } + } + if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий'); + } + async function loadTimeSlots() { const slots = await api.get('/api/admin/time-slots'); timeSlots = slots @@ -322,6 +399,19 @@ export async function initSchedule() { return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; } + function lessonTypeKeyById(lessonTypeId) { + const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId)); + return lessonType ? lessonTypeKey(lessonType) : null; + } + + function lessonTypeKey(lessonType) { + const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase(); + if (name.includes('лекц')) return 'lecture'; + if (name.includes('лаб')) return 'laboratory'; + if (name.includes('практ')) return 'practice'; + return null; + } + function semesterLabel(semester) { const type = normalizeSemesterType(semester.semesterType); return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index cc5bc8e..86a5efd 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -18,13 +18,44 @@ -
- - -
-
- - +
+ +
+ +
+
+
Лекции
+
+ + +
+
+ + +
+
+
+
Лабораторные
+
+ + +
+
+ + +
+
+
+
Практики
+
+ + +
+
+ + +
+
@@ -61,15 +92,14 @@ Дисциплина Семестр Группы - Начало - Часы + Нагрузка Слоты Действия - Загрузка... + Загрузка... From e12a5bcccd9735330586d4572af3eef5bed63e90 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 6 May 2026 22:25:53 +0300 Subject: [PATCH 08/16] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BD=D1=8B=D1=85=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleRuleAdminController.java | 26 +++- .../app/controller/SubgroupController.java | 139 ++++++++++++++++++ .../magistr/app/dto/RenderedLessonDto.java | 1 + .../magistr/app/dto/ScheduleRuleSlotDto.java | 1 + .../java/com/magistr/app/dto/SubgroupDto.java | 10 ++ .../magistr/app/model/ScheduleRuleSlot.java | 15 +- .../java/com/magistr/app/model/Subgroup.java | 54 +++++++ .../repository/ScheduleRuleRepository.java | 8 + .../ScheduleRuleSlotRepository.java | 5 + .../app/repository/SubgroupRepository.java | 46 ++++++ .../app/service/ScheduleGeneratorService.java | 51 +++++-- .../main/resources/db/migration/V1__init.sql | 43 ++++++ docs/API.md | 41 +++++- docs/BUSINESS_LOGIC.md | 7 +- docs/DATABASE.md | 6 +- docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 8 +- docs/FRONTEND.md | 12 +- docs/SCHEDULE_PROPOSAL.md | 8 +- docs/SCHEDULE_TASKS.md | 1 + frontend/admin/css/components.css | 2 +- frontend/admin/js/views/groups.js | 139 ++++++++++++++++++ frontend/admin/js/views/schedule.js | 65 +++++++- frontend/admin/views/groups.html | 43 ++++++ frontend/student/index.html | 1 + frontend/teacher/index.html | 1 + 25 files changed, 693 insertions(+), 40 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/controller/SubgroupController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/SubgroupDto.java create mode 100644 backend/src/main/java/com/magistr/app/model/Subgroup.java create mode 100644 backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java 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 40258a1..3c36000 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -26,6 +26,7 @@ public class ScheduleRuleAdminController { private final UserRepository userRepository; private final ClassroomRepository classroomRepository; private final LessonTypesRepository lessonTypesRepository; + private final SubgroupRepository subgroupRepository; private final ScheduleGeneratorService scheduleGeneratorService; public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository, @@ -36,6 +37,7 @@ public class ScheduleRuleAdminController { UserRepository userRepository, ClassroomRepository classroomRepository, LessonTypesRepository lessonTypesRepository, + SubgroupRepository subgroupRepository, ScheduleGeneratorService scheduleGeneratorService) { this.scheduleRuleRepository = scheduleRuleRepository; this.subjectRepository = subjectRepository; @@ -45,6 +47,7 @@ public class ScheduleRuleAdminController { this.userRepository = userRepository; this.classroomRepository = classroomRepository; this.lessonTypesRepository = lessonTypesRepository; + this.subgroupRepository = subgroupRepository; this.scheduleGeneratorService = scheduleGeneratorService; } @@ -187,7 +190,8 @@ public class ScheduleRuleAdminController { .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")); LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); - ScheduleLessonCategory.fromLessonType(lessonType); + ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType); + Subgroup subgroup = resolveSubgroup(rule, slotDto.subgroupId(), category); if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) { throw new IllegalArgumentException("Формат занятия обязателен"); } @@ -197,7 +201,7 @@ public class ScheduleRuleAdminController { slot.setDayOfWeek(slotDto.dayOfWeek()); slot.setParity(slotDto.parity()); slot.setTimeSlot(timeSlot); - slot.setSubgroupId(slotDto.subgroupId()); + slot.setSubgroup(subgroup); slot.setTeacher(teacher); slot.setClassroom(classroom); slot.setLessonType(lessonType); @@ -205,6 +209,23 @@ public class ScheduleRuleAdminController { return slot; } + private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) { + if (subgroupId == null) { + return null; + } + if (category != ScheduleLessonCategory.LABORATORY) { + throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий"); + } + Subgroup subgroup = subgroupRepository.findById(subgroupId) + .orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена")); + boolean subgroupBelongsToRuleGroups = rule.getGroups().stream() + .anyMatch(group -> group.getId().equals(subgroup.getStudentGroup().getId())); + if (!subgroupBelongsToRuleGroups) { + throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила"); + } + return subgroup; + } + private String validateTypeHours(ScheduleRuleDto request) { if (isMissing(request.lectureAcademicHours()) || isMissing(request.laboratoryAcademicHours()) @@ -306,6 +327,7 @@ public class ScheduleRuleAdminController { timeSlot.getOrderNumber(), formatTimeSlot(timeSlot), slot.getSubgroupId(), + slot.getSubgroup() == null ? null : slot.getSubgroup().getName(), slot.getTeacher().getId(), ScheduleGeneratorService.displayUserName(slot.getTeacher()), slot.getClassroom().getId(), diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java new file mode 100644 index 0000000..7488d52 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -0,0 +1,139 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.SubgroupDto; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.model.Subgroup; +import com.magistr.app.repository.GroupRepository; +import com.magistr.app.repository.ScheduleRuleSlotRepository; +import com.magistr.app.repository.SubgroupRepository; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +public class SubgroupController { + + private final SubgroupRepository subgroupRepository; + private final GroupRepository groupRepository; + private final ScheduleRuleSlotRepository scheduleRuleSlotRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public SubgroupController(SubgroupRepository subgroupRepository, + GroupRepository groupRepository, + ScheduleRuleSlotRepository scheduleRuleSlotRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.subgroupRepository = subgroupRepository; + this.groupRepository = groupRepository; + this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping("/api/subgroups") + public List getAll() { + return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream() + .map(this::toDto) + .toList(); + } + + @GetMapping("/api/groups/{groupId}/subgroups") + public ResponseEntity getByGroup(@PathVariable Long groupId) { + if (!groupRepository.existsById(groupId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(subgroupRepository.findByStudentGroupIdOrderByNameAsc(groupId).stream() + .map(this::toDto) + .toList()); + } + + @PostMapping("/api/groups/{groupId}/subgroups") + public ResponseEntity create(@PathVariable Long groupId, @RequestBody SubgroupDto request) { + StudentGroup group = groupRepository.findById(groupId).orElse(null); + if (group == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); + } + + Subgroup subgroup = new Subgroup(); + subgroup.setStudentGroup(group); + subgroup.setName(request.name().trim()); + subgroup.setStudentCapacity(request.studentCapacity()); + Subgroup saved = subgroupRepository.save(subgroup); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } + + @PutMapping("/api/groups/{groupId}/subgroups/{id}") + public ResponseEntity update(@PathVariable Long groupId, + @PathVariable Long id, + @RequestBody SubgroupDto request) { + Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null); + if (subgroup == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + String newName = request.name().trim(); + boolean duplicateName = subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, newName) + && !subgroup.getName().equalsIgnoreCase(newName); + if (duplicateName) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); + } + + subgroup.setName(newName); + subgroup.setStudentCapacity(request.studentCapacity()); + Subgroup saved = subgroupRepository.save(subgroup); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } + + @DeleteMapping("/api/groups/{groupId}/subgroups/{id}") + public ResponseEntity delete(@PathVariable Long groupId, @PathVariable Long id) { + Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null); + if (subgroup == null) { + return ResponseEntity.notFound().build(); + } + if (scheduleRuleSlotRepository.existsBySubgroupId(id)) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); + } + try { + subgroupRepository.delete(subgroup); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Подгруппа удалена")); + } catch (DataIntegrityViolationException e) { + return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); + } + } + + private String validate(SubgroupDto request) { + if (request == null || request.name() == null || request.name().isBlank()) { + return "Название подгруппы обязательно"; + } + if (request.studentCapacity() != null && request.studentCapacity() < 0) { + return "Численность подгруппы не может быть отрицательной"; + } + return null; + } + + private SubgroupDto toDto(Subgroup subgroup) { + StudentGroup group = subgroup.getStudentGroup(); + return new SubgroupDto( + subgroup.getId(), + group.getId(), + group.getName(), + subgroup.getName(), + subgroup.getStudentCapacity() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 633219d..6b6ddcc 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -28,6 +28,7 @@ public record RenderedLessonDto( String lessonTypeName, String lessonFormat, Long subgroupId, + String subgroupName, List groupIds, List groupNames, String activityType, diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java index 6f01af3..6cb88cf 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java @@ -11,6 +11,7 @@ public record ScheduleRuleSlotDto( Integer timeSlotOrder, String timeSlotLabel, Long subgroupId, + String subgroupName, Long teacherId, String teacherName, Long classroomId, diff --git a/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java b/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java new file mode 100644 index 0000000..b0747ef --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java @@ -0,0 +1,10 @@ +package com.magistr.app.dto; + +public record SubgroupDto( + Long id, + Long groupId, + String groupName, + String name, + Integer studentCapacity +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java index a837635..63cfcf3 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java @@ -25,8 +25,9 @@ public class ScheduleRuleSlot { @JoinColumn(name = "time_slot_id", nullable = false) private TimeSlot timeSlot; - @Column(name = "subgroup_id") - private Long subgroupId; + @ManyToOne + @JoinColumn(name = "subgroup_id") + private Subgroup subgroup; @ManyToOne(optional = false) @JoinColumn(name = "teacher_id", nullable = false) @@ -84,11 +85,15 @@ public class ScheduleRuleSlot { } public Long getSubgroupId() { - return subgroupId; + return subgroup == null ? null : subgroup.getId(); } - public void setSubgroupId(Long subgroupId) { - this.subgroupId = subgroupId; + public Subgroup getSubgroup() { + return subgroup; + } + + public void setSubgroup(Subgroup subgroup) { + this.subgroup = subgroup; } public User getTeacher() { diff --git a/backend/src/main/java/com/magistr/app/model/Subgroup.java b/backend/src/main/java/com/magistr/app/model/Subgroup.java new file mode 100644 index 0000000..851fec9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Subgroup.java @@ -0,0 +1,54 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "subgroups") +public class Subgroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "group_id", nullable = false) + private StudentGroup studentGroup; + + @Column(nullable = false, length = 100) + private String name; + + @Column(name = "student_capacity") + private Integer studentCapacity; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public StudentGroup getStudentGroup() { + return studentGroup; + } + + public void setStudentGroup(StudentGroup studentGroup) { + this.studentGroup = studentGroup; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getStudentCapacity() { + return studentCapacity; + } + + public void setStudentCapacity(Integer studentCapacity) { + this.studentCapacity = studentCapacity; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java index b4573c6..1577f1d 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -19,6 +19,8 @@ public interface ScheduleRuleRepository extends JpaRepository { + + @Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot where slot.subgroup.id = :subgroupId") + boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId); } diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java new file mode 100644 index 0000000..0a4ac8f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -0,0 +1,46 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Subgroup; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface SubgroupRepository extends JpaRepository { + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup studentGroup + order by studentGroup.name, subgroup.name + """) + List findAllWithGroupsOrderByGroupNameAndName(); + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup + where subgroup.studentGroup.id = :groupId + order by subgroup.name + """) + List findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId); + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup + where subgroup.id = :id + and subgroup.studentGroup.id = :groupId + """) + Optional findByIdAndStudentGroupId(@Param("id") Long id, @Param("groupId") Long groupId); + + @Query(""" + select case when count(subgroup) > 0 then true else false end + from Subgroup subgroup + where subgroup.studentGroup.id = :groupId + and lower(subgroup.name) = lower(:name) + """) + boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name); +} 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 757df1c..0a98f9e 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -190,7 +190,7 @@ public class ScheduleGeneratorService { return; } - Map consumedHoursByCategory = new EnumMap<>(ScheduleLessonCategory.class); + Map consumedHoursByBucket = new HashMap<>(); for (ScheduleRuleSlot slot : slots) { ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType()); @@ -201,17 +201,22 @@ public class ScheduleGeneratorService { if (totalHours <= 0) { continue; } - int consumedHours = consumedHoursByCategory.computeIfAbsent( - category, - key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, key) + List lessonGroups = groupsForSlot(slot, eligibleGroups); + if (lessonGroups.isEmpty()) { + continue; + } + String consumptionBucket = consumptionBucket(category, slot); + int consumedHours = consumedHoursByBucket.computeIfAbsent( + consumptionBucket, + key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId()) ); if (consumedHours >= totalHours) { continue; } int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT); - result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, totalHours, consumedHours, remainingAfterLesson)); - consumedHoursByCategory.put(category, consumedHours + ACADEMIC_HOURS_PER_SLOT); + consumedHoursByBucket.put(consumptionBucket, consumedHours + ACADEMIC_HOURS_PER_SLOT); } } @@ -229,17 +234,23 @@ public class ScheduleGeneratorService { private int calculateConsumedHoursBeforeDate(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate, - ScheduleLessonCategory category) { + ScheduleLessonCategory category, + Long subgroupId) { Long groupId = targetGroup == null ? null : targetGroup.getId(); - String cacheKey = rule.getId() + ":" + category.name() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate; + String cacheKey = rule.getId() + + ":" + category.name() + + ":" + (subgroupId == null ? "whole" : subgroupId) + + ":" + (groupId == null ? "all" : groupId) + + ":" + targetDate; return consumedHoursCache.computeIfAbsent(cacheKey, - ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category)); + ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId)); } private int calculateConsumedHours(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate, - ScheduleLessonCategory category) { + ScheduleLessonCategory category, + Long subgroupId) { int consumed = 0; for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) { Semester semester = rule.getSemester(); @@ -250,7 +261,8 @@ public class ScheduleGeneratorService { if (weekNumber < rule.startWeekFor(category)) { continue; } - if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) { + List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); + if (eligibleGroups.isEmpty()) { continue; } @@ -260,6 +272,8 @@ public class ScheduleGeneratorService { .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) + .filter(slot -> Objects.equals(slot.getSubgroupId(), subgroupId)) + .filter(slot -> !groupsForSlot(slot, eligibleGroups).isEmpty()) .count(); consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; if (consumed >= rule.academicHoursFor(category)) { @@ -269,6 +283,20 @@ public class ScheduleGeneratorService { return consumed; } + private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) { + return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId()); + } + + private List groupsForSlot(ScheduleRuleSlot slot, List eligibleGroups) { + if (slot.getSubgroup() == null) { + return eligibleGroups; + } + Long subgroupGroupId = slot.getSubgroup().getStudentGroup().getId(); + return eligibleGroups.stream() + .filter(group -> Objects.equals(group.getId(), subgroupGroupId)) + .toList(); + } + private RenderedLessonDto toRenderedLesson(ScheduleRule rule, ScheduleRuleSlot slot, LocalDate date, @@ -305,6 +333,7 @@ public class ScheduleGeneratorService { slot.getLessonType().getLessonType(), slot.getLessonFormat(), slot.getSubgroupId(), + slot.getSubgroup() == null ? null : slot.getSubgroup().getName(), sortedGroups.stream().map(StudentGroup::getId).toList(), sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), "Т", diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 8beacef..0fe5465 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -119,6 +119,22 @@ CREATE TABLE IF NOT EXISTS subgroups ( UNIQUE(group_id, name) ); +INSERT INTO subgroups (group_id, name, student_capacity) +SELECT + sg.id, + data.name, + CASE + WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT + ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT + END +FROM student_groups sg +CROSS JOIN (VALUES + ('Подгруппа 1', 1), + ('Подгруппа 2', 2) +) AS data(name, part_number) +WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м') +ON CONFLICT (group_id, name) DO NOTHING; + -- ========================================== -- Справочники -- ========================================== @@ -512,8 +528,35 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots ( CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_subgroup ON schedule_rule_slots(subgroup_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id); +CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroup() +RETURNS TRIGGER AS $$ +DECLARE + lesson_type_name VARCHAR(50); +BEGIN + IF NEW.subgroup_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT name INTO lesson_type_name + FROM lesson_types + WHERE id = NEW.lesson_type_id; + + IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN + RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER validate_schedule_rule_slot_subgroup + BEFORE INSERT OR UPDATE ON schedule_rule_slots + FOR EACH ROW + EXECUTE FUNCTION validate_schedule_rule_slot_subgroup(); + CREATE TEMP TABLE tmp_default_rules AS SELECT NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, diff --git a/docs/API.md b/docs/API.md index 75f8bf9..42caf0a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,7 +101,7 @@ ## Динамическое расписание -Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Лабораторные слоты можно назначать на подгруппы, лекции и практики всегда проводятся для всей выбранной группы или потока. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. ### `GET /api/schedule` @@ -148,6 +148,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "lessonTypeName": "Лекция", "lessonFormat": "Очно", "subgroupId": null, + "subgroupName": null, "groupIds": [1], "groupNames": ["ИВТ-21-1"], "activityType": "Т", @@ -291,7 +292,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "dayOfWeek": 1, "parity": "BOTH", "timeSlotId": 3, - "subgroupId": null, + "subgroupId": null, "teacherId": 2, "classroomId": 1, "lessonTypeId": 1, @@ -313,6 +314,8 @@ CRUD доступен по: `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. +`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации. + Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. ### `GET /api/lesson-types` @@ -323,7 +326,8 @@ CRUD доступен по: ```json [ { "id": 1, "name": "Лекция" }, - { "id": 2, "name": "Практика" } + { "id": 2, "name": "Практика" }, + { "id": 3, "name": "Лабораторная работа" } ] ``` @@ -454,6 +458,37 @@ CRUD доступен по: Удаление группы. +### Подгруппы группы + +Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId`. + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/subgroups` | Список всех подгрупп | +| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы | +| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу | +| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу | +| `DELETE` | `/api/groups/{groupId}/subgroups/{id}` | Удалить подгруппу, если она не используется в расписании | + +**Тело создания/обновления:** +```json +{ + "name": "Подгруппа 1", + "studentCapacity": 12 +} +``` + +**Ответ:** +```json +{ + "id": 1, + "groupId": 1, + "groupName": "ИВТ-21-1", + "name": "Подгруппа 1", + "studentCapacity": 12 +} +``` + ### Календарные графики группы | Метод | URL | Назначение | diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 940eaa8..3b3ffe6 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -84,7 +84,7 @@ | `time_slots` | Настраиваемая сетка пар для тенанта | | `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | | `schedule_rule_groups` | Группы правила, включая потоковые лекции | -| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | +| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип, формат и опциональная лабораторная подгруппа | Генератор `ScheduleGeneratorService` рендерит расписание по запросу: 1. Определяет семестр для каждой даты диапазона. @@ -95,9 +95,11 @@ 6. Загружает правила группы или преподавателя. 7. Для каждого типа занятия проверяет свою неделю начала в семестре. 8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую. -9. Считает уже проведённые часы отдельно для лекций, лабораторных и практик. +9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы. 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. +Лабораторные работы могут делиться на подгруппы через `schedule_rule_slots.subgroup_id`. Если подгруппа выбрана, занятие выводится только для родительской группы этой подгруппы, а лимит лабораторных часов списывается отдельно по этой подгруппе. Лекции и практики не делятся на подгруппы: такие слоты сохраняются только с `subgroup_id = NULL`. + Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. ### Временные слоты @@ -124,6 +126,7 @@ - **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. +- **Подгруппы:** `subgroupId` разрешён только для лабораторных слотов и должен относиться к одной из групп правила. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index f7f962a..d11be68 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -348,6 +348,8 @@ erDiagram | `name` | VARCHAR(100) | Название подгруппы | | `student_capacity` | INT | Количество студентов | +Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий. + #### `subjects` — Дисциплины | Колонка | Тип | Описание | |---------|-----|----------| @@ -532,12 +534,14 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник | | `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | | `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот | -| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа | +| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа лабораторной работы | | `teacher_id` | BIGINT FK → users | Преподаватель | | `classroom_id` | BIGINT FK → classrooms | Аудитория | | `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | | `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | +`subgroup_id` заполняется только для лабораторных слотов. Лекции и практики не делятся на подгруппы и должны хранить `NULL`. Ограничение дополнительно проверяется триггером `validate_schedule_rule_slot_subgroup`; для быстрых выборок есть индекс `idx_schedule_rule_slots_subgroup`. + --- ## Flyway миграции diff --git a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 79ed21f..7a06847 100644 --- a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -25,7 +25,7 @@ | `time_slots` | Настраиваемая сетка пар | | `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | | `schedule_rule_groups` | Группы правила | -| `schedule_rule_slots` | Шаблонные слоты правила | +| `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу | Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. @@ -40,7 +40,7 @@ | `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | | `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | -`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. +`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. ## API @@ -64,6 +64,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 | `/api/admin/academic-calendars/{id}/grid` | Получение и сохранение дневной сетки | | `/api/specialties/{id}/profiles` | CRUD профилей специальности | | `/api/groups/{id}/calendar-assignments` | Назначения графиков группе | +| `/api/subgroups`, `/api/groups/{id}/subgroups` | Управление подгруппами для лабораторных | | `/api/admin/schedule-rules` | CRUD правил расписания | ## Frontend @@ -72,12 +73,13 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 - конструктор правил динамического расписания; - отдельные поля часов и недель начала для лекций, лабораторных и практик; +- выбор подгруппы только для лабораторного слота; - управление временными слотами; - учебные годы и семестры; - CRUD календарных графиков; - Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей. -Экран `groups` создаёт и редактирует группы с профилем обучения, а также назначает календарный график группе на учебный год. +Экран `groups` создаёт и редактирует группы с профилем обучения, управляет подгруппами для лабораторных и назначает календарный график группе на учебный год. Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d887377..6884331 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -106,13 +106,13 @@ frontend/ | Tab | Описание | API | |-----|----------|-----| | `users` | CRUD пользователей | `/api/users` | -| `groups` | CRUD групп | `/api/groups` | +| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `edu-forms` | Формы обучения | `/api/education-forms` | | `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` | +| `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` | | `database` | Тенанты | `/api/database` | @@ -120,10 +120,10 @@ frontend/ ### Особенности админских вкладок -- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. +- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Из календарной системы здесь используется список семестров для выбора периода действия правила. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. В слоте подгруппа доступна только при выборе лабораторной работы; для лекций и практик селект подгруппы заблокирован. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. @@ -218,7 +218,7 @@ export function isAuthenticatedAsAdmin() { - навигация по неделям: предыдущая, текущая, следующая; - выбор даты через `input[type="date"]`; - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; -- отображение дисциплины, времени, типа занятия, аудитории и всех групп правила. +- отображение дисциплины, времени, типа занятия, лабораторной подгруппы, аудитории и всех групп правила. Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. @@ -230,7 +230,7 @@ export function isAuthenticatedAsAdmin() { - селект группы с сохранением выбора в `localStorage.studentGroupId`; - недельная сетка по дням; - запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; -- отображение дисциплины, времени, преподавателя, аудитории, формата и типа занятия. +- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы. --- diff --git a/docs/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md index 3450083..2af646d 100644 --- a/docs/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -8,7 +8,7 @@ - календарный учебный график по специальности, профилю, форме обучения и учебному году; - правила расписания дисциплин; -- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия и формат; +- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональная подгруппа для лабораторных; - назначение конкретного календарного графика учебной группе на учебный год. При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются. @@ -29,7 +29,7 @@ - `time_slots` задаёт сетку пар для тенанта. - `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик. - `schedule_rule_groups` связывает правило с одной или несколькими группами. -- `schedule_rule_slots` хранит шаблонные занятия правила. +- `schedule_rule_slots` хранит шаблонные занятия правила; `subgroup_id` разрешён только для лабораторных работ. ## Алгоритм генерации @@ -39,7 +39,9 @@ 4. По курсу и дате находится код активности. 5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. 6. Для разрешённого дня выбираются активные правила и слоты. -7. Лимит часов считается только по реально проведённым занятиям. +7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе. + +Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы. Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. diff --git a/docs/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md index 3af1334..fb220d7 100644 --- a/docs/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -12,6 +12,7 @@ - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. - [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. +- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы. - [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. - [x] Обновлены админские экраны специальностей, групп и расписания. - [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 1cf7c2f..cd5a439 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -164,7 +164,7 @@ .schedule-slot-row { display: grid; - grid-template-columns: repeat(7, minmax(130px, 1fr)) auto; + grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; gap: 0.75rem; align-items: end; padding: 0.75rem; diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index 70606ba..b1fa96b 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -22,8 +22,17 @@ export async function initGroups() { const calendarYearSelect = document.getElementById('calendar-year'); const calendarSelect = document.getElementById('calendar-id'); const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); + const subgroupForm = document.getElementById('subgroup-form'); + const subgroupIdInput = document.getElementById('subgroup-id'); + const subgroupGroupSelect = document.getElementById('subgroup-group'); + const subgroupNameInput = document.getElementById('subgroup-name'); + const subgroupCapacityInput = document.getElementById('subgroup-capacity'); + const subgroupSubmitButton = document.getElementById('subgroup-submit'); + const subgroupResetButton = document.getElementById('subgroup-reset'); + const subgroupsTbody = document.getElementById('subgroups-tbody'); let allGroups = []; + let subgroups = []; let educationForms = []; let departments = []; let specialties = []; @@ -47,7 +56,11 @@ export async function initGroups() { createGroupForm.addEventListener('submit', createGroup); editGroupForm.addEventListener('submit', updateGroup); calendarForm.addEventListener('submit', saveAssignment); + subgroupForm.addEventListener('submit', saveSubgroup); + subgroupGroupSelect.addEventListener('change', renderSubgroups); + subgroupResetButton.addEventListener('click', resetSubgroupForm); groupsTbody.addEventListener('click', handleGroupTableClick); + subgroupsTbody.addEventListener('click', handleSubgroupTableClick); calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); modalEditGroup.addEventListener('click', (event) => { @@ -73,8 +86,10 @@ export async function initGroups() { populateYearSelect(); populateCalendarSelect(); await loadGroups(); + await loadSubgroups(); } catch (error) { groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; + subgroupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } } @@ -91,12 +106,22 @@ export async function initGroups() { allGroups = await api.get('/api/groups'); applyGroupFilter(); populateGroupSelect(); + populateSubgroupGroupSelect(); await loadAssignments(); } catch (error) { groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } + async function loadSubgroups() { + try { + subgroups = await api.get('/api/subgroups'); + renderSubgroups(); + } catch (error) { + subgroupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + function applyGroupFilter() { const filterId = filterEfSelect.value; const filtered = filterId @@ -217,6 +242,18 @@ export async function initGroups() { syncSelects(calendarGroupSelect); } + function populateSubgroupGroupSelect() { + const current = subgroupGroupSelect.value; + subgroupGroupSelect.innerHTML = '' + + allGroups.map(group => ``).join(''); + if (current && allGroups.some(group => String(group.id) === String(current))) { + subgroupGroupSelect.value = current; + } else if (allGroups.length) { + subgroupGroupSelect.value = String(allGroups[0].id); + } + syncSelects(subgroupGroupSelect); + } + function populateCalendarSelect() { const group = selectedCalendarGroup(); const yearId = calendarYearSelect.value; @@ -248,6 +285,7 @@ export async function initGroups() { createGroupForm.reset(); syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); await loadGroups(); + await loadSubgroups(); } catch (error) { showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); } @@ -265,6 +303,7 @@ export async function initGroups() { modalEditGroup.classList.remove('open'); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); await loadGroups(); + await loadSubgroups(); } catch (error) { showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); } @@ -309,6 +348,7 @@ export async function initGroups() { try { await api.delete('/api/groups/' + btnDelete.dataset.id); await loadGroups(); + await loadSubgroups(); } catch (error) { alert(error.message || 'Ошибка удаления'); } @@ -339,6 +379,100 @@ export async function initGroups() { modalEditGroup.classList.add('open'); } + async function saveSubgroup(event) { + event.preventDefault(); + hideAlert('subgroup-alert'); + const groupId = subgroupGroupSelect.value; + const name = subgroupNameInput.value.trim(); + const capacity = subgroupCapacityInput.value; + const subgroupId = subgroupIdInput.value; + + if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; } + if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; } + if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; } + + const payload = { + name, + studentCapacity: capacity ? Number(capacity) : null + }; + + try { + const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`; + const saved = subgroupId + ? await api.put(endpoint, payload) + : await api.post(endpoint, payload); + showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success'); + resetSubgroupForm(); + await loadSubgroups(); + } catch (error) { + showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error'); + } + } + + function renderSubgroups() { + const groupId = subgroupGroupSelect.value; + const visibleSubgroups = groupId + ? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId)) + : subgroups; + if (!visibleSubgroups.length) { + subgroupsTbody.innerHTML = 'Подгруппы не созданы'; + return; + } + subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => ` + + ${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))} + ${escapeHtml(subgroup.name)} + ${escapeHtml(String(subgroup.studentCapacity ?? '-'))} + + + + + + `).join(''); + } + + async function handleSubgroupTableClick(event) { + const editButton = event.target.closest('.btn-edit-subgroup'); + const deleteButton = event.target.closest('.btn-delete-subgroup'); + + if (editButton) { + const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id)); + if (!subgroup) return; + subgroupIdInput.value = subgroup.id; + subgroupGroupSelect.value = subgroup.groupId; + subgroupGroupSelect.disabled = true; + subgroupNameInput.value = subgroup.name || ''; + subgroupCapacityInput.value = subgroup.studentCapacity ?? ''; + subgroupSubmitButton.textContent = 'Сохранить подгруппу'; + syncSelects(subgroupGroupSelect); + hideAlert('subgroup-alert'); + return; + } + + if (deleteButton) { + const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id)); + if (!subgroup || !confirm('Удалить подгруппу?')) return; + try { + await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`); + showAlert('subgroup-alert', 'Подгруппа удалена', 'success'); + resetSubgroupForm(); + await loadSubgroups(); + } catch (error) { + showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error'); + } + } + } + + function resetSubgroupForm() { + subgroupIdInput.value = ''; + subgroupNameInput.value = ''; + subgroupCapacityInput.value = ''; + subgroupGroupSelect.disabled = false; + subgroupSubmitButton.textContent = 'Создать подгруппу'; + syncSelects(subgroupGroupSelect); + hideAlert('subgroup-alert'); + } + async function saveAssignment(event) { event.preventDefault(); hideAlert('group-calendar-alert'); @@ -419,6 +553,11 @@ export async function initGroups() { return department.departmentName || department.name || departmentId; } + function groupName(groupId) { + const group = allGroups.find(item => String(item.id) === String(groupId)); + return group ? group.name : groupId || '-'; + } + function specialityLabel(specialityId) { const speciality = specialties.find(item => item.id == specialityId); if (!speciality) return specialityId || '-'; diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index accc674..ce8c626 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -72,6 +72,7 @@ export async function initSchedule() { let teachers = []; let classrooms = []; let lessonTypes = []; + let subgroups = []; let timeSlots = []; let semesters = []; @@ -99,17 +100,24 @@ export async function initSchedule() { renderRuleSlots(slots.length ? slots : [{}]); }); + ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false))); + ruleSlotsContainer.addEventListener('change', (event) => { + if (event.target.closest('.slot-lesson-type')) { + renderRuleSlots(readRuleSlots(false)); + } + }); ruleForm.addEventListener('submit', saveRule); rulesTbody.addEventListener('click', handleRuleTableClick); } async function loadBaseLists() { - [subjects, groups, teachers, classrooms, lessonTypes] = await Promise.all([ + [subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([ api.get('/api/subjects'), api.get('/api/groups'), api.get('/api/users/teachers'), api.get('/api/classrooms'), - api.get('/api/lesson-types') + api.get('/api/lesson-types'), + api.get('/api/subgroups') ]); populateSubjectSelects(); } @@ -159,6 +167,7 @@ export async function initSchedule() { slot.dayName, slot.timeSlotLabel, parity, + slot.subgroupName, slot.teacherName, slot.classroomName, slot.lessonTypeName, @@ -290,6 +299,7 @@ export async function initSchedule() {
+
@@ -299,12 +309,13 @@ export async function initSchedule() { function readRuleSlots(validate) { const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); return rows.map((row, index) => { + const subgroupValue = row.querySelector('.slot-subgroup')?.value || ''; const slot = { id: null, dayOfWeek: Number(row.querySelector('.slot-day').value), parity: row.querySelector('.slot-parity').value, timeSlotId: Number(row.querySelector('.slot-time').value), - subgroupId: null, + subgroupId: subgroupValue ? Number(subgroupValue) : null, teacherId: Number(row.querySelector('.slot-teacher').value), classroomId: Number(row.querySelector('.slot-classroom').value), lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), @@ -314,6 +325,9 @@ export async function initSchedule() { || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) { throw new Error(`Заполните слот ${index + 1}`); } + if (validate) { + validateSlotSubgroup(slot, index); + } return slot; }); } @@ -354,6 +368,23 @@ export async function initSchedule() { if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий'); } + function validateSlotSubgroup(slot, index) { + const typeKey = lessonTypeKeyById(slot.lessonTypeId); + if (slot.subgroupId && typeKey !== 'laboratory') { + throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`); + } + if (!slot.subgroupId) return; + + const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId)); + if (!selectedSubgroup) { + throw new Error(`В слоте ${index + 1} подгруппа не найдена`); + } + const selectedGroups = new Set(selectedRuleGroupIds().map(String)); + if (!selectedGroups.has(String(selectedSubgroup.groupId))) { + throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`); + } + } + async function loadTimeSlots() { const slots = await api.get('/api/admin/time-slots'); timeSlots = slots @@ -382,6 +413,26 @@ export async function initSchedule() { }).join(''); } + function subgroupOptions(slot) { + if (!isLaboratorySlot(slot)) { + return ''; + } + const selected = slot.subgroupId == null ? '' : String(slot.subgroupId); + const selectedGroups = new Set(selectedRuleGroupIds().map(String)); + const matchingSubgroups = subgroups + .filter(subgroup => selectedGroups.has(String(subgroup.groupId))) + .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); + return '' + matchingSubgroups.map(subgroup => { + const value = String(subgroup.id); + return ``; + }).join(''); + } + + function selectedRuleGroupIds() { + return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) + .map(input => Number(input.value)); + } + function toTimeSlotOption(slot) { return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; } @@ -404,6 +455,14 @@ export async function initSchedule() { return lessonType ? lessonTypeKey(lessonType) : null; } + function isLaboratorySlot(slot) { + return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory'; + } + + function subgroupLabel(subgroup) { + return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`; + } + function lessonTypeKey(lessonType) { const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase(); if (name.includes('лекц')) return 'lecture'; diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index cdba50c..879269a 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -79,6 +79,49 @@ +
+

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

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

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

diff --git a/frontend/student/index.html b/frontend/student/index.html index 4ca5a2f..4b83cae 100644 --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -422,6 +422,7 @@
${escapeHtml(lesson.subjectName)}
${escapeHtml(lesson.lessonTypeName)} + ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} ${escapeHtml(lesson.lessonFormat)} ${escapeHtml(lesson.teacherName)} ${escapeHtml(lesson.classroomName)} diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index 43d82f4..f15428c 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -437,6 +437,7 @@
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))} ${escapeHtml(lesson.lessonTypeName)} + ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} ${escapeHtml(lesson.classroomName)} ${groups ? `${escapeHtml(groups)}` : ''}
From 02c5ea911848d1ce6be5b095249d7fd69f74fbb5 Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 8 May 2026 13:41:25 +0300 Subject: [PATCH 09/16] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleRuleAdminController.java | 66 ++++- .../magistr/app/dto/RenderedLessonDto.java | 2 + .../magistr/app/dto/ScheduleRuleSlotDto.java | 4 + .../magistr/app/model/ScheduleRuleSlot.java | 46 ++- .../repository/ScheduleRuleRepository.java | 16 +- .../ScheduleRuleSlotRepository.java | 2 +- .../app/service/ScheduleGeneratorService.java | 114 ++++++-- .../main/resources/db/migration/V1__init.sql | 76 ++++- docs/API.md | 9 +- docs/BUSINESS_LOGIC.md | 7 +- docs/DATABASE.md | 18 +- docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 7 +- docs/FRONTEND.md | 6 +- docs/SCHEDULE_PROPOSAL.md | 6 +- docs/SCHEDULE_TASKS.md | 2 +- frontend/admin/css/components.css | 42 ++- frontend/admin/js/views/schedule.js | 271 ++++++++++++++++-- frontend/admin/views/schedule.html | 14 +- frontend/student/index.html | 5 +- frontend/teacher/index.html | 5 +- 20 files changed, 592 insertions(+), 126 deletions(-) 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 3c36000..516a73b 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -191,7 +191,7 @@ public class ScheduleRuleAdminController { LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType); - Subgroup subgroup = resolveSubgroup(rule, slotDto.subgroupId(), category); + Set subgroups = resolveSubgroups(rule, slotDto, category); if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) { throw new IllegalArgumentException("Формат занятия обязателен"); } @@ -201,7 +201,7 @@ public class ScheduleRuleAdminController { slot.setDayOfWeek(slotDto.dayOfWeek()); slot.setParity(slotDto.parity()); slot.setTimeSlot(timeSlot); - slot.setSubgroup(subgroup); + slot.setSubgroups(subgroups); slot.setTeacher(teacher); slot.setClassroom(classroom); slot.setLessonType(lessonType); @@ -209,21 +209,44 @@ public class ScheduleRuleAdminController { return slot; } - private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) { - if (subgroupId == null) { - return null; + private Set resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) { + LinkedHashSet subgroupIds = new LinkedHashSet<>(); + if (slotDto.subgroupIds() != null) { + slotDto.subgroupIds().stream() + .filter(Objects::nonNull) + .forEach(subgroupIds::add); } + if (slotDto.subgroupId() != null) { + subgroupIds.add(slotDto.subgroupId()); + } + if (subgroupIds.isEmpty()) { + return new LinkedHashSet<>(); + } + if (category != ScheduleLessonCategory.LABORATORY) { throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий"); } - Subgroup subgroup = subgroupRepository.findById(subgroupId) - .orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена")); - boolean subgroupBelongsToRuleGroups = rule.getGroups().stream() - .anyMatch(group -> group.getId().equals(subgroup.getStudentGroup().getId())); - if (!subgroupBelongsToRuleGroups) { - throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила"); + + Set ruleGroupIds = rule.getGroups().stream() + .map(StudentGroup::getId) + .collect(java.util.stream.Collectors.toSet()); + Set subgroupGroupIds = new HashSet<>(); + LinkedHashSet subgroups = new LinkedHashSet<>(); + + for (Long subgroupId : subgroupIds) { + Subgroup subgroup = subgroupRepository.findById(subgroupId) + .orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена")); + Long subgroupGroupId = subgroup.getStudentGroup().getId(); + if (!ruleGroupIds.contains(subgroupGroupId)) { + throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила"); + } + if (!subgroupGroupIds.add(subgroupGroupId)) { + throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы"); + } + subgroups.add(subgroup); } - return subgroup; + + return subgroups; } private String validateTypeHours(ScheduleRuleDto request) { @@ -318,6 +341,7 @@ public class ScheduleRuleAdminController { private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) { TimeSlot timeSlot = slot.getTimeSlot(); + List sortedSubgroups = sortedSubgroups(slot); return new ScheduleRuleSlotDto( slot.getId(), slot.getDayOfWeek(), @@ -326,8 +350,10 @@ public class ScheduleRuleAdminController { timeSlot.getId(), timeSlot.getOrderNumber(), formatTimeSlot(timeSlot), - slot.getSubgroupId(), - slot.getSubgroup() == null ? null : slot.getSubgroup().getName(), + sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(), + sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)), + sortedSubgroups.stream().map(Subgroup::getId).toList(), + sortedSubgroups.stream().map(this::formatSubgroup).toList(), slot.getTeacher().getId(), ScheduleGeneratorService.displayUserName(slot.getTeacher()), slot.getClassroom().getId(), @@ -348,4 +374,16 @@ public class ScheduleRuleAdminController { private String formatTimeSlot(TimeSlot timeSlot) { return timeSlot.getStartTime() + " - " + timeSlot.getEndTime(); } + + private String formatSubgroup(Subgroup subgroup) { + return subgroup.getStudentGroup().getName() + ": " + subgroup.getName(); + } + + private List sortedSubgroups(ScheduleRuleSlot slot) { + return slot.getSubgroups().stream() + .sorted(Comparator + .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName()) + .thenComparing(Subgroup::getName)) + .toList(); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 6b6ddcc..b449fae 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -29,6 +29,8 @@ public record RenderedLessonDto( String lessonFormat, Long subgroupId, String subgroupName, + List subgroupIds, + List subgroupNames, List groupIds, List groupNames, String activityType, diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java index 6cb88cf..bcdfb01 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java @@ -2,6 +2,8 @@ package com.magistr.app.dto; import com.magistr.app.model.ScheduleParity; +import java.util.List; + public record ScheduleRuleSlotDto( Long id, Integer dayOfWeek, @@ -12,6 +14,8 @@ public record ScheduleRuleSlotDto( String timeSlotLabel, Long subgroupId, String subgroupName, + List subgroupIds, + List subgroupNames, Long teacherId, String teacherName, Long classroomId, diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java index 63cfcf3..1404dc8 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java @@ -2,6 +2,10 @@ package com.magistr.app.model; import jakarta.persistence.*; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + @Entity @Table(name = "schedule_rule_slots") public class ScheduleRuleSlot { @@ -25,9 +29,13 @@ public class ScheduleRuleSlot { @JoinColumn(name = "time_slot_id", nullable = false) private TimeSlot timeSlot; - @ManyToOne - @JoinColumn(name = "subgroup_id") - private Subgroup subgroup; + @ManyToMany + @JoinTable( + name = "schedule_rule_slot_subgroups", + joinColumns = @JoinColumn(name = "schedule_rule_slot_id"), + inverseJoinColumns = @JoinColumn(name = "subgroup_id") + ) + private Set subgroups = new LinkedHashSet<>(); @ManyToOne(optional = false) @JoinColumn(name = "teacher_id", nullable = false) @@ -85,15 +93,41 @@ public class ScheduleRuleSlot { } public Long getSubgroupId() { - return subgroup == null ? null : subgroup.getId(); + return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null); } public Subgroup getSubgroup() { - return subgroup; + return subgroups.stream().findFirst().orElse(null); } public void setSubgroup(Subgroup subgroup) { - this.subgroup = subgroup; + subgroups.clear(); + if (subgroup != null) { + subgroups.add(subgroup); + } + } + + public Set getSubgroups() { + return subgroups; + } + + public void setSubgroups(Set subgroups) { + this.subgroups.clear(); + if (subgroups != null) { + this.subgroups.addAll(subgroups); + } + } + + public List getSubgroupIds() { + return subgroups.stream() + .map(Subgroup::getId) + .toList(); + } + + public List getSubgroupNames() { + return subgroups.stream() + .map(Subgroup::getName) + .toList(); } public User getTeacher() { 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 1577f1d..6b51ac5 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -19,8 +19,8 @@ public interface ScheduleRuleRepository extends JpaRepository { - @Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot where slot.subgroup.id = :subgroupId") + @Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId") boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId); } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 0a98f9e..83925d4 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -201,22 +201,54 @@ public class ScheduleGeneratorService { if (totalHours <= 0) { continue; } - List lessonGroups = groupsForSlot(slot, eligibleGroups); - if (lessonGroups.isEmpty()) { + List lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups); + if (lessonScopes.isEmpty()) { continue; } - String consumptionBucket = consumptionBucket(category, slot); - int consumedHours = consumedHoursByBucket.computeIfAbsent( - consumptionBucket, - key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId()) - ); - if (consumedHours >= totalHours) { + + List activeScopes = new ArrayList<>(); + for (LessonScope scope : lessonScopes) { + String consumptionBucket = consumptionBucket(category, scope.subgroupId()); + int consumedHours = consumedHoursByBucket.computeIfAbsent( + consumptionBucket, + key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId()) + ); + if (consumedHours >= totalHours) { + continue; + } + int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT); + activeScopes.add(new ActiveLessonScope(scope, consumedHours, remainingAfterLesson)); + } + + if (activeScopes.isEmpty()) { continue; } - int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT); - result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, + + Map lessonGroupById = new LinkedHashMap<>(); + Map lessonSubgroupById = new LinkedHashMap<>(); + activeScopes.forEach(scope -> { + scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group)); + scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup)); + }); + List lessonGroups = new ArrayList<>(lessonGroupById.values()); + List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values()); + int consumedHours = activeScopes.stream() + .mapToInt(ActiveLessonScope::consumedHours) + .min() + .orElse(0); + int remainingAfterLesson = activeScopes.stream() + .mapToInt(ActiveLessonScope::remainingAfterLesson) + .min() + .orElse(0); + + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups, totalHours, consumedHours, remainingAfterLesson)); - consumedHoursByBucket.put(consumptionBucket, consumedHours + ACADEMIC_HOURS_PER_SLOT); + for (ActiveLessonScope scope : activeScopes) { + consumedHoursByBucket.put( + consumptionBucket(category, scope.scope().subgroupId()), + scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT + ); + } } } @@ -272,8 +304,8 @@ public class ScheduleGeneratorService { .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) - .filter(slot -> Objects.equals(slot.getSubgroupId(), subgroupId)) - .filter(slot -> !groupsForSlot(slot, eligibleGroups).isEmpty()) + .flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream()) + .filter(scope -> Objects.equals(scope.subgroupId(), subgroupId)) .count(); consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; if (consumed >= rule.academicHoursFor(category)) { @@ -283,26 +315,51 @@ public class ScheduleGeneratorService { return consumed; } - private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) { - return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId()); + private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) { + return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId); } - private List groupsForSlot(ScheduleRuleSlot slot, List eligibleGroups) { - if (slot.getSubgroup() == null) { - return eligibleGroups; + private List lessonScopesForSlot(ScheduleRuleSlot slot, + ScheduleLessonCategory category, + List eligibleGroups) { + if (category != ScheduleLessonCategory.LABORATORY || slot.getSubgroups().isEmpty()) { + return eligibleGroups.isEmpty() + ? List.of() + : List.of(new LessonScope(eligibleGroups, List.of(), null)); } - Long subgroupGroupId = slot.getSubgroup().getStudentGroup().getId(); - return eligibleGroups.stream() - .filter(group -> Objects.equals(group.getId(), subgroupGroupId)) + + Set eligibleGroupIds = eligibleGroups.stream() + .map(StudentGroup::getId) + .collect(Collectors.toSet()); + Map eligibleGroupById = eligibleGroups.stream() + .collect(Collectors.toMap(StudentGroup::getId, group -> group)); + + return slot.getSubgroups().stream() + .filter(subgroup -> eligibleGroupIds.contains(subgroup.getStudentGroup().getId())) + .sorted(Comparator + .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName()) + .thenComparing(Subgroup::getName)) + .map(subgroup -> new LessonScope( + List.of(eligibleGroupById.get(subgroup.getStudentGroup().getId())), + List.of(subgroup), + subgroup.getId() + )) .toList(); } + private record LessonScope(List groups, List subgroups, Long subgroupId) { + } + + private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) { + } + private RenderedLessonDto toRenderedLesson(ScheduleRule rule, ScheduleRuleSlot slot, LocalDate date, int weekNumber, ScheduleParity dateParity, List groups, + List subgroups, int lessonTypeAcademicHours, int consumedBeforeLesson, int remainingAfterLesson) { @@ -310,6 +367,11 @@ public class ScheduleGeneratorService { List sortedGroups = groups.stream() .sorted(Comparator.comparing(StudentGroup::getName)) .toList(); + List sortedSubgroups = subgroups.stream() + .sorted(Comparator + .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName()) + .thenComparing(Subgroup::getName)) + .toList(); return new RenderedLessonDto( rule.getId(), @@ -332,8 +394,10 @@ public class ScheduleGeneratorService { slot.getLessonType().getId(), slot.getLessonType().getLessonType(), slot.getLessonFormat(), - slot.getSubgroupId(), - slot.getSubgroup() == null ? null : slot.getSubgroup().getName(), + sortedSubgroups.size() == 1 ? sortedSubgroups.get(0).getId() : null, + sortedSubgroups.size() == 1 ? formatSubgroup(sortedSubgroups.get(0)) : null, + sortedSubgroups.stream().map(Subgroup::getId).toList(), + sortedSubgroups.stream().map(this::formatSubgroup).toList(), sortedGroups.stream().map(StudentGroup::getId).toList(), sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), "Т", @@ -343,6 +407,10 @@ public class ScheduleGeneratorService { ); } + private String formatSubgroup(Subgroup subgroup) { + return subgroup.getStudentGroup().getName() + ": " + subgroup.getName(); + } + private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) { if (baseSlot == null || baseSlot.getOrderNumber() == null) { return baseSlot; diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 0fe5465..8bba67b 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -515,7 +515,6 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots ( day_of_week INT NOT NULL, parity VARCHAR(10) NOT NULL, time_slot_id BIGINT NOT NULL REFERENCES time_slots(id), - subgroup_id BIGINT NULL REFERENCES subgroups(id) ON DELETE SET NULL, teacher_id BIGINT NOT NULL REFERENCES users(id), classroom_id BIGINT NOT NULL REFERENCES classrooms(id), lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), @@ -528,34 +527,84 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots ( CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id); -CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_subgroup ON schedule_rule_slots(subgroup_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id); -CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroup() +CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups ( + schedule_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id) ON DELETE CASCADE, + subgroup_id BIGINT NOT NULL REFERENCES subgroups(id) ON DELETE CASCADE, + PRIMARY KEY (schedule_rule_slot_id, subgroup_id) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id); + +CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups() RETURNS TRIGGER AS $$ DECLARE lesson_type_name VARCHAR(50); + rule_id BIGINT; + subgroup_group_id BIGINT; + has_group_duplicate BOOLEAN; BEGIN - IF NEW.subgroup_id IS NULL THEN - RETURN NEW; - END IF; - - SELECT name INTO lesson_type_name - FROM lesson_types - WHERE id = NEW.lesson_type_id; + SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name + FROM schedule_rule_slots slot + JOIN lesson_types type ON type.id = slot.lesson_type_id + WHERE slot.id = NEW.schedule_rule_slot_id; IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий'; END IF; + SELECT group_id INTO subgroup_group_id + FROM subgroups + WHERE id = NEW.subgroup_id; + + IF subgroup_group_id IS NULL THEN + RAISE EXCEPTION 'Подгруппа не найдена'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM schedule_rule_groups rule_group + WHERE rule_group.schedule_rule_id = rule_id + AND rule_group.group_id = subgroup_group_id + ) THEN + RAISE EXCEPTION 'Подгруппа должна относиться к одной из групп правила'; + END IF; + + IF TG_OP = 'UPDATE' THEN + SELECT EXISTS ( + SELECT 1 + FROM schedule_rule_slot_subgroups slot_subgroup + JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id + WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id + AND subgroup.group_id = subgroup_group_id + AND NOT ( + slot_subgroup.schedule_rule_slot_id = OLD.schedule_rule_slot_id + AND slot_subgroup.subgroup_id = OLD.subgroup_id + ) + ) INTO has_group_duplicate; + ELSE + SELECT EXISTS ( + SELECT 1 + FROM schedule_rule_slot_subgroups slot_subgroup + JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id + WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id + AND subgroup.group_id = subgroup_group_id + ) INTO has_group_duplicate; + END IF; + + IF has_group_duplicate THEN + RAISE EXCEPTION 'В одном слоте можно выбрать не больше одной подгруппы каждой группы'; + END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER validate_schedule_rule_slot_subgroup - BEFORE INSERT OR UPDATE ON schedule_rule_slots +CREATE TRIGGER validate_schedule_rule_slot_subgroups + BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups FOR EACH ROW - EXECUTE FUNCTION validate_schedule_rule_slot_subgroup(); + EXECUTE FUNCTION validate_schedule_rule_slot_subgroups(); CREATE TEMP TABLE tmp_default_rules AS SELECT @@ -712,6 +761,7 @@ COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения к COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; +COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания'; COMMENT ON 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 'Лимит академических часов практик'; diff --git a/docs/API.md b/docs/API.md index 42caf0a..babf842 100644 --- a/docs/API.md +++ b/docs/API.md @@ -149,6 +149,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "lessonFormat": "Очно", "subgroupId": null, "subgroupName": null, + "subgroupIds": [], + "subgroupNames": [], "groupIds": [1], "groupNames": ["ИВТ-21-1"], "activityType": "Т", @@ -292,7 +294,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "dayOfWeek": 1, "parity": "BOTH", "timeSlotId": 3, - "subgroupId": null, + "subgroupId": null, + "subgroupIds": [], "teacherId": 2, "classroomId": 1, "lessonTypeId": 1, @@ -314,7 +317,7 @@ CRUD доступен по: `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. -`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации. +`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы. Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. @@ -460,7 +463,7 @@ CRUD доступен по: ### Подгруппы группы -Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId`. +Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`. | Метод | URL | Назначение | |-------|-----|------------| diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 3b3ffe6..fe9d3a1 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -84,7 +84,8 @@ | `time_slots` | Настраиваемая сетка пар для тенанта | | `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | | `schedule_rule_groups` | Группы правила, включая потоковые лекции | -| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип, формат и опциональная лабораторная подгруппа | +| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | +| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | Генератор `ScheduleGeneratorService` рендерит расписание по запросу: 1. Определяет семестр для каждой даты диапазона. @@ -98,7 +99,7 @@ 9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы. 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. -Лабораторные работы могут делиться на подгруппы через `schedule_rule_slots.subgroup_id`. Если подгруппа выбрана, занятие выводится только для родительской группы этой подгруппы, а лимит лабораторных часов списывается отдельно по этой подгруппе. Лекции и практики не делятся на подгруппы: такие слоты сохраняются только с `subgroup_id = NULL`. +Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. @@ -126,7 +127,7 @@ - **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. -- **Подгруппы:** `subgroupId` разрешён только для лабораторных слотов и должен относиться к одной из групп правила. +- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index d11be68..b4af127 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -226,12 +226,16 @@ erDiagram INT day_of_week VARCHAR parity BIGINT time_slot_id FK - BIGINT subgroup_id FK BIGINT teacher_id FK BIGINT classroom_id FK BIGINT lesson_type_id FK VARCHAR lesson_format } + + schedule_rule_slot_subgroups { + BIGINT schedule_rule_slot_id FK,PK + BIGINT subgroup_id FK,PK + } departments ||--o{ users : "department_id" departments ||--o{ student_groups : "department_id" @@ -265,10 +269,11 @@ erDiagram academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" + schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id" time_slot_scopes ||--o{ time_slots : "time_slot_scope_id" time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id" time_slots ||--o{ schedule_rule_slots : "time_slot_id" - subgroups ||--o{ schedule_rule_slots : "subgroup_id" + subgroups ||--o{ schedule_rule_slot_subgroups : "subgroup_id" users ||--o{ schedule_rule_slots : "teacher_id" ``` @@ -534,13 +539,18 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник | | `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | | `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот | -| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа лабораторной работы | | `teacher_id` | BIGINT FK → users | Преподаватель | | `classroom_id` | BIGINT FK → classrooms | Аудитория | | `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | | `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | -`subgroup_id` заполняется только для лабораторных слотов. Лекции и практики не делятся на подгруппы и должны хранить `NULL`. Ограничение дополнительно проверяется триггером `validate_schedule_rule_slot_subgroup`; для быстрых выборок есть индекс `idx_schedule_rule_slots_subgroup`. +#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота +| Колонка | Тип | Описание | +|---------|-----|----------| +| `schedule_rule_slot_id` | BIGINT PK, FK → schedule_rule_slots (CASCADE) | Слот правила | +| `subgroup_id` | BIGINT PK, FK → subgroups (CASCADE) | Подгруппа | + +Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `validate_schedule_rule_slot_subgroups` проверяет тип занятия, принадлежность подгруппы к группам правила и запрет на две подгруппы одной группы в одном слоте; для быстрых выборок есть индекс `idx_schedule_rule_slot_subgroups_subgroup`. --- diff --git a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 7a06847..3987c1a 100644 --- a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -25,7 +25,8 @@ | `time_slots` | Настраиваемая сетка пар | | `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | | `schedule_rule_groups` | Группы правила | -| `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу | +| `schedule_rule_slots` | Шаблонные слоты правила | +| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. @@ -40,7 +41,7 @@ | `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | | `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | -`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. +`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; один лабораторный слот может включать разные подгруппы разных групп. Лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. ## API @@ -73,7 +74,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 - конструктор правил динамического расписания; - отдельные поля часов и недель начала для лекций, лабораторных и практик; -- выбор подгруппы только для лабораторного слота; +- выбор подгрупп только для лабораторного слота; - управление временными слотами; - учебные годы и семестры; - CRUD календарных графиков; diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6884331..31b8404 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -123,7 +123,7 @@ frontend/ - Вкладка `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. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. В слоте подгруппа доступна только при выборе лабораторной работы; для лекций и практик селект подгруппы заблокирован. Из календарной системы здесь используется список семестров для выбора периода действия правила. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. @@ -218,7 +218,7 @@ export function isAuthenticatedAsAdmin() { - навигация по неделям: предыдущая, текущая, следующая; - выбор даты через `input[type="date"]`; - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; -- отображение дисциплины, времени, типа занятия, лабораторной подгруппы, аудитории и всех групп правила. +- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила. Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. @@ -230,7 +230,7 @@ export function isAuthenticatedAsAdmin() { - селект группы с сохранением выбора в `localStorage.studentGroupId`; - недельная сетка по дням; - запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; -- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы. +- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп. --- diff --git a/docs/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md index 2af646d..c064c40 100644 --- a/docs/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -8,7 +8,7 @@ - календарный учебный график по специальности, профилю, форме обучения и учебному году; - правила расписания дисциплин; -- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональная подгруппа для лабораторных; +- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональные подгруппы для лабораторных; - назначение конкретного календарного графика учебной группе на учебный год. При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются. @@ -29,7 +29,7 @@ - `time_slots` задаёт сетку пар для тенанта. - `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик. - `schedule_rule_groups` связывает правило с одной или несколькими группами. -- `schedule_rule_slots` хранит шаблонные занятия правила; `subgroup_id` разрешён только для лабораторных работ. +- `schedule_rule_slots` хранит шаблонные занятия правила, а `schedule_rule_slot_subgroups` связывает лабораторный слот с одной или несколькими подгруппами. ## Алгоритм генерации @@ -41,7 +41,7 @@ 6. Для разрешённого дня выбираются активные правила и слоты. 7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе. -Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы. +Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к нескольким подгруппам разных групп, он выводится как совместное занятие этих групп, а часы списываются отдельно по каждой подгруппе. Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. diff --git a/docs/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md index fb220d7..86221ef 100644 --- a/docs/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -12,7 +12,7 @@ - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. - [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. -- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы. +- [x] Добавлены подгруппы для лабораторных слотов, включая выбор разных подгрупп разных групп в одном поточном лабораторном слоте; лекции и практики не делятся на подгруппы. - [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. - [x] Обновлены админские экраны специальностей, групп и расписания. - [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index cd5a439..19a65e4 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -114,14 +114,18 @@ letter-spacing: 0.04em; } -.schedule-checkbox-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 0.35rem; - padding: 0.5rem; - background: var(--bg-input); - border: 1px solid var(--bg-card-border); - border-radius: var(--radius-sm); +.schedule-group-select { + max-width: 520px; +} + +.schedule-group-select .select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-group-checkboxes { + max-height: 220px; } .schedule-hours-panel { @@ -164,7 +168,7 @@ .schedule-slot-row { display: grid; - grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; + grid-template-columns: repeat(7, minmax(125px, 1fr)) auto; gap: 0.75rem; align-items: end; padding: 0.75rem; @@ -173,6 +177,20 @@ border-radius: var(--radius-sm); } +.schedule-slot-row.has-subgroup { + grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; +} + +.slot-subgroup-multi .select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slot-subgroup-multi .dropdown-menu { + min-width: 260px; +} + .schedule-slot-summary, .schedule-semester-line { margin-bottom: 0.35rem; @@ -479,7 +497,8 @@ grid-template-columns: 1fr; } - .schedule-slot-row { + .schedule-slot-row, + .schedule-slot-row.has-subgroup { grid-template-columns: repeat(2, minmax(180px, 1fr)); } @@ -499,7 +518,8 @@ grid-template-columns: 1fr; } - .schedule-slot-row { + .schedule-slot-row, + .schedule-slot-row.has-subgroup { grid-template-columns: 1fr; } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index ce8c626..490fc83 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -22,6 +22,8 @@ const SEMESTER_LABELS = { spring: 'весенний' }; +const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; + const LESSON_TYPE_LIMITS = [ { key: 'lecture', @@ -59,7 +61,10 @@ export async function initSchedule() { hours: document.getElementById(config.hoursInputId), startWeek: document.getElementById(config.startWeekInputId) }])); - const ruleGroupsContainer = document.getElementById('schedule-rule-groups'); + const ruleGroupsBox = document.getElementById('schedule-rule-groups-box'); + const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu'); + const ruleGroupsText = document.getElementById('schedule-rule-groups-text'); + const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes'); const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); const ruleResetButton = document.getElementById('schedule-rule-reset'); const slotAddButton = document.getElementById('schedule-slot-add'); @@ -94,16 +99,44 @@ export async function initSchedule() { ruleSlotsContainer.addEventListener('click', (event) => { const removeButton = event.target.closest('.schedule-slot-remove'); - if (!removeButton) return; - const slots = readRuleSlots(false); - slots.splice(Number(removeButton.dataset.index), 1); - renderRuleSlots(slots.length ? slots : [{}]); + const subgroupBox = event.target.closest('.slot-subgroup-box'); + const subgroupMenu = event.target.closest('.slot-subgroup-menu'); + + if (removeButton) { + const slots = readRuleSlots(false); + slots.splice(Number(removeButton.dataset.index), 1); + renderRuleSlots(slots.length ? slots : [{}]); + return; + } + + if (subgroupBox) { + toggleSlotSubgroupMenu(event, subgroupBox); + return; + } + + if (subgroupMenu) { + event.stopPropagation(); + } }); - ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false))); + ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu); + ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown); + ruleGroupsMenu.addEventListener('click', event => event.stopPropagation()); + ruleGroupCheckboxes.addEventListener('change', () => { + updateRuleGroupsText(); + renderRuleSlots(readRuleSlots(false)); + }); + document.addEventListener('click', () => { + closeRuleGroupsMenu(); + closeSlotSubgroupMenus(); + }); ruleSlotsContainer.addEventListener('change', (event) => { if (event.target.closest('.slot-lesson-type')) { renderRuleSlots(readRuleSlots(false)); + return; + } + if (event.target.closest('.slot-subgroup-checkboxes')) { + updateSlotSubgroupText(event.target.closest('.schedule-slot-row')); } }); ruleForm.addEventListener('submit', saveRule); @@ -167,7 +200,7 @@ export async function initSchedule() { slot.dayName, slot.timeSlotLabel, parity, - slot.subgroupName, + slotSubgroupNames(slot).join(', '), slot.teacherName, slot.classroomName, slot.lessonTypeName, @@ -232,8 +265,7 @@ export async function initSchedule() { } function buildRulePayload() { - const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) - .map(input => Number(input.value)); + const groupIds = selectedRuleGroupIds(); const slots = readRuleSlots(true); if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); @@ -277,29 +309,31 @@ export async function initSchedule() { function renderRuleGroups(selectedIds) { if (!groups.length) { - ruleGroupsContainer.innerHTML = '
Нет групп
'; + ruleGroupCheckboxes.innerHTML = '
Нет групп
'; + updateRuleGroupsText(); return; } const selected = new Set((selectedIds || []).map(String)); - ruleGroupsContainer.innerHTML = groups.map(group => ` + ruleGroupCheckboxes.innerHTML = groups.map(group => ` `).join(''); + updateRuleGroupsText(); } function renderRuleSlots(slots) { ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => ` -
+
-
+ ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
@@ -309,13 +343,14 @@ export async function initSchedule() { function readRuleSlots(validate) { const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); return rows.map((row, index) => { - const subgroupValue = row.querySelector('.slot-subgroup')?.value || ''; + const subgroupIds = readSlotSubgroupIds(row); const slot = { id: null, dayOfWeek: Number(row.querySelector('.slot-day').value), parity: row.querySelector('.slot-parity').value, timeSlotId: Number(row.querySelector('.slot-time').value), - subgroupId: subgroupValue ? Number(subgroupValue) : null, + subgroupId: subgroupIds[0] || null, + subgroupIds, teacherId: Number(row.querySelector('.slot-teacher').value), classroomId: Number(row.querySelector('.slot-classroom').value), lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), @@ -370,18 +405,26 @@ export async function initSchedule() { function validateSlotSubgroup(slot, index) { const typeKey = lessonTypeKeyById(slot.lessonTypeId); - if (slot.subgroupId && typeKey !== 'laboratory') { + const subgroupIds = slotSubgroupIds(slot); + if (subgroupIds.length && typeKey !== 'laboratory') { throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`); } - if (!slot.subgroupId) return; + if (!subgroupIds.length) return; - const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId)); - if (!selectedSubgroup) { - throw new Error(`В слоте ${index + 1} подгруппа не найдена`); - } const selectedGroups = new Set(selectedRuleGroupIds().map(String)); - if (!selectedGroups.has(String(selectedSubgroup.groupId))) { - throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`); + const subgroupGroupIds = new Set(); + for (const subgroupId of subgroupIds) { + const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId)); + if (!selectedSubgroup) { + throw new Error(`В слоте ${index + 1} подгруппа не найдена`); + } + if (!selectedGroups.has(String(selectedSubgroup.groupId))) { + throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`); + } + if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) { + throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`); + } + subgroupGroupIds.add(String(selectedSubgroup.groupId)); } } @@ -417,22 +460,196 @@ export async function initSchedule() { if (!isLaboratorySlot(slot)) { return ''; } - const selected = slot.subgroupId == null ? '' : String(slot.subgroupId); - const selectedGroups = new Set(selectedRuleGroupIds().map(String)); + const selectedGroupIds = selectedRuleGroupIds(); + if (selectedGroupIds.length !== 1) { + return ''; + } + const selected = slotSubgroupIds(slot)[0] == null + ? WHOLE_GROUP_SUBGROUP_VALUE + : String(slotSubgroupIds(slot)[0]); + const selectedGroups = new Set(selectedGroupIds.map(String)); const matchingSubgroups = subgroups .filter(subgroup => selectedGroups.has(String(subgroup.groupId))) .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); - return '' + matchingSubgroups.map(subgroup => { + return `` + matchingSubgroups.map(subgroup => { const value = String(subgroup.id); return ``; }).join(''); } + function renderSubgroupField(slot, index) { + return selectedRuleGroupIds().length > 1 + ? renderSubgroupMultiSelect(slot, index) + : `
`; + } + + function renderSubgroupMultiSelect(slot, index) { + const selected = new Set(slotSubgroupIds(slot).map(String)); + const selectedGroups = new Set(selectedRuleGroupIds().map(String)); + const matchingSubgroups = subgroups + .filter(subgroup => selectedGroups.has(String(subgroup.groupId))) + .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); + const items = matchingSubgroups.length + ? matchingSubgroups.map(subgroup => { + const value = String(subgroup.id); + return ` + + `; + }).join('') + : '
Нет подгрупп выбранных групп
'; + + return ` +
+ +
+ + +
+
+ `; + } + + function readSlotSubgroupIds(row) { + const subgroupValue = row.querySelector('.slot-subgroup')?.value || ''; + const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked')) + .map(input => Number(input.value)) + .filter(Boolean); + return checkboxValues.length + ? checkboxValues + : (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []); + } + + function slotSubgroupIds(slot) { + if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) { + return slot.subgroupIds.map(Number).filter(Boolean); + } + return slot.subgroupId ? [Number(slot.subgroupId)] : []; + } + + function slotSubgroupNames(slot) { + if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) { + return slot.subgroupNames.filter(Boolean); + } + return slot.subgroupName ? [slot.subgroupName] : []; + } + + function slotSubgroupText(slot) { + const selectedIds = slotSubgroupIds(slot); + if (!selectedIds.length) return 'Все выбранные группы'; + if (selectedIds.length === 1) { + const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0])); + return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа'; + } + return `Выбрано подгрупп: ${selectedIds.length}`; + } + function selectedRuleGroupIds() { - return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) + return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked')) .map(input => Number(input.value)); } + function toggleRuleGroupsMenu(event) { + event.stopPropagation(); + const isOpen = ruleGroupsMenu.classList.contains('open'); + document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open')); + document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active')); + if (isOpen) { + closeRuleGroupsMenu(); + return; + } + ruleGroupsMenu.classList.add('open'); + ruleGroupsBox.classList.add('active'); + ruleGroupsBox.setAttribute('aria-expanded', 'true'); + } + + function handleRuleGroupsKeydown(event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleRuleGroupsMenu(event); + } + if (event.key === 'Escape') { + closeRuleGroupsMenu(); + } + } + + function closeRuleGroupsMenu() { + ruleGroupsMenu.classList.remove('open'); + ruleGroupsBox.classList.remove('active'); + ruleGroupsBox.setAttribute('aria-expanded', 'false'); + } + + function toggleSlotSubgroupMenu(event, box) { + event.stopPropagation(); + const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu'); + if (!menu) return; + const isOpen = menu.classList.contains('open'); + document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open')); + document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active')); + if (isOpen) { + box.setAttribute('aria-expanded', 'false'); + return; + } + menu.classList.add('open'); + box.classList.add('active'); + box.setAttribute('aria-expanded', 'true'); + } + + function closeSlotSubgroupMenus() { + ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open')); + ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => { + box.classList.remove('active'); + box.setAttribute('aria-expanded', 'false'); + }); + } + + function updateSlotSubgroupText(row) { + if (!row) return; + const text = row.querySelector('.slot-subgroup-box .select-text'); + if (!text) return; + const selectedIds = readSlotSubgroupIds(row); + if (!selectedIds.length) { + text.textContent = 'Все выбранные группы'; + return; + } + if (selectedIds.length === 1) { + const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0])); + text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа'; + return; + } + text.textContent = `Выбрано подгрупп: ${selectedIds.length}`; + } + + function updateRuleGroupsText() { + const selectedIds = selectedRuleGroupIds(); + if (!groups.length) { + ruleGroupsText.textContent = 'Нет групп'; + return; + } + if (!selectedIds.length) { + ruleGroupsText.textContent = 'Выберите группы...'; + return; + } + if (selectedIds.length === 1) { + const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0])); + ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа'; + return; + } + ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`; + } + function toTimeSlotOption(slot) { return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; } diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 86a5efd..fe8637d 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -61,7 +61,19 @@
-
+
+ + +
diff --git a/frontend/student/index.html b/frontend/student/index.html index 4b83cae..34219aa 100644 --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -416,13 +416,16 @@ function renderLesson(lesson) { const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : ''; + const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length + ? lesson.subgroupNames.join(', ') + : lesson.subgroupName; return `
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}
${escapeHtml(lesson.subjectName)}
${escapeHtml(lesson.lessonTypeName)} - ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} + ${subgroups ? `${escapeHtml(subgroups)}` : ''} ${escapeHtml(lesson.lessonFormat)} ${escapeHtml(lesson.teacherName)} ${escapeHtml(lesson.classroomName)} diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index f15428c..e06adab 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -431,13 +431,16 @@ function renderLesson(lesson) { const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : ''; + const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length + ? lesson.subgroupNames.join(', ') + : lesson.subgroupName; return `
${escapeHtml(lesson.subjectName)}
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))} ${escapeHtml(lesson.lessonTypeName)} - ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} + ${subgroups ? `${escapeHtml(subgroups)}` : ''} ${escapeHtml(lesson.classroomName)} ${groups ? `${escapeHtml(groups)}` : ''}
From 670e0a51ad6458b872aa185beee872608ea212b0 Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 15 May 2026 02:12:58 +0300 Subject: [PATCH 10/16] =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=80=D0=B5=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FRONTEND.md | 7 +- frontend/admin/css/auditorium-workload.css | 351 ++++++++++ .../admin/js/views/auditorium-workload.js | 651 +++++++++++++++++- frontend/admin/views/auditorium-workload.html | 53 +- 4 files changed, 1012 insertions(+), 50 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 31b8404..1439ce6 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -29,6 +29,7 @@ frontend/ │ │ ├── layout.css # Раскладка (sidebar, topbar, content) │ │ ├── components.css # Кнопки, таблицы, карточки, формы │ │ ├── modals.css # Модальные окна +│ │ ├── auditorium-workload.css # Таблицы загруженности аудиторий │ │ └── departments-data.css # Стили создания кафедры/специальности │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация @@ -45,6 +46,7 @@ frontend/ │ │ ├── profiles.js # Профили обучения специальностей │ │ ├── schedule.js # Конструктор правил расписания │ │ ├── academic-calendar.js # Календарные учебные графики +│ │ ├── auditorium-workload.js # Загруженность аудиторий │ │ ├── database.js # Управление тенантами │ │ └── departments-data.js # Создание кафедры/специальности │ ├── views/ # HTML-шаблоны представлений @@ -57,6 +59,7 @@ frontend/ │ │ ├── profiles.html │ │ ├── schedule.html │ │ ├── academic-calendar.html +│ │ ├── auditorium-workload.html │ │ ├── database.html │ │ └── departments-data.html │ │ @@ -114,14 +117,14 @@ frontend/ | `subjects` | Дисциплины | `/api/subjects` | | `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` | +| `auditorium-workload` | Динамическая загруженность аудиторий: сводная матрица по дате или совмещённая таблица выбранной аудитории по чётной/нечётной неделе | `/api/classrooms`, `/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. +- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. В поле «Отображение» можно выбрать конкретную аудиторию; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной аудитории растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index eb01efb..584aa8f 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -9,6 +9,45 @@ position: relative; } +.workload-filter-row { + flex-wrap: wrap; +} + +.workload-display-control { + flex: 1 1 260px; + min-width: 240px; +} + +.workload-display-select { + width: 100%; + min-height: 38px; + padding: 0.45rem 2.25rem 0.45rem 1rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font: inherit; + font-size: 0.85rem; + outline: none; + transition: background var(--transition), border-color var(--transition), box-shadow var(--transition); +} + +.workload-display-select:hover { + background: var(--bg-hover); +} + +.workload-display-select:focus { + background: var(--bg-input-focus); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.workload-overview-filter[hidden], +#workload-overview-block[hidden], +#room-workload-block[hidden] { + display: none !important; +} + .workload-table { width: 100%; border-collapse: collapse; @@ -219,6 +258,14 @@ font-size: 0.75rem; } +.lesson-subgroup { + color: var(--accent-hover); + font-size: 0.72rem; + font-weight: 600; + margin-bottom: 0.1rem; + line-height: 1.2; +} + .lesson-teacher { color: var(--text-secondary); font-size: 0.75rem; @@ -231,6 +278,292 @@ line-height: 1.2; } +.room-workload-block { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.room-workload-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin: -0.75rem 0 0.25rem; + color: var(--text-secondary); + font-size: 0.86rem; +} + +.room-summary-main { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.45rem; + margin-right: 0.25rem; +} + +.room-summary-main strong { + color: var(--text-primary); + font-size: 0.98rem; +} + +.room-summary-chip, +.room-summary-warning { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0.25rem 0.55rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + line-height: 1.15; +} + +.room-summary-warning { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 10%, var(--bg-input)); +} + +.room-workload-tables { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.room-week-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.room-week-heading h3 { + margin: 0; + color: var(--text-primary); + font-size: 1rem; + line-height: 1.2; +} + +.room-week-heading span { + color: var(--text-secondary); + font-size: 0.82rem; +} + +.room-week-panel { + display: flex; + flex-direction: column; + min-height: calc(100vh - 19rem); +} + +.room-grid-container { + flex: 1; + height: calc(100vh - 24rem); + min-height: 420px; + max-height: none; +} + +.room-week-table { + min-width: 1040px; +} + +.room-week-table th, +.room-week-table td { + min-width: 126px; + height: 172px; +} + +.room-week-table .room-time-heading, +.room-week-table .room-time-cell { + position: sticky; + left: 0; + min-width: 128px; + width: 128px; + background: var(--bg-input); + box-shadow: 1px 0 0 var(--bg-card-border); +} + +.room-week-table .room-time-heading { + z-index: 20; +} + +.room-week-table .room-time-cell { + z-index: 5; + color: var(--text-primary); + font-weight: 600; + vertical-align: middle; +} + +.room-week-table .room-time-cell strong, +.room-week-table .room-time-cell span, +.room-week-table .room-day-cell strong, +.room-week-table .room-day-cell span, +.room-week-table .room-day-cell small { + display: block; +} + +.room-week-table .room-time-cell strong { + font-size: 0.86rem; + line-height: 1.15; +} + +.room-week-table .room-time-cell span { + margin-top: 0.2rem; + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.15; +} + +.room-week-table .room-day-cell strong { + color: var(--text-primary); + font-size: 0.84rem; + line-height: 1.1; +} + +.room-week-table .room-day-cell > span { + margin-top: 0.15rem; + color: var(--text-secondary); + font-size: 0.76rem; + line-height: 1.1; +} + +.room-week-table .room-day-cell small { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.68rem; + line-height: 1.1; +} + +.room-day-parity-dates { + display: grid; + gap: 0.2rem; + margin-top: 0.45rem; +} + +.room-day-parity-dates span { + padding: 0.16rem 0.35rem; + border-radius: 999px; + background: var(--bg-input); + color: var(--text-secondary); + font-size: 0.68rem; + font-weight: 700; + line-height: 1.1; + white-space: nowrap; +} + +.room-combined-cell { + height: 1px; + padding: 0 !important; + background: var(--bg-primary); +} + +.room-single-cell { + padding: 0 !important; +} + +.room-week-unified { + min-height: 172px; + height: 100%; + padding: 0.45rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.25rem; +} + +.room-week-unified-busy { + justify-content: flex-start; + background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary)); +} + +.room-week-unified-busy > .lesson-card { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + margin-bottom: 0; +} + +.room-week-unified-free { + background: color-mix(in srgb, var(--success) 4%, var(--bg-primary)); +} + +.room-week-unified-missing, +.room-week-unified-empty { + background: color-mix(in srgb, var(--warning) 5%, var(--bg-primary)); +} + +.room-week-half { + position: relative; + min-height: 86px; + padding: 1.4rem 0.45rem 0.45rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.25rem; +} + +.room-week-half + .room-week-half { + border-top: 1px solid var(--bg-card-border); +} + +.room-week-half-busy { + justify-content: flex-start; + background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary)); +} + +.room-week-half-free { + background: color-mix(in srgb, var(--success) 4%, var(--bg-primary)); +} + +.room-week-half-missing, +.room-week-half-empty { + background: color-mix(in srgb, var(--warning) 5%, var(--bg-primary)); +} + +.room-half-marker { + position: absolute; + top: 0.35rem; + left: 0.45rem; + display: inline-flex; + align-items: center; + max-width: calc(100% - 0.9rem); + padding: 0.1rem 0.35rem; + border-radius: 999px; + background: var(--bg-input); + color: var(--text-secondary); + font-size: 0.64rem; + font-weight: 700; + line-height: 1.1; + white-space: nowrap; +} + +.room-week-half-odd .room-half-marker { + color: var(--accent-hover); +} + +.room-week-half-even .room-half-marker { + color: var(--success); +} + +.slot-unavailable { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 58px; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.room-workload-state { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + /* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; @@ -246,3 +579,21 @@ [data-theme="light"] .workload-grid-container::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); } + +@media (max-width: 768px) { + .workload-display-control { + min-width: 100%; + } + + .room-week-heading { + align-items: flex-start; + } + + .room-week-panel { + min-height: auto; + } + + .room-grid-container { + height: 520px; + } +} diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js index d7f12d2..a8b8446 100644 --- a/frontend/admin/js/views/auditorium-workload.js +++ b/frontend/admin/js/views/auditorium-workload.js @@ -2,6 +2,9 @@ import { api } from '../api.js'; import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js'; const NO_BUILDING = '__no_building__'; +const DISPLAY_ALL = 'all'; +const ROOM_PREFIX = 'room:'; +const MS_PER_DAY = 24 * 60 * 60 * 1000; const DAY_SHORT_LABELS = { 1: 'Пн', @@ -13,6 +16,23 @@ const DAY_SHORT_LABELS = { 7: 'Вс' }; +const DAY_FULL_LABELS = { + 1: 'Понедельник', + 2: 'Вторник', + 3: 'Среда', + 4: 'Четверг', + 5: 'Пятница', + 6: 'Суббота', + 7: 'Воскресенье' +}; + +const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]; + +const CELL_PARITY_LAYOUT = [ + { parity: 'ODD', label: 'Нечётная' }, + { parity: 'EVEN', label: 'Чётная' } +]; + const CAPACITY_BUCKETS = [ { id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 }, { id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 }, @@ -23,16 +43,26 @@ const CAPACITY_BUCKETS = [ export async function initAuditoriumWorkload() { const dateInput = document.getElementById('workload-date'); const refreshButton = document.getElementById('workload-refresh'); + const displaySelect = document.getElementById('workload-display-select'); const headerRow = document.getElementById('workload-header-row'); const tbody = document.getElementById('workload-tbody'); const summary = document.getElementById('workload-summary'); + const overviewBlock = document.getElementById('workload-overview-block'); + const roomBlock = document.getElementById('room-workload-block'); + const roomSummary = document.getElementById('room-workload-summary'); + const roomTables = document.getElementById('room-workload-tables'); + const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter')); + const dateLabel = dateInput?.closest('.form-group')?.querySelector('label'); let classrooms = []; let timeSlots = []; let equipments = []; let groups = []; + let semesters = []; let lessonsByRoomSlot = new Map(); let failedGroupLoads = 0; + let currentView = DISPLAY_ALL; + let selectedRoomId = null; setDefaultDate(); initFilters(); @@ -42,11 +72,7 @@ export async function initAuditoriumWorkload() { function setDefaultDate() { if (!dateInput || dateInput.value) return; const today = new Date(); - dateInput.value = [ - today.getFullYear(), - String(today.getMonth() + 1).padStart(2, '0'), - String(today.getDate()).padStart(2, '0') - ].join('-'); + dateInput.value = formatIsoDate(today); } function initFilters() { @@ -56,8 +82,13 @@ export async function initAuditoriumWorkload() { } function bindEvents() { - refreshButton?.addEventListener('click', loadScheduleForSelectedDate); - dateInput?.addEventListener('change', loadScheduleForSelectedDate); + refreshButton?.addEventListener('click', loadCurrentView); + dateInput?.addEventListener('change', loadCurrentView); + displaySelect?.addEventListener('change', () => { + applyDisplaySelection(); + syncDisplayMode(); + loadCurrentView(); + }); [ ['building-checkboxes', 'building-text', 'Все корпуса'], @@ -67,7 +98,9 @@ export async function initAuditoriumWorkload() { const container = document.getElementById(containerId); container?.addEventListener('change', () => { updateFilterText(containerId, textId, emptyText); - renderGrid(); + if (currentView === DISPLAY_ALL) { + renderGrid(); + } }); }); } @@ -75,16 +108,44 @@ export async function initAuditoriumWorkload() { async function loadInitialData() { setTableLoading('Загрузка аудиторий и расписания...'); try { - [classrooms, equipments, groups] = await Promise.all([ + const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears] = await Promise.all([ api.get('/api/classrooms'), api.get('/api/equipments'), - api.get('/api/groups') + api.get('/api/groups'), + api.get('/api/admin/calendar/years').catch(() => []) ]); - classrooms = [...classrooms].sort((a, b) => naturalCompare(a.name, b.name)); + classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name)); + equipments = loadedEquipments; + groups = loadedGroups; + semesters = (academicYears || []).flatMap(year => year.semesters || []); populateFilters(); - await loadScheduleForSelectedDate(); + populateDisplayOptions(); + applyDisplaySelection(); + syncDisplayMode(); + await loadCurrentView(); } catch (error) { - setTableError(error.message || 'Ошибка загрузки данных загруженности аудиторий'); + const message = error.message || 'Ошибка загрузки данных загруженности аудиторий'; + setTableError(message); + setRoomError(message); + } + } + + async function loadCurrentView() { + applyDisplaySelection(); + syncDisplayMode(); + try { + if (currentView === DISPLAY_ALL) { + await loadScheduleForSelectedDate(); + } else { + await loadRoomWorkload(); + } + } catch (error) { + const message = error.message || 'Ошибка загрузки загруженности аудиторий'; + if (currentView === DISPLAY_ALL) { + setTableError(message); + } else { + setRoomError(message); + } } } @@ -96,6 +157,7 @@ export async function initAuditoriumWorkload() { } hideAlert('workload-alert'); + failedGroupLoads = 0; setTableLoading('Загрузка расписания на выбранную дату...'); timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`); @@ -106,21 +168,15 @@ export async function initAuditoriumWorkload() { return; } - const params = group => new URLSearchParams({ - groupId: String(group.id), - startDate: date, - endDate: date - }).toString(); - const responses = await Promise.allSettled( - groups.map(group => api.get(`/api/schedule?${params(group)}`)) + groups.map(group => api.get(`/api/schedule?${scheduleParams(group, date, date)}`)) ); failedGroupLoads = responses.filter(response => response.status === 'rejected').length; const lessons = responses .filter(response => response.status === 'fulfilled') .flatMap(response => response.value || []); - lessonsByRoomSlot = groupLessons(lessons); + lessonsByRoomSlot = groupLessonsByRoomSlot(lessons); renderGrid(); if (failedGroupLoads > 0) { @@ -128,6 +184,105 @@ export async function initAuditoriumWorkload() { } } + async function loadRoomWorkload() { + const date = dateInput?.value; + const room = selectedRoom(); + if (!date) { + showAlert('workload-alert', 'Выберите дату', 'error'); + setRoomError('Выберите дату для расчёта двухнедельного периода'); + return; + } + if (!room) { + showAlert('workload-alert', 'Выберите аудиторию', 'error'); + setRoomError('Выберите аудиторию в поле «Отображение»'); + return; + } + + hideAlert('workload-alert'); + setRoomLoading('Загрузка расписания аудитории на две недели...'); + const range = twoWeekRange(date); + const dates = datesBetween(range.startDate, range.endDate); + const [slotLoad, lessonLoad] = await Promise.all([ + loadSlotsForDates(dates), + loadRoomLessons(range, room.id) + ]); + + renderRoomWorkload(room, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, { + failedSlotLoads: slotLoad.failedLoads, + failedGroupLoads: lessonLoad.failedLoads, + parityLessons: lessonLoad.parityLessons + }); + + const warnings = []; + if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`); + if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`); + if (warnings.length) { + showAlert('workload-alert', `Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`, 'error'); + } + } + + async function loadSlotsForDates(dates) { + const responses = await Promise.allSettled( + dates.map(date => api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`)) + ); + const slotsByDate = new Map(); + responses.forEach((response, index) => { + const slots = response.status === 'fulfilled' ? response.value || [] : []; + slotsByDate.set(dates[index], [...slots].sort(compareSlots)); + }); + return { + slotsByDate, + failedLoads: responses.filter(response => response.status === 'rejected').length + }; + } + + async function loadRoomLessons(range, roomId) { + if (!groups.length) { + return { + lessons: [], + lessonsByDateSlot: new Map(), + parityLessons: [], + failedLoads: 0 + }; + } + + const responses = await Promise.allSettled( + groups.map(group => api.get(`/api/schedule?${scheduleParams(group, range.startDate, range.endDate)}`)) + ); + const parityLessons = responses + .filter(response => response.status === 'fulfilled') + .flatMap(response => response.value || []); + const lessons = uniqueLessons( + parityLessons.filter(lesson => sameId(lesson.classroomId, roomId)) + ); + const lessonsByDateSlot = new Map(); + lessons.forEach(lesson => { + const slotKey = lessonSlotKey(lesson); + if (!lesson.date || slotKey == null) return; + const key = dateSlotKey(lesson.date, slotKey); + if (!lessonsByDateSlot.has(key)) lessonsByDateSlot.set(key, []); + lessonsByDateSlot.get(key).push(lesson); + }); + lessonsByDateSlot.forEach(items => { + items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName)); + }); + + return { + lessons, + lessonsByDateSlot, + parityLessons, + failedLoads: responses.filter(response => response.status === 'rejected').length + }; + } + + function scheduleParams(group, startDate, endDate) { + return new URLSearchParams({ + groupId: String(group.id), + startDate, + endDate + }).toString(); + } + function populateFilters() { renderCheckboxes( 'building-checkboxes', @@ -153,6 +308,49 @@ export async function initAuditoriumWorkload() { updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование'); } + function populateDisplayOptions() { + if (!displaySelect) return; + const selectedValue = displaySelect.value; + 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}`)); + }); + if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) { + displaySelect.value = selectedValue; + } + } + + function applyDisplaySelection() { + const value = displaySelect?.value || DISPLAY_ALL; + if (value.startsWith(ROOM_PREFIX)) { + currentView = 'room'; + selectedRoomId = value.slice(ROOM_PREFIX.length); + return; + } + currentView = DISPLAY_ALL; + selectedRoomId = null; + } + + function syncDisplayMode() { + const roomMode = currentView !== DISPLAY_ALL; + overviewFilters.forEach(filter => { + filter.hidden = roomMode; + }); + if (overviewBlock) overviewBlock.hidden = roomMode; + if (roomBlock) roomBlock.hidden = !roomMode; + if (dateLabel) { + dateLabel.textContent = roomMode ? 'Дата в периоде' : 'Дата'; + } + } + + function selectedRoom() { + if (!selectedRoomId) return null; + return classrooms.find(room => sameId(room.id, selectedRoomId)) || null; + } + function buildingOptions() { const values = new Map(); classrooms.forEach(room => { @@ -193,7 +391,7 @@ export async function initAuditoriumWorkload() { `).join(''); } - function groupLessons(lessons) { + function uniqueLessons(lessons) { const unique = new Map(); lessons.forEach(lesson => { if (!lesson.classroomId || !lesson.timeSlotId) return; @@ -209,17 +407,24 @@ export async function initAuditoriumWorkload() { if (existing) { existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames); existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds); + existing.subgroupNames = mergeNames(existing.subgroupNames, lesson.subgroupNames); + existing.subgroupIds = mergeIds(existing.subgroupIds, lesson.subgroupIds); return; } unique.set(key, { ...lesson, groupNames: [...(lesson.groupNames || [])], - groupIds: [...(lesson.groupIds || [])] + groupIds: [...(lesson.groupIds || [])], + subgroupNames: [...(lesson.subgroupNames || [])], + subgroupIds: [...(lesson.subgroupIds || [])] }); }); + return Array.from(unique.values()); + } + function groupLessonsByRoomSlot(lessons) { const grouped = new Map(); - unique.forEach(lesson => { + uniqueLessons(lessons).forEach(lesson => { const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(lesson); @@ -288,11 +493,298 @@ export async function initAuditoriumWorkload() { }); } + function renderRoomWorkload(room, 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); + + if (!slotRows.length) { + roomTables.innerHTML = '
За выбранный период нет временных слотов
'; + return; + } + + roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot); + } + + function renderRoomSummary(room, 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 warnings = []; + if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`); + if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`); + if (unknownParityDates) warnings.push('часть дат вне семестра'); + + roomSummary.innerHTML = ` +
+ ${escapeHtml(room.name)} + ${roomMeta ? `${escapeHtml(roomMeta)}` : ''} +
+ ${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))} + Занято: ${occupiedCells} из ${totalCells} (${percent}%) + ${warnings.length ? `${escapeHtml(warnings.join(', '))}` : ''} + `; + } + + function renderRoomCombinedWeekTable(datesByDay, slotRows, slotsByDate, lessonsByDateSlot) { + const dayHeaders = DAY_ORDER.map(day => ` + + ${escapeHtml(dayShort(day))} + ${escapeHtml(DAY_FULL_LABELS[day] || '')} +
+ ${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(datesByDay[day]?.[parity], label)).join('')} +
+ + `).join(''); + const rows = slotRows.map(row => ` + + + ${escapeHtml(row.orderLabel)} + ${escapeHtml(row.timeLabel)} + + ${DAY_ORDER.map(day => renderRoomCombinedSlotCell(datesByDay[day] || {}, row, slotsByDate, lessonsByDateSlot)).join('')} + + `).join(''); + + return ` +
+
+

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

+ ${escapeHtml(roomWeekCaption(datesByDay))} +
+
+ + + + + ${dayHeaders} + + + + ${rows} + +
Время
+
+
+ `; + } + + function renderDayParityDate(date, label) { + return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; + } + + function renderRoomCombinedSlotCell(datesByParity, row, slotsByDate, lessonsByDateSlot) { + const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ + parity, + label, + state: roomSlotState(datesByParity[parity], row, slotsByDate, lessonsByDateSlot) + })); + + if (roomSlotStatesEqual(states[0].state, states[1].state)) { + return `${renderRoomUnifiedSlot(states[0].state)}`; + } + + const halves = states.map(({ parity, label, state }) => + renderRoomSlotHalf(state, parity, label) + ).join(''); + return `${halves}`; + } + + function roomSlotState(date, row, slotsByDate, lessonsByDateSlot) { + if (!date) { + return { kind: 'empty', lessons: [] }; + } + + const key = dateSlotKey(date, row.key); + const lessons = lessonsByDateSlot.get(key) || []; + const slotExists = hasSlotForDate(date, row.key, slotsByDate) || lessons.length > 0; + if (lessons.length) { + return { kind: 'busy', lessons }; + } + if (!slotExists) { + return { kind: 'missing', lessons: [] }; + } + return { kind: 'free', lessons: [] }; + } + + function renderRoomUnifiedSlot(state) { + if (state.kind === 'busy') { + return `
${state.lessons.map(renderLessonCard).join('')}
`; + } + if (state.kind === 'missing') { + return '
Нет слота
'; + } + if (state.kind === 'empty') { + return '
Нет даты
'; + } + return '
Свободно
'; + } + + function renderRoomSlotHalf(state, parity, label) { + if (state.kind === 'busy') { + return ` +
+ ${escapeHtml(label)} + ${state.lessons.map(renderLessonCard).join('')} +
+ `; + } + if (state.kind === 'missing') { + return ` +
+ ${escapeHtml(label)} + Нет слота +
+ `; + } + if (state.kind === 'empty') { + return ` +
+ ${escapeHtml(label)} + Нет даты +
+ `; + } + return ` +
+ ${escapeHtml(label)} + Свободно +
+ `; + } + + function roomSlotStatesEqual(first, second) { + if (first.kind !== second.kind) return false; + if (first.kind !== 'busy') return true; + return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons); + } + + function lessonSetSignature(lessons) { + return lessons + .map(lessonContentSignature) + .sort((a, b) => naturalCompare(a, b)) + .join('|'); + } + + function lessonContentSignature(lesson) { + return [ + lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', + lesson.subjectId || lesson.subjectName || '', + lesson.teacherId || lesson.teacherName || '', + lesson.lessonTypeId || lesson.lessonTypeName || '', + lesson.lessonFormat || '', + valueListSignature(lesson.groupIds, lesson.groupNames), + valueListSignature(lesson.subgroupIds, lesson.subgroupNames) + ].join(':'); + } + + function valueListSignature(primary = [], fallback = []) { + const primaryList = Array.isArray(primary) ? primary : []; + const fallbackList = Array.isArray(fallback) ? fallback : []; + const source = primaryList.length ? primaryList : fallbackList; + return [...source] + .map(value => String(value)) + .sort((a, b) => naturalCompare(a, b)) + .join(','); + } + + function buildSlotRows(dates, slotsByDate, lessons) { + const rows = new Map(); + dates.forEach(date => { + (slotsByDate.get(date) || []).forEach(slot => { + const key = slotOrderKey(slot); + if (key == null) return; + putSlotRow(rows, key, slot.orderNumber, slot.startTime, slot.endTime); + }); + }); + lessons.forEach(lesson => { + const key = lessonSlotKey(lesson); + if (key == null) return; + putSlotRow(rows, key, lesson.timeSlotOrder, lesson.startTime, lesson.endTime); + }); + return Array.from(rows.values()).sort(compareSlotRows); + } + + function putSlotRow(rows, key, orderNumber, startTime, endTime) { + const existing = rows.get(key); + const next = { + key, + orderNumber, + startTime, + endTime, + orderLabel: orderNumber ? `${orderNumber} пара` : 'Пара', + timeLabel: startTime || endTime ? `${trimTime(startTime)}-${trimTime(endTime)}` : 'Время не указано' + }; + if (!existing || existing.timeLabel === 'Время не указано') { + rows.set(key, next); + } + } + + function buildDateGridByDay(dates, lessons) { + const datesByDay = {}; + let unknownParityDates = 0; + dates.forEach((date, index) => { + let parity = parityForDate(date, lessons); + if (!parity) { + unknownParityDates += 1; + parity = index < 7 ? 'ODD' : 'EVEN'; + } + const day = isoDay(date); + if (!datesByDay[day]) datesByDay[day] = {}; + datesByDay[day][parity] = date; + }); + return { datesByDay, unknownParityDates }; + } + + function parityForDate(date, lessons) { + const lessonParity = lessons.find(lesson => lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity))?.parity; + if (lessonParity) return lessonParity; + const weekNumber = weekNumberForDate(date); + if (!weekNumber) return null; + return weekNumber % 2 === 0 ? 'EVEN' : 'ODD'; + } + + function weekNumberForDate(date) { + const semester = semesters.find(item => item.startDate <= date && item.endDate >= date); + if (!semester) return null; + const days = Math.floor((parseIsoDate(date) - parseIsoDate(semester.startDate)) / MS_PER_DAY); + return Math.floor(days / 7) + 1; + } + + function uniqueWeekNumbers(dates) { + return Array.from(new Set(dates.map(weekNumberForDate).filter(Boolean))) + .sort((a, b) => a - b); + } + + function roomWeekCaption(datesByDay) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean); + const weeks = uniqueWeekNumbers(dates); + const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра'; + const positionText = parity === 'ODD' ? 'сверху' : 'снизу'; + return `${label} ${positionText}, ${weekText}`; + }) + .join('; '); + } + + function hasSlotForDate(date, key, slotsByDate) { + return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key); + } + function renderLessonCard(lesson) { + const subgroupText = (lesson.subgroupNames || []).join(', '); return `
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
+ ${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''}
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
${escapeHtml([lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}
@@ -349,6 +841,17 @@ export async function initAuditoriumWorkload() { showAlert('workload-alert', message, 'error'); } + function setRoomLoading(message) { + if (roomSummary) roomSummary.textContent = ''; + if (roomTables) roomTables.innerHTML = `
${escapeHtml(message)}
`; + } + + function setRoomError(message) { + if (roomSummary) roomSummary.textContent = ''; + if (roomTables) roomTables.innerHTML = `
${escapeHtml(message)}
`; + showAlert('workload-alert', message, 'error'); + } + function renderSummary(roomCount, occupiedCells, totalCells) { if (!summary) return; const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; @@ -364,10 +867,40 @@ export async function initAuditoriumWorkload() { ).length, 0); } + function countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot) { + return dates.reduce((count, date) => { + const keys = new Set((slotsByDate.get(date) || []).map(slotOrderKey).filter(key => key != null)); + lessonsByDateSlot.forEach((items, key) => { + if (items.length && key.startsWith(`${date}:`)) { + keys.add(key.slice(date.length + 1)); + } + }); + return count + keys.size; + }, 0); + } + + function countOccupiedRoomCells(lessonsByDateSlot) { + return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length; + } + function roomSlotKey(roomId, timeSlotId) { return `${roomId}:${timeSlotId}`; } + function dateSlotKey(date, slotKey) { + return `${date}:${slotKey}`; + } + + function slotOrderKey(slot) { + const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id; + return value == null ? null : String(value); + } + + function lessonSlotKey(lesson) { + const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId; + return value == null ? null : String(value); + } + function buildingKey(room) { return room.building ? String(room.building) : NO_BUILDING; } @@ -386,9 +919,7 @@ export async function initAuditoriumWorkload() { function selectedIsoDay() { if (!dateInput?.value) return null; - const date = new Date(`${dateInput.value}T00:00:00`); - const day = date.getDay(); - return day === 0 ? 7 : day; + return isoDay(dateInput.value); } function scopeShort(slot) { @@ -402,15 +933,81 @@ export async function initAuditoriumWorkload() { return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; } + function isoDay(value) { + const day = parseIsoDate(value).getDay(); + return day === 0 ? 7 : day; + } + + function twoWeekRange(value) { + const selected = parseIsoDate(value); + const start = addDays(selected, 1 - isoDay(value)); + const end = addDays(start, 13); + return { + startDate: formatIsoDate(start), + endDate: formatIsoDate(end) + }; + } + + function datesBetween(startDate, endDate) { + const dates = []; + for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) { + dates.push(formatIsoDate(date)); + } + return dates; + } + + function parseIsoDate(value) { + const [year, month, day] = String(value).split('-').map(Number); + return new Date(year, month - 1, day); + } + + function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; + } + function trimTime(value) { return value ? String(value).slice(0, 5) : ''; } + function formatIsoDate(date) { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); + } + function formatDate(value) { const [year, month, day] = String(value).split('-'); return `${day}.${month}.${year}`; } + function formatDateShort(value) { + const [, month, day] = String(value).split('-'); + return `${day}.${month}`; + } + + function compareSlots(a, b) { + return Number(a.orderNumber || 0) - Number(b.orderNumber || 0) + || naturalCompare(a.startTime, b.startTime); + } + + function compareSlotRows(a, b) { + const first = Number(a.orderNumber || a.key); + const second = Number(b.orderNumber || b.key); + if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) { + return first - second; + } + return naturalCompare(a.startTime, b.startTime) + || naturalCompare(a.key, b.key); + } + + function sameId(first, second) { + return String(first) === String(second); + } + function naturalCompare(a, b) { return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); } diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html index 38a3bda..ccf4f30 100644 --- a/frontend/admin/views/auditorium-workload.html +++ b/frontend/admin/views/auditorium-workload.html @@ -4,8 +4,15 @@
-
-
+
+
+ + +
+ +
@@ -20,7 +27,7 @@
-
+
@@ -35,7 +42,7 @@
-
+
@@ -57,23 +64,27 @@
-
- -
- - - - - - - - - - -
- Время - Аудитория -
+
+
+ +
+ + + + + + + +
+ Время + Аудитория +
+
+
+ +
From f010ffc4671bacc136540d31551eb4ec4f8d97c6 Mon Sep 17 00:00:00 2001 From: Zuev Date: Tue, 19 May 2026 15:02:07 +0300 Subject: [PATCH 11/16] =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md | 541 ++++++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md diff --git a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..66b5d9b --- /dev/null +++ b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,541 @@ +# План реализации задач после созвона с научным руководителем + +Дата: 2026-05-19 + +## 1. Короткий вывод + +В проекте уже есть основа для большей части требований: справочники, пользователи, кафедры, дисциплины, аудитории, динамическое расписание на базе `schedule_rules`, календарный учебный график, временные сетки и экран загруженности аудиторий. Главные недостающие архитектурные слои: + +- нормальная ролевая модель и серверная авторизация; +- темпоральность: история принадлежности, версионирование связей и запрет ретроактивного изменения прошлого расписания; +- мягкое удаление справочников вместо физического удаления; +- отдельные рабочие места для кафедры и учебного отдела; +- расширенные просмотры расписания и загруженности по преподавателям, аудиториям, кафедрам, парам и периодам. + +Рекомендуемый порядок: сначала ввести темпоральный слой, жизненный цикл записей и роли, затем уже строить новые кабинеты и редакторы. Если начать с экранов, старые связи будут продолжать теряться при удалениях, переносах преподавателей и правках расписания. + +## 2. Расшифровка задач руководителя в терминах системы + +| Формулировка | Что реализуем | +|-------------|---------------| +| Загруженность по паре или преподавателю | Аналитика занятости по временным слотам, преподавателям, аудиториям, кафедрам, дисциплинам и периодам. | +| Темпоральные базы данных | Не отдельная БД, а темпоральная модель внутри текущей PostgreSQL БД каждого тенанта: периоды действия записей, история связей, версионирование расписания. | +| Сделать удаление аудиторий и т.д., чтобы нельзя было использовать в дальнейшем | Мягкое удаление/архивация справочников. Исторические записи остаются, новые назначения на архивные сущности запрещены. | +| Перенос преподавателей с одной кафедры в другую без потери связи с прошлыми записями | История принадлежности преподавателя к кафедрам через интервалы действия, а не простое перезаписывание `users.department_id`. | +| Уровни пользователей | Расширение ролей и прав: администратор, кафедра, учебный отдел, преподаватель, студент. Проверки должны быть на backend, не только в UI. | +| Администратор: создание аудиторий, специальностей, кафедр и т.д. | Довести справочники до полного CRUD + архив/восстановление + фильтр архивных записей. | +| Кафедра: смотреть предметы кафедры, грузить, связывать предметы, разделять, комментарии, расписания и загруженность | Новый кабинет кафедры: дисциплины кафедры, импорт, описание дисциплин, привязки преподавателей и типов занятий, комментарии, расписание и нагрузка кафедры. | +| Учебный отдел: закреплять время и аудитории, редактировать расписание, закреплять предметы и преподавателей в аудитории в определённое время | Новый кабинет учебного отдела: редактор расписания, фиксация времени/аудиторий/преподавателей, точечные переносы и отмены, проверка конфликтов. | +| Просмотр расписания разных аудиторий, преподавателей, кафедр | Универсальный просмотр расписания с фильтрами по аудитории, преподавателю, группе, кафедре, дисциплине и периоду. | + +## 3. Текущее состояние проекта + +### Уже есть + +- 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`, но это простая текущая связь без периода действия, комментариев и разбиения по ответственности. + +### Основные проблемы текущей модели + +- Физическое удаление справочников опасно для истории расписания и отчётов. +- Обновление текущих связей перезаписывает контекст прошлого. +- Динамическое расписание пересчитывается из текущих правил, поэтому изменение правила может изменить отображение прошлого периода. +- Ролевая модель не отражает учебный отдел и кафедру как самостоятельных пользователей. +- Просмотр расписания и загруженности завязан на частные сценарии, а не на единый фильтруемый слой. + +## 4. Целевая архитектурная идея + +Ввести три базовых понятия: + +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 +``` + +Поля: + +- `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 + +Новые или расширенные сущности: + +- `subject_comments`; +- `subject_department_assignments` или темпоральные поля у `subjects`; +- версионированные `teacher_subjects`; +- `teacher_lesson_types` вывести в полноценный API, если таблица уже есть в схеме. + +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` или встроенные комментарии в карточках. + +## 11. Этап 7. Кабинет учебного отдела + +### Функции + +- Просмотр расписания по группам, аудиториям, преподавателям и кафедрам. +- Редактирование правил расписания. +- Точечный перенос пары. +- Отмена пары. +- Замена преподавателя. +- Замена аудитории. +- Закрепление времени, аудитории и преподавателя. +- Работа с конфликтами. +- Просмотр загруженности аудиторий и преподавателей. + +### Backend + +API: + +- `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` + +### Frontend + +Создать `/edu-office/` с вкладками: + +- `Расписание`; +- `Редактор`; +- `Конфликты`; +- `Загруженность аудиторий`; +- `Загруженность преподавателей`; +- `Закрепления`. + +Текущий админский конструктор расписания можно переиспользовать, но его нужно отделить от справочников и адаптировать под роль учебного отдела. + +## 12. Этап 8. Универсальный просмотр расписания + +Сейчас `GET /api/schedule` требует ровно `groupId` или `teacherId`. Для новых требований нужен отдельный эндпоинт или расширение текущего. + +Рекомендуется добавить новый эндпоинт: + +```http +GET /api/schedule/search +``` + +Фильтры: + +- `startDate`; +- `endDate`; +- `groupId`; +- `teacherId`; +- `classroomId`; +- `departmentId`; +- `subjectId`; +- `lessonTypeId`; +- `timeSlotId`; +- `parity`; +- `includeArchived`. + +Правило: можно передавать один или несколько фильтров. Если фильтров нет, доступ зависит от роли: + +- `ADMIN` и `EDUCATION_OFFICE` могут видеть всё; +- `DEPARTMENT` видит свою кафедру; +- `TEACHER` видит себя; +- `STUDENT` видит доступные группы или свою группу, когда появится привязка студента к группе. + +Форматы отображения: + +- недельная сетка; +- табличный список; +- вид по аудиториям; +- вид по преподавателям; +- вид по кафедрам. + +## 13. Этап 9. Загруженность по паре, преподавателю и кафедре + +### Показатели + +- занятость аудитории по дате и паре; +- занятость преподавателя по дате и паре; +- часы преподавателя за период; +- часы кафедры за период; +- распределение по дисциплинам; +- распределение по типам занятий; +- свободные аудитории на выбранную дату/пару; +- перегрузки и конфликты. + +### 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`. + +### Реализация + +На первом этапе считать загруженность из `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`; +- `docs/API.md`; +- `docs/BUSINESS_LOGIC.md`; +- `docs/FRONTEND.md`; +- `docs/ARCHITECTURE.md`, если меняются роли, авторизация или темпоральная модель. + +Для больших этапов лучше добавлять отдельные документы: + +- `docs/RBAC.md`; +- `docs/TEMPORAL_MODEL.md`; +- `docs/SCHEDULE_EDITING.md`; +- `docs/WORKLOAD_ANALYTICS.md`. + +## 16. Проверка и критерии готовности + +### Backend + +- `docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q clean compile` +- Прогон Flyway на чистой PostgreSQL БД. +- Проверка, что старые данные мигрировали в темпоральные таблицы. +- Тесты или ручные проверки: + - архивная аудитория не выбирается для будущей пары; + - старая пара с архивной аудиторией отображается; + - перевод преподавателя не меняет кафедру в отчёте за прошлый период; + - правка будущего правила не меняет прошлое расписание; + - конфликт преподавателя/аудитории блокируется или предупреждается. + +### Frontend + +- `node --check` для изменённых JS-файлов. +- `git diff --check`. +- Ручной smoke-test в браузере: + - вход под каждой ролью; + - видимость нужных вкладок; + - запрет доступа к чужим эндпоинтам; + - создание/архивирование/восстановление справочников; + - редактирование расписания учебным отделом; + - просмотр расписания и загруженности по разным фильтрам. + +## 17. Риски и вопросы, которые нужно уточнить + +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. + +Такой порядок закрывает базовую целостность данных до того, как появятся новые роли и экраны, которые начнут массово создавать и менять расписание. From 91729cff7d3be9076a9340bcfc90faabf1da2486 Mon Sep 17 00:00:00 2001 From: Zuev Date: Tue, 19 May 2026 23:10:56 +0300 Subject: [PATCH 12/16] =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=B5=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8E=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=B0=20+=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B6=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=B0=D1=84=D0=B5=D0=B4=D1=80=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=D0=BC=20+=20=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BC=D0=BE=D1=80=D1=82=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=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 @@ + + + + + + Учебный отдел + + + + + + From b1b85c3d175892e9ee62fb46cf598f14a39adee4 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 20 May 2026 01:29:07 +0300 Subject: [PATCH 13/16] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md | 9 +- docs/BUSINESS_LOGIC.md | 2 +- docs/FRONTEND.md | 8 +- frontend/admin/css/auditorium-workload.css | 7 +- frontend/admin/js/views/schedule-view.js | 455 +++++++++++++++++---- frontend/admin/views/schedule-view.html | 17 +- 6 files changed, 397 insertions(+), 101 deletions(-) diff --git a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md index ed632d6..68dfc2d 100644 --- a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md +++ b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md @@ -14,7 +14,7 @@ - добавлены точечные изменения расписания: перенос, отмена, замена; - добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности; - добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям; -- просмотр расписаний переведён с плоского списка на недельные матричные таблицы; +- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям; - вкладка загруженности стала общей для аудиторий, преподавателей и кафедр; - интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки; - отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`; @@ -150,8 +150,11 @@ frontend/admin/js/views/schedule-view.js Возможности: -- фильтры по периоду, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности; -- недельные матричные таблицы найденных занятий: строки — даты, столбцы — пары; +- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности; +- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели; +- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям; +- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели; +- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку; - карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота. ### Загруженность diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 8d729ea..e78a6be 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -9,7 +9,7 @@ | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | | **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | -| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в недельных матричных таблицах. | +| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в совмещённых таблицах чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 4e3c0b2..8ee0d29 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -137,7 +137,7 @@ frontend/ | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | -| `schedule-view` | Read-only просмотр расписаний: результат поиска выводится недельными матричными таблицами с датами по строкам и парами по столбцам | `/api/schedule/search` | +| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон и результат разбивается на отдельные совмещённые таблицы чётной/нечётной недели по группам, преподавателям или аудиториям | `/api/schedule/search` | | `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | @@ -147,7 +147,7 @@ frontend/ ### Особенности админских вкладок - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. -- Вкладка `schedule-view` показывает найденные занятия только в табличной матрице: период разбивается по учебным неделям, строки — даты, столбцы — пары, а внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. +- Вкладка `schedule-view` показывает найденные занятия только в табличной матрице. Пользователь выбирает одну дату в периоде; frontend сам запрашивает двухнедельный диапазон от понедельника выбранной недели, чтобы обе половины совмещённой таблицы были заполнены одним периодом. Чтобы ячейка вроде «понедельник, 1 пара» не смешивала занятия разных групп и аудиторий, результат разбивается на отдельные таблицы. Поле «Таблицы по» поддерживает авто-режим, группы, преподавателей и аудитории; авто-режим выбирает преподавателей при фильтре преподавателя, аудитории при фильтре аудитории, иначе группы. Каждая таблица строится двухнедельными блоками: строки — пары, столбцы — дни недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. @@ -246,7 +246,7 @@ export function isAuthenticatedAsAdmin() { Учебный отдел работает в общей админ-панели с ограниченным набором вкладок: -- расширенный поиск `/api/schedule/search` по периоду, преподавателю, аудитории и кафедре; +- расширенный поиск `/api/schedule/search` по дате в периоде, преподавателю, аудитории и кафедре; - конструктор расписания через `/api/admin/schedule-rules`; - создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API; - матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`; @@ -264,7 +264,7 @@ export function isAuthenticatedAsAdmin() { ### Просмотр расписаний (`/admin/#schedule-view`) -Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по периоду, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности. Найденные занятия отображаются недельными таблицами с датами по строкам и парами по столбцам. +Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам. ### Преподаватель (`/teacher/`) diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 31cfdd4..ed5fad2 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -592,12 +592,7 @@ .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; + height: 172px; } .schedule-view-lesson-card .lesson-meta + .lesson-meta { diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index 85d61ba..787075a 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -7,19 +7,45 @@ const PARITY_LABELS = { ODD: 'нечётная', EVEN: 'чётная' }; +const DAY_SHORT_LABELS = { + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + 7: 'Вс' +}; +const DAY_FULL_LABELS = { + 1: 'Понедельник', + 2: 'Вторник', + 3: 'Среда', + 4: 'Четверг', + 5: 'Пятница', + 6: 'Суббота', + 7: 'Воскресенье' +}; +const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]; +const CELL_PARITY_LAYOUT = [ + { parity: 'ODD', label: 'Нечётная' }, + { parity: 'EVEN', label: 'Чётная' } +]; +const SPLIT_AUTO = 'auto'; +const SPLIT_GROUPS = 'groups'; +const SPLIT_TEACHERS = 'teachers'; +const SPLIT_CLASSROOMS = 'classrooms'; 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 dateInput = document.getElementById('schedule-view-date'); 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); + setDefaultDate(dateInput); loadButton?.addEventListener('click', loadData); try { @@ -38,9 +64,17 @@ export async function initScheduleView() { setTableState('Загрузка...'); countLabel.textContent = 'Загрузка...'; + if (!dateInput.value) { + const message = 'Выберите дату в периоде'; + showAlert('schedule-view-alert', message, 'error'); + setTableState(message); + countLabel.textContent = '0 занятий'; + return; + } + const range = twoWeekRange(dateInput.value); const params = new URLSearchParams({ - startDate: startInput.value, - endDate: endInput.value + startDate: range.startDate, + endDate: range.endDate }); addOptional(params, 'groupId', valueOf('schedule-view-group')); addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); @@ -52,7 +86,7 @@ export async function initScheduleView() { try { const lessons = await api.get(`/api/schedule/search?${params}`); - renderLessons(lessons); + renderLessons(lessons, range); } catch (error) { const message = error.message || 'Ошибка загрузки расписания'; showAlert('schedule-view-alert', message, 'error'); @@ -61,46 +95,72 @@ export async function initScheduleView() { } } - function renderLessons(lessons) { + function renderLessons(lessons, range) { 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)) + const sections = buildScheduleSections(lessons); + if (!sections.length) { + setTableState('Нет данных для выбранного разреза таблиц'); + return; + } + const tableCount = sections.reduce((count, section) => + count + buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons).length, 0 + ); + if (!tableCount) { + setTableState('Выберите корректный период'); + return; + } + countLabel.textContent = `${lessonCountLabel(lessons.length)}, ${tableCountLabel(tableCount)}`; + tables.innerHTML = sections + .map(section => renderScheduleSection(section, range)) .join(''); } - function renderWeekTable(week, slots, lessonsByDateSlot) { - const rows = week.dates.map(date => ` + function renderScheduleSection(section, range) { + const slots = collectSlots(section.lessons); + const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); + const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); + return blocks + .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) + .join(''); + } + + function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { + const dayHeaders = DAY_ORDER.map(day => ` + + ${escapeHtml(dayShort(day))} + ${escapeHtml(DAY_FULL_LABELS[day] || '')} +
+ ${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')} +
+ + `).join(''); + const rows = slots.map(slot => ` - - ${escapeHtml(dayTitle(date, lessonsByDateSlot))} - ${escapeHtml(formatDate(date))} + + ${escapeHtml(slot.orderLabel)} + ${escapeHtml(slot.timeLabel)} + ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} - ${slots.map(slot => renderSlotCell(date, slot, lessonsByDateSlot)).join('')} + ${DAY_ORDER.map(day => renderCombinedSlotCell(block.datesByDay[day] || {}, slot, lessonsByDateSlot)).join('')} `).join(''); return `
-

${escapeHtml(week.label)}

- ${escapeHtml(weekCaption(week.dates))} +

${escapeHtml(section.title)} · ${escapeHtml(block.label)}

+ ${escapeHtml(`${lessonCountLabel(section.lessons.length)}; ${combinedWeekCaption(block)}`)}
- +
- - ${slots.map(renderSlotHeader).join('')} + + ${dayHeaders} ${rows} @@ -110,23 +170,107 @@ export async function initScheduleView() { `; } - function renderSlotHeader(slot) { + function renderDayParityDate(date, label) { + return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; + } + + function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { + const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ + parity, + label, + state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) + })); + + if (slotStatesEqual(states[0].state, states[1].state)) { + return ``; + } + + const halves = states.map(({ parity, label, state }) => + renderSlotHalf(state, parity, label) + ).join(''); + return ``; + } + + function scheduleSlotState(date, slot, lessonsByDateSlot) { + if (!date) { + return { kind: 'empty', lessons: [] }; + } + const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || []; + if (lessons.length) { + return { kind: 'busy', lessons }; + } + return { kind: 'free', lessons: [] }; + } + + function renderUnifiedSlot(state) { + if (state.kind === 'busy') { + return `
${state.lessons.map(renderLessonCard).join('')}
`; + } + if (state.kind === 'empty') { + return '
Нет даты
'; + } + return '
Нет занятий
'; + } + + function renderSlotHalf(state, parity, label) { + if (state.kind === 'busy') { + return ` +
+ ${escapeHtml(label)} + ${state.lessons.map(renderLessonCard).join('')} +
+ `; + } + if (state.kind === 'empty') { + return ` +
+ ${escapeHtml(label)} + Нет даты +
+ `; + } return ` - +
+ ${escapeHtml(label)} + Нет занятий +
`; } - function renderSlotCell(date, slot, lessonsByDateSlot) { - const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || []; - return ` - - `; + function slotStatesEqual(first, second) { + if (first.kind !== second.kind) return false; + if (first.kind !== 'busy') return true; + return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons); + } + + function lessonSetSignature(lessons) { + return lessons + .map(lessonContentSignature) + .sort((a, b) => naturalCompare(a, b)) + .join('|'); + } + + function lessonContentSignature(lesson) { + return [ + lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', + lesson.subjectId || lesson.subjectName || '', + lesson.teacherId || lesson.teacherName || '', + lesson.classroomId || lesson.classroomName || '', + lesson.lessonTypeId || lesson.lessonTypeName || '', + lesson.lessonFormat || '', + valueListSignature(lesson.groupIds, lesson.groupNames), + valueListSignature(lesson.subgroupIds, lesson.subgroupNames) + ].join(':'); + } + + function valueListSignature(primary = [], fallback = []) { + const primaryList = Array.isArray(primary) ? primary : []; + const fallbackList = Array.isArray(fallback) ? fallback : []; + const source = primaryList.length ? primaryList : fallbackList; + return [...source] + .map(value => String(value)) + .sort((a, b) => naturalCompare(a, b)) + .join(','); } function renderLessonCard(lesson) { @@ -178,12 +322,9 @@ async function loadDictionaries(role, userDepartmentId) { } } -function setDefaultDates(startInput, endInput) { +function setDefaultDate(dateInput) { const today = new Date(); - const nextWeek = new Date(); - nextWeek.setDate(today.getDate() + 7); - startInput.value = toIsoDate(today); - endInput.value = toIsoDate(nextWeek); + dateInput.value = toIsoDate(today); } function fillSelect(id, items, valueFn, labelFn, emptyLabel) { @@ -205,14 +346,6 @@ 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 => { @@ -252,35 +385,159 @@ function groupLessonsByDateSlot(lessons) { 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); +function buildScheduleSections(lessons) { + const splitType = resolveSplitType(); + const sections = new Map(); + + lessons.forEach(lesson => { + splitEntitiesForLesson(lesson, splitType).forEach(entity => { + if (!sections.has(entity.key)) { + sections.set(entity.key, { + key: entity.key, + title: entity.title, + lessons: [] + }); + } + sections.get(entity.key).lessons.push(lesson); }); - return Array.from(groups.entries()).map(([startDate, weekDates]) => ({ - startDate, - dates: weekDates, - label: `Неделя ${formatDate(startDate)} - ${formatDate(addDaysToIso(startDate, 6))}` - })); + }); + + return Array.from(sections.values()) + .map(section => ({ + ...section, + lessons: sortLessons(section.lessons) + })) + .sort((a, b) => naturalCompare(a.title, b.title)); } -function dayTitle(date, lessonsByDateSlot) { - for (const [key, lessons] of lessonsByDateSlot.entries()) { - if (key.startsWith(`${date}:`) && lessons[0]?.dayName) { - return lessons[0].dayName; - } +function resolveSplitType() { + const requested = valueOf('schedule-view-split'); + if ([SPLIT_GROUPS, SPLIT_TEACHERS, SPLIT_CLASSROOMS].includes(requested)) { + return requested; } - return dayName(date); + if (valueOf('schedule-view-teacher')) return SPLIT_TEACHERS; + if (valueOf('schedule-view-classroom')) return SPLIT_CLASSROOMS; + return SPLIT_GROUPS; } -function weekCaption(dates) { - const first = dates[0]; - const last = dates[dates.length - 1]; - return `${formatDate(first)} - ${formatDate(last)}`; +function splitEntitiesForLesson(lesson, splitType) { + if (splitType === SPLIT_TEACHERS) { + return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; + } + if (splitType === SPLIT_CLASSROOMS) { + return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; + } + return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана'); +} + +function singleEntity(prefix, id, name, fallback) { + const title = name || fallback; + return { + key: `${prefix}:${id ?? title}`, + title + }; +} + +function listEntities(prefix, ids = [], names = [], fallback) { + const safeIds = Array.isArray(ids) ? ids : []; + const safeNames = Array.isArray(names) ? names : []; + if (!safeIds.length && !safeNames.length) { + return [singleEntity(prefix, null, fallback, fallback)]; + } + const maxLength = Math.max(safeIds.length, safeNames.length); + const entities = []; + for (let index = 0; index < maxLength; index += 1) { + const id = safeIds[index] ?? null; + const name = safeNames[index] || (id == null ? fallback : `Группа ${id}`); + entities.push(singleEntity(prefix, id, name, fallback)); + } + return entities; +} + +function sortLessons(lessons) { + return [...lessons].sort((a, b) => + naturalCompare(a.date, b.date) + || compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b)) + || naturalCompare(a.subjectName, b.subjectName) + || naturalCompare(a.teacherName, b.teacherName) + || naturalCompare(a.classroomName, b.classroomName) + ); +} + +function lessonSlotDescriptor(lesson) { + return { + key: lessonSlotKey(lesson), + orderNumber: lesson.timeSlotOrder, + startTime: lesson.startTime + }; +} + +function buildCombinedWeekBlocks(startDate, endDate, lessons) { + const dates = datesBetween(startDate, endDate); + const weekGroups = new Map(); + dates.forEach(date => { + const key = weekStart(date); + if (!weekGroups.has(key)) weekGroups.set(key, []); + weekGroups.get(key).push(date); + }); + + const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b)); + const blocks = []; + for (let index = 0; index < weekStarts.length; index += 2) { + const firstWeek = weekGroups.get(weekStarts[index]) || []; + const secondWeek = weekGroups.get(weekStarts[index + 1]) || []; + const blockDates = [...firstWeek, ...secondWeek]; + const firstParity = weekParity(firstWeek, lessons) || 'ODD'; + const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD'); + const datesWithFallback = [ + ...firstWeek.map(date => ({ date, fallbackParity: firstParity })), + ...secondWeek.map(date => ({ date, fallbackParity: secondParity })) + ]; + blocks.push({ + startDate: blockDates[0], + endDate: blockDates[blockDates.length - 1], + datesByDay: buildDatesByDay(datesWithFallback, lessons), + label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}` + }); + } + return blocks; +} + +function weekParity(dates, lessons) { + for (const date of dates) { + const parity = parityForDate(date, lessons); + if (parity) return parity; + } + return null; +} + +function buildDatesByDay(datesWithFallback, lessons) { + const datesByDay = {}; + datesWithFallback.forEach(({ date, fallbackParity }) => { + const day = isoDay(date); + const parity = parityForDate(date, lessons) || fallbackParity; + if (!datesByDay[day]) datesByDay[day] = {}; + datesByDay[day][parity] = date; + }); + return datesByDay; +} + +function parityForDate(date, lessons) { + const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity)); + if (lesson?.parity) return lesson.parity; + const withWeek = lessons.find(item => item.date === date && item.weekNumber != null); + if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD'; + return null; +} + +function combinedWeekCaption(block) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const dates = DAY_ORDER.map(day => block.datesByDay[day]?.[parity]).filter(Boolean); + if (!dates.length) return `${label}: дат нет`; + return `${label}: ${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`; + }) + .join('; '); } function lessonSlotKey(lesson) { @@ -299,10 +556,20 @@ function weekStart(value) { return toIsoDate(date); } -function addDaysToIso(value, days) { - const date = parseIsoDate(value); - date.setDate(date.getDate() + days); - return toIsoDate(date); +function twoWeekRange(value) { + const selected = parseIsoDate(value); + const start = addDays(selected, 1 - isoDay(value)); + const end = addDays(start, 13); + return { + startDate: toIsoDate(start), + endDate: toIsoDate(end) + }; +} + +function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; } function parseIsoDate(value) { @@ -310,8 +577,21 @@ function parseIsoDate(value) { return new Date(year, month - 1, day); } -function dayName(value) { - return parseIsoDate(value).toLocaleDateString('ru-RU', { weekday: 'long' }); +function datesBetween(startDate, endDate) { + const dates = []; + for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) { + dates.push(toIsoDate(date)); + } + return dates; +} + +function isoDay(value) { + const day = parseIsoDate(value).getDay(); + return day === 0 ? 7 : day; +} + +function dayShort(dayOfWeek) { + return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; } function formatDate(value) { @@ -320,6 +600,11 @@ function formatDate(value) { return `${day}.${month}.${year}`; } +function formatDateShort(value) { + const [, month, day] = String(value).split('-'); + return `${day}.${month}`; +} + function trimTime(value) { return String(value || '').slice(0, 5); } @@ -340,6 +625,14 @@ function lessonCountLabel(count) { return `${count} занятий`; } +function tableCountLabel(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); diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html index 2c93be6..4cec711 100644 --- a/frontend/admin/views/schedule-view.html +++ b/frontend/admin/views/schedule-view.html @@ -6,12 +6,8 @@
- - -
-
- - + +
@@ -58,6 +54,15 @@
+
+ + +
From f49fa95d5a6f5f3915d539637007541cb77ba172 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 20 May 2026 15:02:43 +0300 Subject: [PATCH 14/16] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BUSINESS_LOGIC.md | 2 +- docs/FRONTEND.md | 4 +- frontend/admin/css/auditorium-workload.css | 317 +++++++++++- frontend/admin/js/views/schedule-view.js | 531 ++++++++++++++++++--- frontend/admin/views/schedule-view.html | 189 +++++--- 5 files changed, 887 insertions(+), 156 deletions(-) diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index e78a6be..2fa695c 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -9,7 +9,7 @@ | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | | **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | -| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в совмещённых таблицах чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям. | +| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 8ee0d29..c5f5027 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -137,7 +137,7 @@ frontend/ | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | -| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон и результат разбивается на отдельные совмещённые таблицы чётной/нечётной недели по группам, преподавателям или аудиториям | `/api/schedule/search` | +| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | @@ -147,7 +147,7 @@ frontend/ ### Особенности админских вкладок - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. -- Вкладка `schedule-view` показывает найденные занятия только в табличной матрице. Пользователь выбирает одну дату в периоде; frontend сам запрашивает двухнедельный диапазон от понедельника выбранной недели, чтобы обе половины совмещённой таблицы были заполнены одним периодом. Чтобы ячейка вроде «понедельник, 1 пара» не смешивала занятия разных групп и аудиторий, результат разбивается на отдельные таблицы. Поле «Таблицы по» поддерживает авто-режим, группы, преподавателей и аудитории; авто-режим выбирает преподавателей при фильтре преподавателя, аудитории при фильтре аудитории, иначе группы. Каждая таблица строится двухнедельными блоками: строки — пары, столбцы — дни недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. +- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index ed5fad2..9722ab7 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -569,19 +569,205 @@ background: var(--bg-input); } -.schedule-view-table-stack { - display: flex; - flex-direction: column; +.schedule-view-shell { + min-height: calc(100vh - 8.5rem); + display: grid; + grid-template-rows: auto auto minmax(420px, 1fr); gap: 1rem; } +.schedule-view-control-card, +.schedule-view-results-card, +.schedule-view-table-card { + animation-duration: 0.28s; +} + +.schedule-view-control-header, +.schedule-view-results-header, +.schedule-view-table-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.schedule-view-control-header h2, +.schedule-view-results-header h2, +.schedule-view-table-header h2 { + margin-bottom: 0.3rem; +} + +.schedule-view-control-grid, +.schedule-view-extra-grid { + display: grid; + gap: 1rem; + align-items: end; +} + +.schedule-view-control-grid { + grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr) minmax(210px, 0.9fr); + margin-top: 1rem; +} + +.schedule-view-extra-grid { + grid-template-columns: repeat(3, minmax(180px, 1fr)); + margin-top: 0.9rem; +} + +.schedule-view-period-control { + min-width: 0; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 500; +} + +.schedule-view-period-buttons, +.schedule-view-result-actions, +.schedule-view-day-tabs { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.schedule-view-shell .btn-secondary { + min-height: 42px; + padding: 0.65rem 0.9rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition); +} + +.schedule-view-shell .btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--accent-hover); + transform: translateY(-1px); +} + +.schedule-view-shell .btn-secondary:disabled { + cursor: default; + opacity: 0.45; +} + +.schedule-view-extra-filters { + margin-top: 0.9rem; + border-top: 1px solid var(--bg-card-border); + padding-top: 0.85rem; +} + +.schedule-view-extra-filters summary { + width: fit-content; + color: var(--text-secondary); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; +} + +.schedule-view-result-actions { + min-height: 42px; + justify-content: flex-end; + color: var(--text-secondary); + font-size: 0.88rem; + text-align: right; +} + +.schedule-view-result-tabs { + display: flex; + gap: 0.55rem; + margin-top: 1rem; + padding-bottom: 0.1rem; + overflow-x: auto; +} + +.schedule-view-result-tab { + flex: 0 0 auto; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 0.18rem; + min-width: 150px; + max-width: 240px; + padding: 0.65rem 0.85rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), transform var(--transition); +} + +.schedule-view-result-tab span, +.schedule-view-result-tab small { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-view-result-tab small { + color: var(--text-secondary); + font-size: 0.72rem; +} + +.schedule-view-result-tab:hover, +.schedule-view-result-tab.active { + background: color-mix(in srgb, var(--accent) 12%, var(--bg-input)); + border-color: var(--accent); + transform: translateY(-1px); +} + +.schedule-view-empty-result { + display: inline-flex; + align-items: center; + min-height: 38px; + color: var(--text-secondary); + font-size: 0.88rem; +} + +.schedule-view-table-card { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.schedule-view-table-header { + flex: 0 0 auto; + margin-bottom: 1rem; +} + +.schedule-view-table-stage { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + .schedule-view-week-panel { + flex: 1 1 auto; + min-height: 0; display: flex; flex-direction: column; gap: 0; } .schedule-view-grid-container { + flex: 1 1 auto; + min-height: 0; + height: 100%; max-height: none; } @@ -600,11 +786,80 @@ } .schedule-view-state { + min-height: 100%; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-input); } +.schedule-view-day-tabs { + flex-wrap: wrap; + justify-content: flex-end; +} + +.schedule-view-day-tab { + min-width: 42px; + min-height: 34px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + font: inherit; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.schedule-view-day-tab.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 15%, var(--bg-input)); + color: var(--text-primary); +} + +.schedule-view-mobile-day { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.schedule-view-mobile-day-heading, +.schedule-view-mobile-slot { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.schedule-view-mobile-day-heading { + padding: 0.8rem 0.9rem; +} + +.schedule-view-mobile-day-heading strong, +.schedule-view-mobile-day-heading span, +.schedule-view-mobile-slot-time strong, +.schedule-view-mobile-slot-time span { + display: block; +} + +.schedule-view-mobile-day-heading span, +.schedule-view-mobile-slot-time span { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.schedule-view-mobile-slot { + overflow: hidden; +} + +.schedule-view-mobile-slot-time { + padding: 0.7rem 0.85rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.schedule-view-mobile-slot-content .room-week-unified { + min-height: 124px; +} + /* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; @@ -621,6 +876,12 @@ background: rgba(0, 0, 0, 0.15); } +@media (max-width: 1180px) { + .schedule-view-control-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 768px) { .workload-target-control, .workload-display-control { @@ -638,4 +899,54 @@ .room-grid-container { height: 520px; } + + .schedule-view-shell { + min-height: auto; + grid-template-rows: auto; + } + + .schedule-view-control-header, + .schedule-view-results-header, + .schedule-view-table-header { + display: grid; + grid-template-columns: 1fr; + } + + .schedule-view-control-grid, + .schedule-view-extra-grid { + grid-template-columns: 1fr; + } + + .schedule-view-period-buttons, + .schedule-view-result-actions, + .schedule-view-day-tabs { + width: 100%; + } + + .schedule-view-period-buttons .btn-secondary, + .schedule-view-result-actions .btn-secondary { + flex: 1; + } + + .schedule-view-result-actions { + justify-content: stretch; + text-align: center; + } + + .schedule-view-result-actions span { + flex: 2; + align-self: center; + } + + .schedule-view-result-tab { + min-width: 72%; + } + + .schedule-view-table-card { + min-height: 520px; + } + + .schedule-view-day-tabs { + justify-content: flex-start; + } } diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index 787075a..6f64179 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -2,6 +2,10 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; const ROLE_DEPARTMENT = 'DEPARTMENT'; +const TARGET_GROUPS = 'groups'; +const TARGET_TEACHERS = 'teachers'; +const TARGET_CLASSROOMS = 'classrooms'; +const TARGET_DEPARTMENTS = 'departments'; const PARITY_LABELS = { BOTH: 'каждая', ODD: 'нечётная', @@ -30,104 +34,418 @@ const CELL_PARITY_LAYOUT = [ { parity: 'ODD', label: 'Нечётная' }, { parity: 'EVEN', label: 'Чётная' } ]; -const SPLIT_AUTO = 'auto'; -const SPLIT_GROUPS = 'groups'; -const SPLIT_TEACHERS = 'teachers'; -const SPLIT_CLASSROOMS = 'classrooms'; +const MOBILE_QUERY = '(max-width: 760px)'; export async function initScheduleView() { const role = localStorage.getItem('role'); - const userDepartmentId = localStorage.getItem('departmentId'); + const userDepartmentId = normalizeId(localStorage.getItem('departmentId')); const dateInput = document.getElementById('schedule-view-date'); + const targetSelect = document.getElementById('schedule-view-target'); const loadButton = document.getElementById('schedule-view-load'); - const departmentField = document.getElementById('schedule-view-department-field'); - const departmentSelect = document.getElementById('schedule-view-department'); - const tables = document.getElementById('schedule-view-tables'); + const previousPeriodButton = document.getElementById('schedule-view-prev-period'); + const todayButton = document.getElementById('schedule-view-today'); + const nextPeriodButton = document.getElementById('schedule-view-next-period'); + const resultPreviousButton = document.getElementById('schedule-view-result-prev'); + const resultNextButton = document.getElementById('schedule-view-result-next'); + const resultTabs = document.getElementById('schedule-view-result-tabs'); + const activeLabel = document.getElementById('schedule-view-active-label'); const countLabel = document.getElementById('schedule-view-count'); + const periodLabel = document.getElementById('schedule-view-period-label'); + const tableTitle = document.getElementById('schedule-view-table-title'); + const tableCaption = document.getElementById('schedule-view-table-caption'); + const dayTabs = document.getElementById('schedule-view-day-tabs'); + const tables = document.getElementById('schedule-view-tables'); + const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]')); + const departmentSelect = document.getElementById('schedule-view-department'); + + const state = { + dictionaries: { + groups: [], + teachers: [], + classrooms: [], + departments: [] + }, + sections: [], + activeSectionIndex: 0, + mobileDay: 1, + range: null, + totalLessons: 0 + }; + const mobileMedia = window.matchMedia(MOBILE_QUERY); setDefaultDate(dateInput); + configureDepartmentTarget(); + updatePeriodLabel(); + updateEntityFieldVisibility(); + renderResultNavigation(); + + targetSelect?.addEventListener('change', () => { + clearInactiveEntityValues(); + updateEntityFieldVisibility(); + resetResults('Нажмите «Показать», чтобы обновить расписание'); + }); loadButton?.addEventListener('click', loadData); + previousPeriodButton?.addEventListener('click', () => movePeriod(-1)); + todayButton?.addEventListener('click', () => { + dateInput.value = toIsoDate(new Date()); + loadData(); + }); + nextPeriodButton?.addEventListener('click', () => movePeriod(1)); + dateInput?.addEventListener('change', () => { + updatePeriodLabel(); + resetResults('Нажмите «Показать», чтобы обновить расписание'); + }); + resultPreviousButton?.addEventListener('click', () => moveResult(-1)); + resultNextButton?.addEventListener('click', () => moveResult(1)); + if (typeof mobileMedia.addEventListener === 'function') { + mobileMedia.addEventListener('change', renderActiveSection); + } else if (typeof mobileMedia.addListener === 'function') { + mobileMedia.addListener(renderActiveSection); + } try { - await loadDictionaries(role, userDepartmentId); - if (role === ROLE_DEPARTMENT) { - departmentField.hidden = true; - departmentSelect.value = userDepartmentId || ''; - } + state.dictionaries = await loadDictionaries(role, userDepartmentId); + configureDepartmentAccess(); + updateEntityFieldVisibility(); await loadData(); } catch (error) { - setTableState(error.message || 'Ошибка загрузки справочников'); + const message = error.message || 'Ошибка загрузки справочников'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + } + + function configureDepartmentTarget() { + if (role !== ROLE_DEPARTMENT) return; + targetSelect.value = TARGET_DEPARTMENTS; + targetSelect.disabled = true; + } + + function configureDepartmentAccess() { + if (role !== ROLE_DEPARTMENT) return; + targetSelect.value = TARGET_DEPARTMENTS; + targetSelect.disabled = true; + if (departmentSelect) { + departmentSelect.value = userDepartmentId || ''; + departmentSelect.disabled = true; + if (userDepartmentId && departmentSelect.value !== userDepartmentId) { + departmentSelect.insertAdjacentHTML('afterbegin', + ``); + departmentSelect.value = userDepartmentId; + } + } + } + + function updateEntityFieldVisibility() { + const target = activeTarget(); + entityFields.forEach(field => { + field.hidden = field.dataset.scheduleViewTargetField !== target; + }); + } + + function clearInactiveEntityValues() { + const target = activeTarget(); + [ + [TARGET_GROUPS, 'schedule-view-group'], + [TARGET_TEACHERS, 'schedule-view-teacher'], + [TARGET_CLASSROOMS, 'schedule-view-classroom'], + [TARGET_DEPARTMENTS, 'schedule-view-department'] + ].forEach(([fieldTarget, id]) => { + if (fieldTarget === target) return; + if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return; + const select = document.getElementById(id); + if (select) select.value = ''; + }); + } + + function activeTarget() { + if (role === ROLE_DEPARTMENT) return TARGET_DEPARTMENTS; + return targetSelect?.value || TARGET_GROUPS; + } + + async function movePeriod(direction) { + if (!dateInput.value) { + dateInput.value = toIsoDate(new Date()); + } + const range = twoWeekRange(dateInput.value); + const nextDate = addDays(parseIsoDate(range.startDate), direction * 14); + dateInput.value = toIsoDate(nextDate); + await loadData(); } async function loadData() { hideAlert('schedule-view-alert'); - setTableState('Загрузка...'); - countLabel.textContent = 'Загрузка...'; if (!dateInput.value) { const message = 'Выберите дату в периоде'; showAlert('schedule-view-alert', message, 'error'); - setTableState(message); - countLabel.textContent = '0 занятий'; + resetResults(message); return; } + + if (role === ROLE_DEPARTMENT && !userDepartmentId) { + const message = 'Не удалось определить кафедру пользователя'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + return; + } + + const target = activeTarget(); const range = twoWeekRange(dateInput.value); + const previousKey = state.sections[state.activeSectionIndex]?.key; + state.range = range; + updatePeriodLabel(range); + setTableState('Загрузка...'); + setActiveSummary('Загрузка расписаний...'); + countLabel.textContent = 'Загрузка...'; + resultTabs.innerHTML = ''; + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + const params = new URLSearchParams({ startDate: range.startDate, endDate: range.endDate }); - 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); + applyTargetFilter(params, target); addOptional(params, 'subjectId', valueOf('schedule-view-subject')); addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type')); addOptional(params, 'parity', valueOf('schedule-view-parity')); try { const lessons = await api.get(`/api/schedule/search?${params}`); - renderLessons(lessons, range); + renderLessons(lessons, range, target, previousKey); } catch (error) { const message = error.message || 'Ошибка загрузки расписания'; showAlert('schedule-view-alert', message, 'error'); - setTableState(message); - countLabel.textContent = '0 занятий'; + resetResults(message); } } - function renderLessons(lessons, range) { - countLabel.textContent = lessonCountLabel(lessons.length); + function applyTargetFilter(params, target) { + if (role === ROLE_DEPARTMENT) { + params.set('departmentId', userDepartmentId); + return; + } + if (target === TARGET_GROUPS) { + addOptional(params, 'groupId', valueOf('schedule-view-group')); + return; + } + if (target === TARGET_TEACHERS) { + addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); + return; + } + if (target === TARGET_CLASSROOMS) { + addOptional(params, 'classroomId', valueOf('schedule-view-classroom')); + return; + } + if (target === TARGET_DEPARTMENTS) { + addOptional(params, 'departmentId', valueOf('schedule-view-department')); + } + } + + function renderLessons(lessons, range, target, previousKey) { + state.totalLessons = lessons.length; if (!lessons.length) { + state.sections = []; + state.activeSectionIndex = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = periodCaption(range); setTableState('Занятия не найдены'); + countLabel.textContent = '0 занятий'; return; } - const sections = buildScheduleSections(lessons); + + const sections = buildScheduleSections(lessons, splitTypeForTarget(target), buildSectionContext(target)); if (!sections.length) { - setTableState('Нет данных для выбранного разреза таблиц'); + state.sections = []; + state.activeSectionIndex = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = periodCaption(range); + setTableState('Нет данных для выбранного режима просмотра'); + countLabel.textContent = lessonCountLabel(lessons.length); return; } - const tableCount = sections.reduce((count, section) => - count + buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons).length, 0 - ); - if (!tableCount) { - setTableState('Выберите корректный период'); - return; + + state.sections = sections; + state.activeSectionIndex = Math.max(0, sections.findIndex(section => section.key === previousKey)); + if (state.activeSectionIndex < 0) { + state.activeSectionIndex = 0; } - countLabel.textContent = `${lessonCountLabel(lessons.length)}, ${tableCountLabel(tableCount)}`; - tables.innerHTML = sections - .map(section => renderScheduleSection(section, range)) - .join(''); + state.mobileDay = preferredMobileDay(range); + renderResultNavigation(); + renderActiveSection(); } - function renderScheduleSection(section, range) { + function buildSectionContext(target) { + const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : ''; + const selectedDepartmentId = role === ROLE_DEPARTMENT + ? userDepartmentId + : (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : ''); + const allowedGroupIds = new Set(); + if (selectedGroupId) { + allowedGroupIds.add(selectedGroupId); + } else if (selectedDepartmentId) { + state.dictionaries.groups + .filter(group => String(group.departmentId) === String(selectedDepartmentId)) + .forEach(group => allowedGroupIds.add(String(group.id))); + } + return { + selectedGroupId, + selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '', + allowedGroupIds, + groupNamesById: new Map(state.dictionaries.groups.map(group => [String(group.id), group.name])) + }; + } + + function splitTypeForTarget(target) { + if (target === TARGET_TEACHERS) return TARGET_TEACHERS; + if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS; + return TARGET_GROUPS; + } + + function renderResultNavigation() { + const section = activeSection(); + const totalSections = state.sections.length; + countLabel.textContent = state.totalLessons + ? `${lessonCountLabel(state.totalLessons)}, ${sectionCountLabel(totalSections)}` + : '0 занятий'; + + resultPreviousButton.disabled = totalSections < 2; + resultNextButton.disabled = totalSections < 2; + + if (!section) { + setActiveSummary('Нет выбранного расписания'); + resultTabs.innerHTML = 'Ничего не найдено'; + return; + } + + setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`); + resultTabs.innerHTML = state.sections.map((item, index) => ` + + `).join(''); + resultTabs.querySelectorAll('[data-schedule-view-section]').forEach(button => { + button.addEventListener('click', () => { + state.activeSectionIndex = Number(button.dataset.scheduleViewSection); + state.mobileDay = preferredMobileDay(state.range); + renderResultNavigation(); + renderActiveSection(); + }); + }); + } + + function setActiveSummary(text) { + activeLabel.textContent = text; + } + + function moveResult(direction) { + if (state.sections.length < 2) return; + state.activeSectionIndex = (state.activeSectionIndex + direction + state.sections.length) % state.sections.length; + state.mobileDay = preferredMobileDay(state.range); + renderResultNavigation(); + renderActiveSection(); + } + + function activeSection() { + return state.sections[state.activeSectionIndex] || null; + } + + function renderActiveSection() { + const section = activeSection(); + if (!section || !state.range) { + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + return; + } + + tableTitle.textContent = section.title; + tableCaption.textContent = `${lessonCountLabel(section.lessons.length)} · ${periodCaption(state.range)}`; + + if (mobileMedia.matches) { + renderMobileDayTabs(); + renderMobileSchedule(section, state.range); + return; + } + + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + renderDesktopSchedule(section, state.range); + } + + function renderDesktopSchedule(section, range) { const slots = collectSlots(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); - return blocks + if (!blocks.length || !slots.length) { + setTableState('Занятия не найдены'); + return; + } + tables.innerHTML = blocks .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) .join(''); } + function renderMobileDayTabs() { + dayTabs.hidden = false; + dayTabs.innerHTML = DAY_ORDER.map(day => ` + + `).join(''); + dayTabs.querySelectorAll('[data-schedule-view-day]').forEach(button => { + button.addEventListener('click', () => { + state.mobileDay = Number(button.dataset.scheduleViewDay); + renderActiveSection(); + }); + }); + } + + function renderMobileSchedule(section, range) { + const slots = collectSlots(section.lessons); + const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); + const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); + if (!blocks.length || !slots.length) { + setTableState('Занятия не найдены'); + return; + } + tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join(''); + } + + function renderMobileDayBlock(block, slots, lessonsByDateSlot) { + const dayDates = block.datesByDay[state.mobileDay] || {}; + const rows = slots.map(slot => { + const states = slotStatesForDates(dayDates, slot, lessonsByDateSlot); + return ` +
+
+ ${escapeHtml(slot.orderLabel)} + ${escapeHtml(slot.timeLabel)} +
+
+ ${renderSlotContent(states)} +
+
+ `; + }).join(''); + + return ` +
+
+ ${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))} + ${escapeHtml(mobileDayCaption(dayDates))} +
+ ${rows} +
+ `; + } + function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { const dayHeaders = DAY_ORDER.map(day => `
- Время - Дата - Время
${renderUnifiedSlot(states[0].state)}${halves} - ${escapeHtml(slot.orderLabel)} - ${escapeHtml(slot.timeLabel)} - ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} - - ${lessons.length ? lessons.map(renderLessonCard).join('') : 'Свободно'} - @@ -152,8 +470,8 @@ export async function initScheduleView() { return `
-

${escapeHtml(section.title)} · ${escapeHtml(block.label)}

- ${escapeHtml(`${lessonCountLabel(section.lessons.length)}; ${combinedWeekCaption(block)}`)} +

${escapeHtml(section.title)}

+ ${escapeHtml(combinedWeekCaption(block))}
@@ -175,20 +493,26 @@ export async function initScheduleView() { } function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { - const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ + const states = slotStatesForDates(datesByParity, slot, lessonsByDateSlot); + const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : ''; + return ``; + } + + function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { + return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ parity, label, state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) })); + } + function renderSlotContent(states) { if (slotStatesEqual(states[0].state, states[1].state)) { - return ``; + return renderUnifiedSlot(states[0].state); } - - const halves = states.map(({ parity, label, state }) => + return states.map(({ parity, label, state }) => renderSlotHalf(state, parity, label) ).join(''); - return ``; } function scheduleSlotState(date, slot, lessonsByDateSlot) { @@ -298,6 +622,38 @@ export async function initScheduleView() { if (!tables) return; tables.innerHTML = `
${escapeHtml(message)}
`; } + + function resetResults(message) { + state.sections = []; + state.activeSectionIndex = 0; + state.mobileDay = 1; + state.totalLessons = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = message; + setTableState(message); + countLabel.textContent = '0 занятий'; + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + } + + function updatePeriodLabel(range) { + if (!periodLabel) return; + if (!dateInput.value) { + periodLabel.textContent = 'Период не выбран'; + return; + } + periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value)); + } + + function groupNameById(id) { + return state.dictionaries.groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`; + } + + function preferredMobileDay(range) { + if (!range) return 1; + return isoDay(dateInput.value || range.startDate); + } } async function loadDictionaries(role, userDepartmentId) { @@ -310,16 +666,23 @@ async function loadDictionaries(role, userDepartmentId) { api.get('/api/lesson-types') ]); - fillSelect('schedule-view-group', groups, item => item.id, item => item.name, 'Все группы'); + const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId + ? groups.filter(group => String(group.departmentId) === String(userDepartmentId)) + : groups; + + fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы'); fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели'); fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории'); fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры'); fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); - if (role === ROLE_DEPARTMENT && userDepartmentId) { - document.getElementById('schedule-view-department').value = userDepartmentId; - } + return { + groups, + teachers, + classrooms, + departments + }; } function setDefaultDate(dateInput) { @@ -342,6 +705,11 @@ function valueOf(id) { return document.getElementById(id)?.value || ''; } +function normalizeId(value) { + if (!value || value === 'null' || value === 'undefined') return ''; + return String(value); +} + function roomLabel(room) { return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); } @@ -385,12 +753,11 @@ function groupLessonsByDateSlot(lessons) { return grouped; } -function buildScheduleSections(lessons) { - const splitType = resolveSplitType(); +function buildScheduleSections(lessons, splitType, context) { const sections = new Map(); lessons.forEach(lesson => { - splitEntitiesForLesson(lesson, splitType).forEach(entity => { + splitEntitiesForLesson(lesson, splitType, context).forEach(entity => { if (!sections.has(entity.key)) { sections.set(entity.key, { key: entity.key, @@ -410,45 +777,42 @@ function buildScheduleSections(lessons) { .sort((a, b) => naturalCompare(a.title, b.title)); } -function resolveSplitType() { - const requested = valueOf('schedule-view-split'); - if ([SPLIT_GROUPS, SPLIT_TEACHERS, SPLIT_CLASSROOMS].includes(requested)) { - return requested; - } - if (valueOf('schedule-view-teacher')) return SPLIT_TEACHERS; - if (valueOf('schedule-view-classroom')) return SPLIT_CLASSROOMS; - return SPLIT_GROUPS; -} - -function splitEntitiesForLesson(lesson, splitType) { - if (splitType === SPLIT_TEACHERS) { +function splitEntitiesForLesson(lesson, splitType, context) { + if (splitType === TARGET_TEACHERS) { return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; } - if (splitType === SPLIT_CLASSROOMS) { + if (splitType === TARGET_CLASSROOMS) { return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; } - return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана'); + if (context.selectedGroupId) { + return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')]; + } + return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById); } function singleEntity(prefix, id, name, fallback) { const title = name || fallback; return { + id, key: `${prefix}:${id ?? title}`, title }; } -function listEntities(prefix, ids = [], names = [], fallback) { +function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) { const safeIds = Array.isArray(ids) ? ids : []; const safeNames = Array.isArray(names) ? names : []; if (!safeIds.length && !safeNames.length) { - return [singleEntity(prefix, null, fallback, fallback)]; + return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)]; } const maxLength = Math.max(safeIds.length, safeNames.length); const entities = []; for (let index = 0; index < maxLength; index += 1) { const id = safeIds[index] ?? null; - const name = safeNames[index] || (id == null ? fallback : `Группа ${id}`); + if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) { + continue; + } + const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`); entities.push(singleEntity(prefix, id, name, fallback)); } return entities; @@ -540,6 +904,19 @@ function combinedWeekCaption(block) { .join('; '); } +function mobileDayCaption(datesByParity) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const date = datesByParity?.[parity]; + return `${label}: ${date ? formatDate(date) : 'даты нет'}`; + }) + .join(' · '); +} + +function periodCaption(range) { + return `${formatDate(range.startDate)} - ${formatDate(range.endDate)}`; +} + function lessonSlotKey(lesson) { const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`; return value ? String(value) : 'unknown'; @@ -625,12 +1002,12 @@ function lessonCountLabel(count) { return `${count} занятий`; } -function tableCountLabel(count) { +function sectionCountLabel(count) { const tail = count % 100; - if (tail >= 11 && tail <= 14) return `${count} таблиц`; - if (count % 10 === 1) return `${count} таблица`; - if ([2, 3, 4].includes(count % 10)) return `${count} таблицы`; - return `${count} таблиц`; + 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) { diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html index 4cec711..a409229 100644 --- a/frontend/admin/views/schedule-view.html +++ b/frontend/admin/views/schedule-view.html @@ -1,79 +1,122 @@ -
-
-

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

- +
+
+
+
+

Расписания

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

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

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

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

- 0 занятий +
+
+
+

Расписание

+ Выберите параметры и нажмите «Показать» +
+ +
+
+
Загрузка...
+
-
-
Загрузка...
-
-
+
From c506a905a2791a3a0aba0a12e4ed8509312d2600 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Mon, 25 May 2026 13:40:22 +0300 Subject: [PATCH 15/16] =?UTF-8?q?=D0=9D=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D0=B5=D1=82=D0=BA=D1=83=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FRONTEND.md | 2 +- frontend/admin/css/components.css | 186 ++++++++++++++----- frontend/admin/css/modals.css | 28 ++- frontend/admin/js/views/academic-calendar.js | 42 ++++- 4 files changed, 194 insertions(+), 64 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 1439ce6..efb120f 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -128,7 +128,7 @@ frontend/ - Вкладка `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` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. +- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса идут вертикально друг под другом, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; выбранная ячейка и её код дополнительно подсвечиваются в общей сводке. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. ### Страница настроек (`/admin/settings/`) diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 19a65e4..f30526d 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -221,14 +221,14 @@ .academic-calendar-grid { display: grid; - gap: 1rem; + gap: 0.65rem; margin-top: 1rem; } .calendar-course-block { border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); - background: var(--bg-input); + background: color-mix(in srgb, var(--bg-input) 82%, var(--bg-card)); overflow: hidden; } @@ -236,23 +236,60 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; + gap: 1rem; + min-height: 44px; + padding: 0.6rem 0.85rem; + cursor: pointer; + list-style: none; + user-select: none; + transition: background var(--transition); +} + +.calendar-course-header::-webkit-details-marker { + display: none; +} + +.calendar-course-header:hover { + background: var(--bg-hover); +} + +.calendar-course-block[open] .calendar-course-header { border-bottom: 1px solid var(--bg-card-border); } -.calendar-course-header h3 { - font-size: 1rem; +.calendar-course-title { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-size: 0.96rem; + font-weight: 800; } -.calendar-course-header span { +.calendar-course-chevron { + width: 9px; + height: 9px; + border-right: 2px solid var(--text-secondary); + border-bottom: 2px solid var(--text-secondary); + transform: rotate(-45deg); + transition: transform var(--transition), border-color var(--transition); +} + +.calendar-course-block[open] .calendar-course-chevron { + transform: rotate(45deg); + border-color: var(--accent-hover); +} + +.calendar-course-meta { color: var(--text-secondary); font-size: 0.78rem; + font-weight: 700; } .calendar-semesters-grid { display: grid; - gap: 0.75rem; - padding: 0.75rem; + grid-template-columns: 1fr; + gap: 0.65rem; + padding: 0.65rem; } .calendar-semester-block { @@ -267,8 +304,8 @@ align-items: center; justify-content: space-between; gap: 1rem; - min-height: 36px; - padding: 0.45rem 0.65rem; + min-height: 32px; + padding: 0.38rem 0.55rem; border-bottom: 1px solid var(--bg-card-border); } @@ -293,7 +330,7 @@ min-width: 100%; border-collapse: collapse; table-layout: fixed; - font-size: 0.68rem; + font-size: 0.62rem; } .calendar-semester-table th, @@ -305,9 +342,9 @@ position: sticky; top: 0; z-index: 1; - height: 22px; - min-width: 26px; - padding: 0 0.2rem; + height: 18px; + min-width: 21px; + padding: 0 0.12rem; background: var(--bg-input); color: var(--text-secondary); font-weight: 700; @@ -318,8 +355,8 @@ position: sticky; left: 0; z-index: 2; - width: 36px; - min-width: 36px; + width: 30px; + min-width: 30px; background: var(--bg-input); color: var(--text-secondary); font-weight: 700; @@ -328,48 +365,69 @@ .calendar-day { position: relative; - width: 26px; - min-width: 26px; - height: 24px; - min-height: 24px; - padding: 0; - background: color-mix(in srgb, var(--activity-color, var(--accent)) 9%, var(--bg-card)); + width: 21px; + min-width: 21px; + height: 20px; + min-height: 20px; + padding: 1px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card)) 0%, var(--bg-card) 100%); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border)); cursor: pointer; outline: none; - box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 30%, transparent); text-align: center; - transition: background var(--transition), box-shadow var(--transition), transform var(--transition); + transition: background var(--transition), box-shadow var(--transition), transform var(--transition), filter var(--transition); } .calendar-day:hover, .calendar-day:focus-visible { - background: color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card)); - box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)), 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 36%, transparent); - transform: scale(1.08); + background: + linear-gradient(180deg, color-mix(in srgb, var(--activity-color, var(--accent)) 32%, var(--bg-card)) 0%, color-mix(in srgb, var(--activity-color, var(--accent)) 10%, var(--bg-card)) 100%); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 72%, transparent), + inset 0 -2px 0 color-mix(in srgb, var(--activity-color, var(--accent)) 58%, transparent), + 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); + transform: none; z-index: 3; } +.calendar-day.is-selected { + background: + radial-gradient(circle at 50% 20%, color-mix(in srgb, var(--activity-color, var(--accent)) 46%, #ffffff) 0%, color-mix(in srgb, var(--activity-color, var(--accent)) 26%, var(--bg-card)) 42%, var(--bg-card) 100%); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 90%, transparent), + inset 0 -2px 0 color-mix(in srgb, var(--activity-color, var(--accent)) 72%, transparent), + 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 32%, transparent), + 0 6px 14px color-mix(in srgb, var(--activity-color, var(--accent)) 18%, transparent); + filter: saturate(1.2); + transform: none; + z-index: 4; +} + .calendar-day-code { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; + border-radius: 3px; color: var(--text-primary); - font-size: 0.68rem; + font-size: 0.6rem; line-height: 1; font-weight: 800; + text-shadow: 0 1px 0 color-mix(in srgb, var(--bg-primary) 70%, transparent); } .calendar-day-time-marker { position: absolute; - right: 1px; - bottom: 1px; - padding: 0 2px; + right: 0; + bottom: 0; + padding: 0 1px; border-radius: 3px; background: var(--accent); color: #fff; - font-size: 0.45rem; + font-size: 0.4rem; font-weight: 800; line-height: 1.15; } @@ -379,9 +437,9 @@ } .calendar-empty-day { - width: 26px; - min-width: 26px; - height: 24px; + width: 21px; + min-width: 21px; + height: 20px; background: color-mix(in srgb, var(--bg-input) 65%, transparent); } @@ -400,6 +458,7 @@ .calendar-total-chip { display: inline-flex; align-items: center; + gap: 0.35rem; min-height: 28px; padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); @@ -410,19 +469,39 @@ font-weight: 600; } +.calendar-total-chip.is-selected { + background: color-mix(in srgb, var(--chip-color) 30%, var(--bg-card)); + border-color: color-mix(in srgb, var(--chip-color) 72%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--chip-color) 22%, transparent); +} + +.calendar-total-selected { + padding: 0.08rem 0.32rem; + border-radius: 999px; + background: color-mix(in srgb, var(--chip-color) 18%, var(--bg-primary)); + color: var(--text-secondary); + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; +} + .calendar-day-dialog-card { - max-width: 560px; + width: min(500px, 100%); + max-height: none; + padding: 1.35rem; + overflow: visible; + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); } .calendar-day-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - margin: 0.75rem 0; + gap: 0.55rem; + margin: 0.65rem 0; } .calendar-day-dialog-grid div { - padding: 0.75rem; + padding: 0.58rem 0.65rem; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-input); @@ -436,24 +515,24 @@ } .calendar-day-dialog-grid strong { - font-size: 0.95rem; + font-size: 0.9rem; } .calendar-day-dialog-activity { display: flex; - gap: 0.8rem; + gap: 0.7rem; align-items: center; - margin: 0.8rem 0 1rem; - padding: 0.75rem; + margin: 0.7rem 0 0.8rem; + padding: 0.65rem; border-radius: var(--radius-sm); background: var(--bg-input); border: 1px solid var(--bg-card-border); } .calendar-day-dialog-code { - width: 46px; - min-width: 46px; - height: 46px; + width: 40px; + min-width: 40px; + height: 40px; display: inline-flex; align-items: center; justify-content: center; @@ -470,8 +549,8 @@ } .calendar-day-dialog-time-grid { - margin-top: 1rem; - padding-top: 1rem; + margin-top: 0.8rem; + padding-top: 0.8rem; border-top: 1px solid var(--bg-card-border); } @@ -480,6 +559,12 @@ flex-wrap: wrap; gap: 0.6rem; margin-top: 0.8rem; + margin-bottom: 0; +} + +#calendar-day-dialog-time-alert:not(.error):not(.success) { + margin: 0; + padding: 0; } .profiles-context { @@ -530,6 +615,11 @@ .calendar-day-dialog-grid { grid-template-columns: 1fr; } + + .calendar-day-dialog-card { + max-height: calc(100vh - 2rem); + overflow-y: auto; + } } /* Hide Number Arrows */ diff --git a/frontend/admin/css/modals.css b/frontend/admin/css/modals.css index 6235575..2ba19c1 100644 --- a/frontend/admin/css/modals.css +++ b/frontend/admin/css/modals.css @@ -2,10 +2,7 @@ .modal-overlay { display: none; position: fixed; - top: 0; - left: 0; - right: 0; - /* bottom: 0; */ + inset: 0; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); @@ -13,6 +10,8 @@ align-items: center; justify-content: center; + padding: 1rem; + overflow-y: auto; opacity: 0; transition: opacity var(--transition); @@ -29,10 +28,11 @@ border-radius: var(--radius-md); padding: 2rem; - width: 100%; - top: 0; - max-width: 100%; + width: min(560px, 100%); + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 2rem); margin: 0 auto; + overflow-y: auto; position: relative; transform: scale(0.95); @@ -40,10 +40,24 @@ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); } +.modal-content.card:hover { + transform: scale(0.95); + border-color: var(--bg-card-border); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +.modal-content.card:hover::before { + opacity: 0; +} + .modal-overlay.open .modal-content { transform: scale(1); } +.modal-overlay.open .modal-content.card:hover { + transform: scale(1); +} + .modal-close { position: absolute; top: 1rem; diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 9efd62f..4ba5213 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -81,6 +81,7 @@ export async function initAcademicCalendar() { let timeSlotScopes = []; let timeSlotDateAssignments = []; let activeCalendarDay = null; + let selectedCalendarDayKey = null; bindEvents(); @@ -521,17 +522,22 @@ export async function initAcademicCalendar() { } function renderCalendarGrid(rows) { + activeCalendarDay = null; + selectedCalendarDayKey = null; const grouped = groupBy(rows, row => row.courseNumber); - calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` -
-
-

${course} курс

- ${courseRows.length} дней -
+ calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => ` +
+ + + + ${course} курс + + ${courseRows.length} дней +
${renderCourseSemesters(Number(course), courseRows)}
-
+ `).join(''); populateFillCourseSelect(); renderCalendarTotals(); @@ -612,6 +618,7 @@ export async function initAcademicCalendar() { function openCalendarDayDialog(day) { activeCalendarDay = day; + selectCalendarDay(day); calendarDayDialogDate.textContent = `${formatLongDate(day.dataset.date)} (${shortDayLabel(day.dataset.day)})`; calendarDayDialogCourse.textContent = `${day.dataset.course} курс`; calendarDayDialogWeek.textContent = `${day.dataset.week} неделя`; @@ -624,6 +631,7 @@ export async function initAcademicCalendar() { syncSelects(calendarDayDialogActivitySelect); syncSelects(calendarDayDialogTimeScopeSelect); updateCalendarDayDialogSummary(day); + renderCalendarTotals(); calendarDayDialog.classList.add('open'); } @@ -730,6 +738,17 @@ export async function initAcademicCalendar() { day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`); } + function selectCalendarDay(day) { + if (!day) return; + selectedCalendarDayKey = calendarDayKey(day); + calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); + day.classList.add('is-selected'); + } + + function calendarDayKey(day) { + return `${day.dataset.course}:${day.dataset.date}`; + } + function buildDefaultGrid(calendar) { const rows = []; const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); @@ -788,11 +807,18 @@ export async function initAcademicCalendar() { const key = activity ? activity.code : '-'; counts.set(key, (counts.get(key) || 0) + 1); }); + const selectedDay = selectedCalendarDayKey + ? Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey) + : null; + const selectedActivity = selectedDay ? activityById(selectedDay.querySelector('.calendar-day-activity').value) : null; + const selectedCode = selectedActivity?.code || null; calendarTotals.innerHTML = Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru')) .map(([code, count]) => { const activity = activityTypes.find(item => item.code === code); - return `${escapeHtml(code)}: ${count}`; + const selectedClass = selectedCode === code ? ' is-selected' : ''; + const suffix = selectedCode === code ? 'выбрано' : ''; + return `${escapeHtml(code)}: ${count}${suffix}`; }).join(''); } From 467f78c3d0f2bafacf7c8a32d4edd24357c8f33d Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Wed, 27 May 2026 13:22:59 +0300 Subject: [PATCH 16/16] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=20=D1=81=D0=B5=D1=82=D0=BA=D0=BE=D0=B9=20=D0=B2=20=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B5=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B6=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FRONTEND.md | 2 +- frontend/admin/css/components.css | 197 +++++++++++--- frontend/admin/js/views/academic-calendar.js | 268 +++++++++++++++++-- 3 files changed, 398 insertions(+), 69 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index c615c46..a8aa0f1 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -152,7 +152,7 @@ frontend/ - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. -- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса идут вертикально друг под другом, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; выбранная ячейка и её код дополнительно подсвечиваются в общей сводке. +- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 9cbe9c4..623cfeb 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -391,13 +391,20 @@ } .calendar-semesters-grid { + --calendar-semester-block-height: 194px; display: grid; - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-items: stretch; gap: 0.65rem; padding: 0.65rem; } .calendar-semester-block { + display: flex; + flex-direction: column; + height: var(--calendar-semester-block-height); + min-height: 100%; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-card); @@ -426,16 +433,28 @@ } .calendar-semester-table-wrap { + flex: 1; + display: flex; + min-height: 0; overflow-x: auto; + overflow-y: hidden; background: var(--bg-primary); + scrollbar-width: none; +} + +.calendar-semester-table-wrap::-webkit-scrollbar { + width: 0; + height: 0; } .calendar-semester-table { width: max-content; min-width: 100%; + height: 100%; border-collapse: collapse; table-layout: fixed; font-size: 0.62rem; + user-select: none; } .calendar-semester-table th, @@ -443,12 +462,16 @@ border: 1px solid var(--bg-card-border); } +.calendar-semester-table tbody tr { + height: 19px; +} + .calendar-semester-table thead th { position: sticky; top: 0; z-index: 1; height: 18px; - min-width: 21px; + min-width: 18px; padding: 0 0.12rem; background: var(--bg-input); color: var(--text-secondary); @@ -470,43 +493,33 @@ .calendar-day { position: relative; - width: 21px; - min-width: 21px; - height: 20px; - min-height: 20px; - padding: 1px; - background: - linear-gradient(180deg, color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card)) 0%, var(--bg-card) 100%); + width: 18px; + min-width: 18px; + height: 19px; + min-height: 19px; + padding: 0; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 12%, var(--bg-card)); border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border)); cursor: pointer; outline: none; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 30%, transparent); + box-shadow: none; text-align: center; - transition: background var(--transition), box-shadow var(--transition), transform var(--transition), filter var(--transition); + transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition); } .calendar-day:hover, .calendar-day:focus-visible { - background: - linear-gradient(180deg, color-mix(in srgb, var(--activity-color, var(--accent)) 32%, var(--bg-card)) 0%, color-mix(in srgb, var(--activity-color, var(--accent)) 10%, var(--bg-card)) 100%); - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 72%, transparent), - inset 0 -2px 0 color-mix(in srgb, var(--activity-color, var(--accent)) 58%, transparent), - 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); - transform: none; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card)); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 50%, var(--bg-card-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 30%, transparent); z-index: 3; } .calendar-day.is-selected { - background: - radial-gradient(circle at 50% 20%, color-mix(in srgb, var(--activity-color, var(--accent)) 46%, #ffffff) 0%, color-mix(in srgb, var(--activity-color, var(--accent)) 26%, var(--bg-card)) 42%, var(--bg-card) 100%); - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 90%, transparent), - inset 0 -2px 0 color-mix(in srgb, var(--activity-color, var(--accent)) 72%, transparent), - 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 32%, transparent), - 0 6px 14px color-mix(in srgb, var(--activity-color, var(--accent)) 18%, transparent); - filter: saturate(1.2); - transform: none; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 34%, var(--bg-card)); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 64%, var(--bg-card-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 44%, transparent); + filter: saturate(1.08); z-index: 4; } @@ -516,7 +529,7 @@ justify-content: center; width: 100%; height: 100%; - border-radius: 3px; + border-radius: 2px; color: var(--text-primary); font-size: 0.6rem; line-height: 1; @@ -541,14 +554,89 @@ display: none !important; } +.calendar-day-tooltip { + position: fixed; + z-index: 1200; + width: min(280px, calc(100vw - 24px)); + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 26%, var(--bg-card-border)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); + color: var(--text-primary); + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.36), 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + pointer-events: none; + opacity: 0; + visibility: hidden; + transform: translateY(4px) scale(0.98); + transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease; +} + +.calendar-day-tooltip.visible { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); +} + +.calendar-day-tooltip-head { + display: flex; + align-items: center; + gap: 0.65rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-day-tooltip-head strong, +.calendar-day-tooltip-body strong { + display: block; + color: var(--text-primary); + font-size: 0.85rem; + line-height: 1.25; +} + +.calendar-day-tooltip-head span, +.calendar-day-tooltip-body span { + display: block; + color: var(--text-secondary); + font-size: 0.68rem; + line-height: 1.2; +} + +.calendar-day-tooltip-code { + width: 34px; + min-width: 34px; + height: 34px; + display: inline-grid; + place-items: center; + border-radius: 8px; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent); + color: var(--text-primary); + font-size: 0.86rem; + font-weight: 800; + line-height: 1; + padding-top: 2px; + text-align: center; +} + +.calendar-day-tooltip-body { + display: grid; + gap: 0.45rem; + padding-top: 0.65rem; +} + .calendar-empty-day { - width: 21px; - min-width: 21px; - height: 20px; + width: 18px; + min-width: 18px; + height: 19px; background: color-mix(in srgb, var(--bg-input) 65%, transparent); } .calendar-empty-semester { + flex: 1; + display: flex; + align-items: center; padding: 0.75rem; color: var(--text-secondary); font-size: 0.82rem; @@ -558,6 +646,7 @@ display: flex; flex-wrap: wrap; gap: 0.5rem; + margin-top: 0.85rem; } .calendar-total-chip { @@ -590,19 +679,25 @@ text-transform: uppercase; } -.calendar-day-dialog-card { - width: min(500px, 100%); +#calendar-day-dialog.modal-overlay { + background: rgba(0, 0, 0, 0.42); + backdrop-filter: blur(3px); +} + +#calendar-day-dialog .calendar-day-dialog-card { + width: min(470px, 100%); max-height: none; - padding: 1.35rem; + padding: 1rem 1rem 0.85rem; overflow: visible; background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); + border-radius: 18px; } .calendar-day-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.55rem; - margin: 0.65rem 0; + gap: 0.45rem; + margin: 0.45rem 0; } .calendar-day-dialog-grid div { @@ -627,8 +722,8 @@ display: flex; gap: 0.7rem; align-items: center; - margin: 0.7rem 0 0.8rem; - padding: 0.65rem; + margin: 0.55rem 0 0.65rem; + padding: 0.6rem; border-radius: var(--radius-sm); background: var(--bg-input); border: 1px solid var(--bg-card-border); @@ -654,8 +749,8 @@ } .calendar-day-dialog-time-grid { - margin-top: 0.8rem; - padding-top: 0.8rem; + margin-top: 0.6rem; + padding-top: 0.6rem; border-top: 1px solid var(--bg-card-border); } @@ -663,15 +758,21 @@ display: flex; flex-wrap: wrap; gap: 0.6rem; - margin-top: 0.8rem; + margin-top: 0.6rem; margin-bottom: 0; } -#calendar-day-dialog-time-alert:not(.error):not(.success) { +#calendar-day-dialog-time-alert { margin: 0; padding: 0; } +#calendar-day-dialog-time-alert.error, +#calendar-day-dialog-time-alert.success { + margin-top: 0.55rem; + padding: 0.5rem 0.75rem; +} + .profiles-context { color: var(--text-secondary); font-weight: 600; @@ -702,6 +803,16 @@ grid-template-columns: repeat(2, minmax(180px, 1fr)); } + .calendar-semesters-grid { + grid-template-columns: 1fr; + grid-auto-rows: auto; + } + + .calendar-semester-block { + height: auto; + min-height: 0; + } + .calendar-semester-header { align-items: flex-start; flex-direction: column; @@ -727,7 +838,7 @@ grid-template-columns: 1fr; } - .calendar-day-dialog-card { + #calendar-day-dialog .calendar-day-dialog-card { max-height: calc(100vh - 2rem); overflow-y: auto; } diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 4ba5213..c0b9ebd 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -82,6 +82,12 @@ export async function initAcademicCalendar() { let timeSlotDateAssignments = []; let activeCalendarDay = null; let selectedCalendarDayKey = null; + let selectedCalendarDays = []; + let isCalendarDragSelecting = false; + let didCalendarDragSelect = false; + let suppressCalendarDayClick = false; + let isSyncingCalendarDayDialog = false; + let calendarDayTooltip = null; bindEvents(); @@ -116,10 +122,34 @@ export async function initAcademicCalendar() { renderCalendarTotals(); } }); + calendarGrid?.addEventListener('mousedown', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) startCalendarDayDragSelection(event, day); + }); + calendarGrid?.addEventListener('mouseover', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) addCalendarDayToDragSelection(day); + }); + calendarGrid?.addEventListener('mousemove', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) showCalendarDayTooltip(day, event); + }); + calendarGrid?.addEventListener('mouseleave', hideCalendarDayTooltip); + calendarGrid?.addEventListener('focusin', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) showCalendarDayTooltip(day); + }); + calendarGrid?.addEventListener('focusout', hideCalendarDayTooltip); calendarGrid?.addEventListener('click', (event) => { + if (suppressCalendarDayClick) { + suppressCalendarDayClick = false; + event.preventDefault(); + return; + } const day = event.target.closest('.calendar-day'); if (day) openCalendarDayDialog(day); }); + document.addEventListener('mouseup', finishCalendarDayDragSelection); calendarGrid?.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const day = event.target.closest('.calendar-day'); @@ -132,14 +162,17 @@ export async function initAcademicCalendar() { if (event.target === calendarDayDialog) closeCalendarDayDialog(); }); calendarDayDialogActivitySelect?.addEventListener('change', () => { - if (!activeCalendarDay) return; - const select = activeCalendarDay.querySelector('.calendar-day-activity'); - select.value = calendarDayDialogActivitySelect.value; - updateCalendarDayVisual(activeCalendarDay); - updateCalendarDayDialogSummary(activeCalendarDay); + if (isSyncingCalendarDayDialog || !calendarDayDialogActivitySelect.value) return; + const days = selectedCalendarDays.length ? selectedCalendarDays : (activeCalendarDay ? [activeCalendarDay] : []); + days.forEach(day => { + const select = day.querySelector('.calendar-day-activity'); + select.value = calendarDayDialogActivitySelect.value; + updateCalendarDayVisual(day); + }); + updateCalendarDayDialogSummaryForSelection(days); renderCalendarTotals(); }); - calendarDayDialogTimeSaveButton?.addEventListener('click', saveTimeSlotDateAssignment); + calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); academicYearForm?.addEventListener('submit', saveAcademicYear); @@ -524,6 +557,7 @@ export async function initAcademicCalendar() { function renderCalendarGrid(rows) { activeCalendarDay = null; selectedCalendarDayKey = null; + selectedCalendarDays = []; const grouped = groupBy(rows, row => row.courseNumber); calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => `
@@ -607,7 +641,6 @@ export async function initAcademicCalendar() { role="button" tabindex="0" aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}" - title="${escapeHtml(`${formatLongDate(row.date)}, ${dayName}, ${row.weekNumber} неделя, ${code} - ${activity.name || ''}`)}" style="--activity-color:${escapeHtml(color)}"> ${escapeHtml(code)} ${timeAssignmentForDate(row.date) ? 'вр.' : ''} @@ -617,20 +650,48 @@ export async function initAcademicCalendar() { } function openCalendarDayDialog(day) { - activeCalendarDay = day; - selectCalendarDay(day); - calendarDayDialogDate.textContent = `${formatLongDate(day.dataset.date)} (${shortDayLabel(day.dataset.day)})`; - calendarDayDialogCourse.textContent = `${day.dataset.course} курс`; - calendarDayDialogWeek.textContent = `${day.dataset.week} неделя`; - calendarDayDialogDay.textContent = dayLabel(day.dataset.day); + openCalendarDayDialogForSelection([day]); + } + + function openCalendarDayDialogForSelection(days) { + const selectedDays = uniqueCalendarDays(days); + if (!selectedDays.length) return; + + activeCalendarDay = selectedDays[0]; + selectCalendarDays(selectedDays); + + const dates = selectedDays.map(day => day.dataset.date).sort(); + const courses = new Set(selectedDays.map(day => day.dataset.course)); + const weeks = new Set(selectedDays.map(day => day.dataset.week)); + const weekDays = new Set(selectedDays.map(day => day.dataset.day)); + const isSingle = selectedDays.length === 1; + + calendarDayDialogDate.textContent = isSingle + ? `${formatLongDate(activeCalendarDay.dataset.date)} (${shortDayLabel(activeCalendarDay.dataset.day)})` + : `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; + calendarDayDialogCourse.textContent = courses.size === 1 + ? `${activeCalendarDay.dataset.course} курс` + : `${courses.size} курса`; + calendarDayDialogWeek.textContent = weeks.size === 1 + ? `${activeCalendarDay.dataset.week} неделя` + : `${weeks.size} недель`; + calendarDayDialogDay.textContent = weekDays.size === 1 + ? dayLabel(activeCalendarDay.dataset.day) + : `${selectedDays.length} ячеек`; calendarDayDialogActivitySelect.innerHTML = options( activityTypes.map(toActivityOption), - day.querySelector('.calendar-day-activity').value + commonCalendarDayActivityValue(selectedDays) ); - populateTimeScopeDialogSelect(day.dataset.date); + if (isSingle) { + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + } else { + populateMultiSelectionTimeScopeDialogSelect(); + } + isSyncingCalendarDayDialog = true; syncSelects(calendarDayDialogActivitySelect); syncSelects(calendarDayDialogTimeScopeSelect); - updateCalendarDayDialogSummary(day); + isSyncingCalendarDayDialog = false; + updateCalendarDayDialogSummaryForSelection(selectedDays); renderCalendarTotals(); calendarDayDialog.classList.add('open'); } @@ -655,6 +716,33 @@ export async function initAcademicCalendar() { `; } + function updateCalendarDayDialogSummaryForSelection(days) { + if (days.length <= 1) { + updateCalendarDayDialogSummary(days[0]); + return; + } + + const activity = activityById(commonCalendarDayActivityValue(days)); + if (!activity) { + calendarDayDialogActivitySummary.innerHTML = ` + ... +
+ Выбрано ячеек: ${days.length} +

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

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

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

+
+ `; + } + async function saveTimeSlotDateAssignment() { if (!activeCalendarDay) return; hideAlert('calendar-day-dialog-time-alert'); @@ -707,10 +795,19 @@ export async function initAcademicCalendar() { calendarDayDialogTimeScopeSelect.innerHTML = `` + manualScopes.map(scope => ``).join(''); calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; - calendarDayDialogTimeSaveButton.disabled = manualScopes.length === 0 && !assignment; + calendarDayDialogTimeScopeSelect.disabled = false; + calendarDayDialogTimeSaveButton.disabled = false; calendarDayDialogTimeClearButton.disabled = !assignment; } + function populateMultiSelectionTimeScopeDialogSelect() { + calendarDayDialogTimeScopeSelect.innerHTML = ''; + calendarDayDialogTimeScopeSelect.value = ''; + calendarDayDialogTimeScopeSelect.disabled = true; + calendarDayDialogTimeSaveButton.disabled = false; + calendarDayDialogTimeClearButton.disabled = true; + } + function updateTimeAssignmentVisuals(date) { const assignment = timeAssignmentForDate(date); calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { @@ -735,14 +832,133 @@ export async function initAcademicCalendar() { day.style.setProperty('--activity-color', color); day.querySelector('.calendar-day-code').textContent = code; day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); - day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`); + } + + function showCalendarDayTooltip(day, event = null) { + if (isCalendarDragSelecting) { + hideCalendarDayTooltip(); + return; + } + const tooltip = ensureCalendarDayTooltip(); + const activity = activityById(day.querySelector('.calendar-day-activity')?.value); + tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b'); + tooltip.innerHTML = renderCalendarDayTooltip(day); + tooltip.classList.add('visible'); + positionCalendarDayTooltip(tooltip, day, event); + } + + function hideCalendarDayTooltip() { + calendarDayTooltip?.classList.remove('visible'); + } + + function ensureCalendarDayTooltip() { + if (calendarDayTooltip) return calendarDayTooltip; + calendarDayTooltip = document.createElement('div'); + calendarDayTooltip.className = 'calendar-day-tooltip'; + calendarDayTooltip.setAttribute('role', 'tooltip'); + document.body.appendChild(calendarDayTooltip); + return calendarDayTooltip; + } + + function renderCalendarDayTooltip(day) { + const activity = activityById(day.querySelector('.calendar-day-activity')?.value); + const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-'; + const color = activity?.colorCode || '#64748b'; + const timeAssignment = timeAssignmentForDate(day.dataset.date); + const timeLabel = timeAssignment + ? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка') + : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); + return ` +
+ ${escapeHtml(code)} +
+ ${escapeHtml(formatLongDate(day.dataset.date))} + ${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя +
+
+
+
+ Курс + ${escapeHtml(day.dataset.course)} +
+
+ Активность + ${escapeHtml(activity?.name || 'Не задана')} +
+
+ Время + ${escapeHtml(timeLabel)} +
+
+ `; + } + + function positionCalendarDayTooltip(tooltip, day, event) { + const offset = 14; + const rect = day.getBoundingClientRect(); + const x = event?.clientX ?? rect.left + rect.width / 2; + const y = event?.clientY ?? rect.top; + const tooltipRect = tooltip.getBoundingClientRect(); + let left = x + offset; + let top = y + offset; + if (left + tooltipRect.width > window.innerWidth - 12) { + left = x - tooltipRect.width - offset; + } + if (top + tooltipRect.height > window.innerHeight - 12) { + top = y - tooltipRect.height - offset; + } + tooltip.style.left = `${Math.max(12, left)}px`; + tooltip.style.top = `${Math.max(12, top)}px`; + } + + function startCalendarDayDragSelection(event, day) { + if (event.button !== 0) return; + hideCalendarDayTooltip(); + isCalendarDragSelecting = true; + didCalendarDragSelect = false; + suppressCalendarDayClick = false; + selectCalendarDays([day]); + event.preventDefault(); + } + + function addCalendarDayToDragSelection(day) { + if (!isCalendarDragSelecting || selectedCalendarDays.includes(day)) return; + didCalendarDragSelect = true; + selectedCalendarDays.push(day); + day.classList.add('is-selected'); + renderCalendarTotals(); + } + + function finishCalendarDayDragSelection() { + if (!isCalendarDragSelecting) return; + isCalendarDragSelecting = false; + if (didCalendarDragSelect && selectedCalendarDays.length > 1) { + suppressCalendarDayClick = true; + openCalendarDayDialogForSelection(selectedCalendarDays); + } } function selectCalendarDay(day) { - if (!day) return; - selectedCalendarDayKey = calendarDayKey(day); + selectCalendarDays(day ? [day] : []); + } + + function selectCalendarDays(days) { + const uniqueDays = uniqueCalendarDays(days); + selectedCalendarDays = uniqueDays; + selectedCalendarDayKey = uniqueDays[0] ? calendarDayKey(uniqueDays[0]) : null; calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); - day.classList.add('is-selected'); + uniqueDays.forEach(item => item.classList.add('is-selected')); + renderCalendarTotals(); + } + + function uniqueCalendarDays(days) { + return Array.from(new Set(days.filter(Boolean))); + } + + function commonCalendarDayActivityValue(days) { + const values = uniqueCalendarDays(days).map(day => day.querySelector('.calendar-day-activity')?.value || ''); + const uniqueValues = Array.from(new Set(values)); + return uniqueValues.length === 1 ? uniqueValues[0] : ''; } function calendarDayKey(day) { @@ -807,10 +1023,12 @@ export async function initAcademicCalendar() { const key = activity ? activity.code : '-'; counts.set(key, (counts.get(key) || 0) + 1); }); - const selectedDay = selectedCalendarDayKey - ? Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey) - : null; - const selectedActivity = selectedDay ? activityById(selectedDay.querySelector('.calendar-day-activity').value) : null; + const selectedDays = selectedCalendarDays.length + ? selectedCalendarDays + : (selectedCalendarDayKey + ? [Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey)].filter(Boolean) + : []); + const selectedActivity = selectedDays.length ? activityById(commonCalendarDayActivityValue(selectedDays)) : null; const selectedCode = selectedActivity?.code || null; calendarTotals.innerHTML = Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru'))
${renderSlotContent(states)}${renderUnifiedSlot(states[0].state)}${halves}