создал систему календарного учебного графика
This commit is contained in:
@@ -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-валидацию конфликтов преподавателей, аудиторий и групп при сохранении правил.
|
||||
- Добавить связь пользователя-студента с учебной группой, чтобы кабинет студента не требовал ручного выбора группы.
|
||||
|
||||
@@ -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». Конвенция чётности (верхняя = чётная или нечётная) **настраивается на уровне тенанта**, так как у разных университетов разные традиции. Это избавляет систему от уязвимостей, связанных с плавающими днями начала учёбы, високосными годами и смещениями дней недели.
|
||||
- `academic_years` и `semesters` задают границы учебного года и семестров.
|
||||
- `specialties` и `specialty_profiles` определяют образовательную программу группы.
|
||||
- `academic_calendars` хранит график профиля и формы обучения.
|
||||
- `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`.
|
||||
- `academic_calendar_days` хранит дневную сетку по курсу и дате.
|
||||
- `student_group_calendar_assignments` выбирает график для группы на учебный год.
|
||||
|
||||
* **Справочник исключений (Праздники и Выходные).** В этой таблице хранятся конкретные даты `YYYY-MM-DD`, когда университет юридически или физически закрыт (например, государственные праздники). Если по правилу пара должна быть в этот день, алгоритм будет знать, что его нужно пропустить без штрафов и ошибок.
|
||||
### Слой правил расписания
|
||||
|
||||
* **Матрица учебного графика.** Это цифровая копия эксель-таблицы (`Курс + Специальность` → `Номер недели` → `Тип деятельности`). Привязка идёт к `course_number` + `specialty_id`, а **не** к конкретной группе, так как учебный график одинаков для всех групп одного курса одной специальности. Номер текущего курса группы вычисляется из поля `year_start_study` модели `StudentGroup` относительно текущей даты по формуле: `course = текущий_учебный_год - year_start_study + 1`. Типы деятельности включают `THEORY` (Теория, пары идут в штатном режиме), `EXAM` (Э — экзаменационная сессия), `VACATION` (К — каникулы), `PRACTICE` (У, П — практика). Если, например, у 3-го курса на 18-й неделе стоит статус `EXAM`, алгоритм даже не будет пытаться генерировать для них теоретические лекции, а отобразит блок «Экзаменационная сессия».
|
||||
- `time_slots` задаёт сетку пар для тенанта.
|
||||
- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов.
|
||||
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
|
||||
- `schedule_rule_slots` хранит шаблонные занятия правила.
|
||||
|
||||
### 1.2 Справочник временных слотов (Time Slots)
|
||||
Вместо хардкода фиксированных 7 пар, система хранит временные слоты в отдельной **настраиваемой таблице**. Каждый тенант (университет) может иметь собственное количество пар, их длительность и временные рамки.
|
||||
## Алгоритм генерации
|
||||
|
||||
Слот содержит:
|
||||
* `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<RenderedLesson> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate)` — расписание группы.
|
||||
* `List<RenderedLesson> 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) для Дня Недели, Временного слота, Подгруппы, Чётности, Преподавателя, Аудитории и Типа занятия.
|
||||
Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу.
|
||||
|
||||
@@ -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` на дне теории и на днях, где пары запрещены.
|
||||
|
||||
@@ -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<AcademicCalendarActivityTypeDto> 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<AcademicCalendarMatrixDto> 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<SemesterDto> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AcademicCalendarDto> 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<CalendarStudyFormDto> 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)
|
||||
.<ResponseEntity<?>>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<AcademicCalendarGridDayDto> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<StudentGroup> groups = groupRepository.findAll();
|
||||
|
||||
List<GroupResponse> 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<GroupResponse> 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<EducationForm> 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<StudentGroup> 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<EducationForm> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record CalendarStudyFormDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
AcademicActivityType activityType,
|
||||
String activityType,
|
||||
Integer totalAcademicHours,
|
||||
Integer consumedAcademicHoursBeforeLesson,
|
||||
Integer remainingAcademicHoursAfterLesson
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record SpecialtyProfileDto(
|
||||
Long id,
|
||||
Long specialtyId,
|
||||
String specialtyCode,
|
||||
String specialtyName,
|
||||
String name,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum AcademicActivityType {
|
||||
THEORY,
|
||||
EXAM,
|
||||
VACATION,
|
||||
PRACTICE
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AcademicCalendarActivityType, Long> {
|
||||
|
||||
List<AcademicCalendarActivityType> findAllByOrderByDisplayOrderAscCodeAsc();
|
||||
|
||||
Optional<AcademicCalendarActivityType> findByCode(String code);
|
||||
}
|
||||
@@ -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<AcademicCalendarDay, Long> {
|
||||
|
||||
@Query("""
|
||||
select day
|
||||
from AcademicCalendarDay day
|
||||
join fetch day.activityType
|
||||
where day.academicCalendar.id = :calendarId
|
||||
order by day.courseNumber asc, day.date asc
|
||||
""")
|
||||
List<AcademicCalendarDay> 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<AcademicCalendarDay> 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);
|
||||
}
|
||||
@@ -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<AcademicCalendarMatrix, Long> {
|
||||
|
||||
Optional<AcademicCalendarMatrix> findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber(
|
||||
Long semesterId,
|
||||
Integer courseNumber,
|
||||
Long specialtyId,
|
||||
Integer weekNumber
|
||||
);
|
||||
|
||||
List<AcademicCalendarMatrix> findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc(
|
||||
Long semesterId,
|
||||
Integer courseNumber,
|
||||
Long specialtyId
|
||||
);
|
||||
}
|
||||
@@ -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<AcademicCalendar, Long> {
|
||||
|
||||
@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<AcademicCalendar> 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<AcademicCalendar> findByIdWithDetails(@Param("id") Long id);
|
||||
}
|
||||
@@ -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<CalendarStudyForm, Long> {
|
||||
|
||||
List<CalendarStudyForm> findAllByOrderByNameAsc();
|
||||
}
|
||||
@@ -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<StudentGroup, Long> {
|
||||
|
||||
Optional<StudentGroup> findByName(String name);
|
||||
|
||||
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||
|
||||
List<StudentGroup> findByDepartmentId(Long departmentId);
|
||||
|
||||
@@ -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<Holiday, Long> {
|
||||
|
||||
List<Holiday> findByAcademicYearIdOrderByDateAsc(Long academicYearId);
|
||||
|
||||
List<Holiday> findByAcademicYearIdAndDateBetween(Long academicYearId, LocalDate startDate, LocalDate endDate);
|
||||
|
||||
boolean existsByAcademicYearIdAndDate(Long academicYearId, LocalDate date);
|
||||
}
|
||||
@@ -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<SpecialtyProfile, Long> {
|
||||
|
||||
List<SpecialtyProfile> findBySpecialityIdOrderByNameAsc(Long specialityId);
|
||||
|
||||
Optional<SpecialtyProfile> findBySpecialityIdAndName(Long specialityId, String name);
|
||||
}
|
||||
@@ -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<StudentGroupCalendarAssignment, Long> {
|
||||
|
||||
@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<StudentGroupCalendarAssignment> 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<StudentGroupCalendarAssignment> 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<StudentGroupCalendarAssignment> findForSchedule(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("academicYearId") Long academicYearId
|
||||
);
|
||||
}
|
||||
@@ -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<AcademicCalendarActivityType> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
Set<Long> warnedAcademicYears = new HashSet<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> 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<StudentGroup> 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
|
||||
|
||||
@@ -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 типа занятия';
|
||||
|
||||
76
docs/ACADEMIC_CALENDAR.md
Normal file
76
docs/ACADEMIC_CALENDAR.md
Normal file
@@ -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-импорт в первой реализации не предусмотрен: редактор заполняет данные через сайт.
|
||||
129
docs/API.md
129
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
|
||||
}
|
||||
```
|
||||
|
||||
Назначаемый график должен относиться к тому же учебному году, специальности и профилю, что и группа.
|
||||
|
||||
---
|
||||
|
||||
## Аудитории
|
||||
|
||||
@@ -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` возвращает пустой список для этой группы без ошибки.
|
||||
|
||||
### Временны́е слоты
|
||||
|
||||
|
||||
143
docs/DATABASE.md
143
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` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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, мониторинг |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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() {
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Capacities
|
||||
// Вместимость.
|
||||
const capacityContainer = document.getElementById('capacity-checkboxes');
|
||||
const capacities = [
|
||||
{ id: 'small', name: "До 30 мест" },
|
||||
@@ -55,7 +55,7 @@ function populateFilters() {
|
||||
</label>
|
||||
`).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 = `
|
||||
<th class="top-left-cell">
|
||||
<span class="top-label">Время</span>
|
||||
<span class="bottom-label">Аудитория</span>
|
||||
</th>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="lesson-card">
|
||||
<div class="lesson-subject">${lesson.subject}</div>
|
||||
|
||||
@@ -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 = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +81,52 @@ export async function initDepartmentsData() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderProfileSpecialtySelect() {
|
||||
const current = profileSpecialtySelect.value;
|
||||
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||||
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)} — ${escapeHtml(s.specialityName || s.name)}</option>`).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 = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
|
||||
return;
|
||||
}
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
|
||||
renderProfiles();
|
||||
} catch (error) {
|
||||
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
if (!profiles.length) {
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
profilesTbody.innerHTML = profiles.map(profile => `
|
||||
<tr>
|
||||
<td>${profile.id}</td>
|
||||
<td>${escapeHtml(profile.name)}</td>
|
||||
<td>${escapeHtml(profile.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 }));
|
||||
}
|
||||
}
|
||||
|
||||
460
frontend/admin/js/views/groups.js
Executable file → Normal file
460
frontend/admin/js/views/groups.js
Executable file → Normal file
@@ -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 = '<tr><td colspan="8" class="loading-row">Ошибка загрузки данных</td></tr>';
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="8" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
populateGroupSelect();
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(group => `
|
||||
<tr>
|
||||
<td>${group.id}</td>
|
||||
<td>${escapeHtml(group.name)}</td>
|
||||
<td>${escapeHtml(String(group.groupSize))}</td>
|
||||
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
|
||||
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
|
||||
<td>${group.course || '-'}</td>
|
||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function populateEfSelects(forms) {
|
||||
// Group creation select
|
||||
const currentVal = newGroupEfSelect.value;
|
||||
newGroupEfSelect.innerHTML = forms.map(ef =>
|
||||
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
|
||||
).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 = '<option value="">Все формы</option>' +
|
||||
forms.map(ef =>
|
||||
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
|
||||
).join('');
|
||||
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||||
if (currentFilter) filterEfSelect.value = currentFilter;
|
||||
syncSelects(filterEfSelect);
|
||||
}
|
||||
|
||||
function populateDepartmentSelect(items) {
|
||||
const currentVal = newGroupDepartmentSelect.value;
|
||||
if (!items || !items.length) {
|
||||
newGroupDepartmentSelect.innerHTML = '<option value="">Нет кафедр</option>';
|
||||
return;
|
||||
function populateEfSelect(select, forms) {
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||||
if (currentVal && forms.find(form => form.id == currentVal)) {
|
||||
select.value = currentVal;
|
||||
}
|
||||
syncSelects(select);
|
||||
}
|
||||
|
||||
newGroupDepartmentSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||||
function populateDepartmentSelects(items) {
|
||||
populateDepartmentSelect(newGroupDepartmentSelect, items);
|
||||
populateDepartmentSelect(editGroupDepartmentSelect, items);
|
||||
}
|
||||
|
||||
function populateDepartmentSelect(select, items) {
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||||
items.map(department => {
|
||||
const name = department.departmentName || department.name || 'Без названия';
|
||||
const code = department.departmentCode || department.code || department.id;
|
||||
return `<option value="${department.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
|
||||
}).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 = '<option value="">Нет специальностей</option>';
|
||||
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 = '<option value="">Выберите специальность</option>' +
|
||||
items.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality.id))}</option>`).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
|
||||
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
|
||||
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет профилей</option>';
|
||||
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
|
||||
select.value = selectedProfileId;
|
||||
}
|
||||
syncSelects(select);
|
||||
}
|
||||
|
||||
function populateYearSelect() {
|
||||
calendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
if (!calendarYearSelect.value && academicYears.length) {
|
||||
calendarYearSelect.value = String(academicYears[0].id);
|
||||
}
|
||||
syncSelects(calendarYearSelect);
|
||||
}
|
||||
|
||||
function populateGroupSelect() {
|
||||
const current = calendarGroupSelect.value;
|
||||
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).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
|
||||
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет подходящих графиков</option>';
|
||||
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 = '<option value="">Выберите специальность</option>' +
|
||||
items.map(speciality => {
|
||||
const name = speciality.specialityName || speciality.name || 'Без названия';
|
||||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
|
||||
return `<option value="${speciality.id}">${escapeHtml(code)} — ${escapeHtml(name)}</option>`;
|
||||
}).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');
|
||||
}
|
||||
syncSelects(newGroupSpecialitySelect);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="3" class="loading-row">Выберите группу</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||||
renderAssignments();
|
||||
} catch (error) {
|
||||
calendarAssignmentsTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignments() {
|
||||
if (!currentAssignments.length) {
|
||||
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Графики не назначены</td></tr>';
|
||||
return;
|
||||
}
|
||||
calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => `
|
||||
<tr>
|
||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
||||
<td>
|
||||
<button class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = '<tr><td colspan="8" class="loading-row">Нет групп</td></tr>';
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(g => `
|
||||
<tr>
|
||||
<td>${g.id}</td>
|
||||
<td>${escapeHtml(g.name)}</td>
|
||||
<td>${escapeHtml(g.groupSize)}</td>
|
||||
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
|
||||
<td>${escapeHtml(departmentLabel(g.departmentId))}</td>
|
||||
<td>${g.course || '-'}</td>
|
||||
<td>${escapeHtml(specialityLabel(g.specialityCode))}</td>
|
||||
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
|
||||
</tr>`).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 }));
|
||||
|
||||
@@ -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 = '<tr><td colspan="8" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
@@ -171,11 +193,10 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function renderRules() {
|
||||
if (!rules || !rules.length) {
|
||||
if (!rules.length) {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Правила расписания не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
rulesTbody.innerHTML = rules.map(rule => `
|
||||
<tr>
|
||||
<td>${rule.id}</td>
|
||||
@@ -195,7 +216,6 @@ export async function initSchedule() {
|
||||
|
||||
function renderRuleSlotsSummary(slots) {
|
||||
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
||||
|
||||
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 = '<div class="loading-row">Нет групп</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = new Set((selectedIds || []).map(String));
|
||||
ruleGroupsContainer.innerHTML = groups.map(group => `
|
||||
<label class="checkbox-item">
|
||||
@@ -330,38 +334,13 @@ export async function initSchedule() {
|
||||
function renderRuleSlots(slots) {
|
||||
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
|
||||
<div class="schedule-slot-row" data-index="${index}">
|
||||
<div class="form-group">
|
||||
<label>День</label>
|
||||
<select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Чётность</label>
|
||||
<select class="slot-parity" required>
|
||||
${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Пара</label>
|
||||
<select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Преподаватель</label>
|
||||
<select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Аудитория</label>
|
||||
<select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип</label>
|
||||
<select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Формат</label>
|
||||
<select class="slot-format" required>
|
||||
${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -381,7 +360,6 @@ export async function initSchedule() {
|
||||
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}`);
|
||||
@@ -401,11 +379,10 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function renderTimeSlots() {
|
||||
if (!timeSlots || !timeSlots.length) {
|
||||
if (!timeSlots.length) {
|
||||
timeSlotsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Сетка пар не настроена</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
timeSlotsTbody.innerHTML = timeSlots.map(slot => `
|
||||
<tr>
|
||||
<td>${slot.orderNumber}</td>
|
||||
@@ -423,7 +400,6 @@ export async function initSchedule() {
|
||||
async function saveTimeSlot(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('time-slot-alert');
|
||||
|
||||
const payload = {
|
||||
id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null,
|
||||
orderNumber: Number(timeSlotOrderInput.value),
|
||||
@@ -431,10 +407,9 @@ export async function initSchedule() {
|
||||
endTime: timeSlotEndInput.value,
|
||||
durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null
|
||||
};
|
||||
|
||||
try {
|
||||
if (!payload.orderNumber || !payload.startTime || !payload.endTime) {
|
||||
throw new Error('Заполните номер пары, начало и окончание');
|
||||
throw new Error('Заполните номер пары и время');
|
||||
}
|
||||
const id = timeSlotIdInput.value;
|
||||
await (id
|
||||
@@ -443,7 +418,6 @@ export async function initSchedule() {
|
||||
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
|
||||
resetTimeSlotForm();
|
||||
await loadTimeSlots();
|
||||
renderRuleSlots(readRuleSlots(false));
|
||||
} catch (error) {
|
||||
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
|
||||
}
|
||||
@@ -452,7 +426,6 @@ 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;
|
||||
@@ -466,7 +439,6 @@ export async function initSchedule() {
|
||||
timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить временной слот?')) return;
|
||||
try {
|
||||
@@ -500,15 +472,14 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function renderAcademicYears() {
|
||||
if (!academicYears || !academicYears.length) {
|
||||
if (!academicYears.length) {
|
||||
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Учебные годы не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
academicYearsTbody.innerHTML = academicYears.map(year => `
|
||||
<tr>
|
||||
<td>${escapeHtml(year.title)}</td>
|
||||
<td>${escapeHtml(year.startDate)} — ${escapeHtml(year.endDate)}</td>
|
||||
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td>
|
||||
<td>${renderSemesterList(year.semesters || [])}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
|
||||
@@ -531,17 +502,13 @@ export async function initSchedule() {
|
||||
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('Заполните название и даты учебного года');
|
||||
}
|
||||
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
|
||||
const id = academicYearIdInput.value;
|
||||
await (id
|
||||
? api.put('/api/admin/calendar/years/' + id, payload)
|
||||
@@ -549,6 +516,7 @@ export async function initSchedule() {
|
||||
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
|
||||
resetAcademicYearForm();
|
||||
await loadYears();
|
||||
await loadCalendars();
|
||||
} catch (error) {
|
||||
showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error');
|
||||
}
|
||||
@@ -558,7 +526,6 @@ export async function initSchedule() {
|
||||
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;
|
||||
@@ -571,7 +538,6 @@ export async function initSchedule() {
|
||||
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (editSemesterButton) {
|
||||
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
||||
if (!semester) return;
|
||||
@@ -586,13 +552,13 @@ export async function initSchedule() {
|
||||
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteYearButton) {
|
||||
if (!confirm('Удалить учебный год вместе с семестрами?')) return;
|
||||
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');
|
||||
}
|
||||
@@ -609,18 +575,14 @@ export async function initSchedule() {
|
||||
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('Заполните учебный год, тип и даты семестра');
|
||||
}
|
||||
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)
|
||||
@@ -641,174 +603,268 @@ export async function initSchedule() {
|
||||
hideAlert('semester-alert');
|
||||
}
|
||||
|
||||
async function saveHoliday(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('holiday-alert');
|
||||
async function loadStudyForms() {
|
||||
studyForms = await api.get('/api/admin/academic-calendars/study-forms');
|
||||
populateStudyFormSelect();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: holidayIdInput.value ? Number(holidayIdInput.value) : null,
|
||||
academicYearId: Number(holidayYearSelect.value),
|
||||
date: holidayDateInput.value,
|
||||
description: holidayDescriptionInput.value.trim()
|
||||
};
|
||||
async function loadActivityTypes() {
|
||||
activityTypes = await api.get('/api/admin/calendar/activity-types');
|
||||
populateActivitySelects();
|
||||
}
|
||||
|
||||
async function loadCalendars() {
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
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();
|
||||
calendars = await api.get('/api/admin/academic-calendars');
|
||||
renderCalendars();
|
||||
populateEditorCalendarSelect();
|
||||
} catch (error) {
|
||||
showAlert('holiday-alert', error.message || 'Ошибка сохранения даты', 'error');
|
||||
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHolidays() {
|
||||
const yearId = holidayYearSelect.value;
|
||||
if (!yearId) {
|
||||
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите учебный год</td></tr>';
|
||||
function renderCalendars() {
|
||||
if (!calendars.length) {
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
holidays = await api.get('/api/admin/calendar/holidays?academicYearId=' + encodeURIComponent(yearId));
|
||||
renderHolidays();
|
||||
} catch (error) {
|
||||
holidaysTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHolidays() {
|
||||
if (!holidays || !holidays.length) {
|
||||
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Исключения не добавлены</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
holidaysTbody.innerHTML = holidays.map(holiday => `
|
||||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||||
<tr>
|
||||
<td>${escapeHtml(holiday.date)}</td>
|
||||
<td>${escapeHtml(holiday.description || '-')}</td>
|
||||
<td>${escapeHtml(calendar.title)}</td>
|
||||
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
|
||||
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
|
||||
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
|
||||
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
|
||||
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-holiday" data-id="${holiday.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-holiday" data-id="${holiday.id}">Удалить</button>
|
||||
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||||
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||||
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleHolidayTableClick(event) {
|
||||
const editButton = event.target.closest('.btn-edit-holiday');
|
||||
const deleteButton = event.target.closest('.btn-delete-holiday');
|
||||
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 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' });
|
||||
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;
|
||||
if (!confirm('Удалить календарный график?')) return;
|
||||
try {
|
||||
await api.delete('/api/admin/calendar/holidays/' + deleteButton.dataset.id);
|
||||
showAlert('holiday-alert', 'Дата удалена', 'success');
|
||||
await loadHolidays();
|
||||
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
|
||||
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
||||
await loadCalendars();
|
||||
} catch (error) {
|
||||
showAlert('holiday-alert', error.message || 'Ошибка удаления даты', 'error');
|
||||
showAlert('academic-calendar-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');
|
||||
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 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');
|
||||
async function loadCalendarGrid() {
|
||||
hideAlert('calendar-editor-alert');
|
||||
const calendar = selectedEditorCalendar();
|
||||
if (!calendar) {
|
||||
showAlert('calendar-editor-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');
|
||||
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('matrix-alert', error.message || 'Ошибка загрузки матрицы', 'error');
|
||||
showAlert('calendar-editor-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
|
||||
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('matrix-alert', 'Сначала загрузите матрицу', 'error');
|
||||
showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put('/api/admin/calendar/matrix', rows);
|
||||
showAlert('matrix-alert', 'Матрица учебного графика сохранена', 'success');
|
||||
await loadMatrix();
|
||||
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
|
||||
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
|
||||
await loadCalendarGrid();
|
||||
} catch (error) {
|
||||
showAlert('matrix-alert', error.message || 'Ошибка сохранения матрицы', 'error');
|
||||
showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMatrix(rows) {
|
||||
matrixGrid.innerHTML = rows.map(row => `
|
||||
<div class="matrix-week"
|
||||
data-id="${row.id || ''}"
|
||||
data-semester-id="${row.semesterId}"
|
||||
data-course-number="${row.courseNumber}"
|
||||
data-specialty-id="${row.specialtyId}"
|
||||
data-week-number="${row.weekNumber}">
|
||||
<span>${row.weekNumber}</span>
|
||||
<select>${options(activityOptions(), row.activityType || 'THEORY')}</select>
|
||||
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]) => `
|
||||
<section class="calendar-course-block">
|
||||
<div class="calendar-course-header">
|
||||
<h3>${course} курс</h3>
|
||||
<span>${courseRows.length} дней</span>
|
||||
</div>
|
||||
<div class="calendar-days-grid">
|
||||
${courseRows.map(renderCalendarDay).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`).join('');
|
||||
populateFillCourseSelect();
|
||||
renderCalendarTotals();
|
||||
}
|
||||
|
||||
function buildDefaultMatrixRows(semesterId, courseNumber, specialtyId) {
|
||||
return Array.from({ length: 22 }, (_, index) => ({
|
||||
function renderCalendarDay(row) {
|
||||
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
|
||||
const dayName = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'][Number(row.dayOfWeek) - 1] || '';
|
||||
return `
|
||||
<div class="calendar-day"
|
||||
data-course="${row.courseNumber}"
|
||||
data-date="${row.date}"
|
||||
data-week="${row.weekNumber}"
|
||||
data-day="${row.dayOfWeek}">
|
||||
<div class="calendar-day-meta">
|
||||
<span>${formatShortDate(row.date)}</span>
|
||||
<span>${dayName}</span>
|
||||
</div>
|
||||
<select class="calendar-day-activity">${options(activityTypes.map(toActivityOption), selected)}</select>
|
||||
<span class="calendar-week-label">${row.weekNumber} нед.</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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,
|
||||
semesterId: Number(semesterId),
|
||||
courseNumber,
|
||||
specialtyId: Number(specialtyId),
|
||||
weekNumber: index + 1,
|
||||
activityType: 'THEORY'
|
||||
}));
|
||||
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 `<span class="calendar-total-chip" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateSubjectSelects() {
|
||||
@@ -820,26 +876,66 @@ export async function initSchedule() {
|
||||
const yearOptions = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
semesterYearSelect.innerHTML = yearOptions;
|
||||
holidayYearSelect.innerHTML = yearOptions;
|
||||
|
||||
if (!holidayYearSelect.value && academicYears.length) {
|
||||
holidayYearSelect.value = String(academicYears[0].id);
|
||||
}
|
||||
syncSelects(semesterYearSelect, holidayYearSelect);
|
||||
calendarYearSelect.innerHTML = yearOptions;
|
||||
syncSelects(semesterYearSelect, calendarYearSelect);
|
||||
}
|
||||
|
||||
function populateSemesterSelects() {
|
||||
const semesterOptions = '<option value="">Выберите семестр</option>' +
|
||||
semesters.map(semester => `<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>`).join('');
|
||||
ruleSemesterSelect.innerHTML = semesterOptions;
|
||||
matrixSemesterSelect.innerHTML = semesterOptions;
|
||||
syncSelects(ruleSemesterSelect, matrixSemesterSelect);
|
||||
syncSelects(ruleSemesterSelect);
|
||||
}
|
||||
|
||||
function populateSpecialtySelects() {
|
||||
matrixSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||||
calendarSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||||
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
|
||||
syncSelects(matrixSpecialtySelect);
|
||||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
|
||||
syncSelects(calendarSpecialtySelect);
|
||||
}
|
||||
|
||||
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
|
||||
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
|
||||
select.innerHTML = profiles.length
|
||||
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
|
||||
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет профилей</option>';
|
||||
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
|
||||
select.value = selectedProfileId;
|
||||
}
|
||||
syncSelects(select);
|
||||
}
|
||||
|
||||
function populateStudyFormSelect() {
|
||||
calendarStudyFormSelect.innerHTML = '<option value="">Выберите форму</option>' +
|
||||
studyForms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||||
syncSelects(calendarStudyFormSelect);
|
||||
}
|
||||
|
||||
function populateActivitySelects() {
|
||||
const activityOptions = '<option value="">Выберите код</option>' +
|
||||
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
|
||||
fillActivitySelect.innerHTML = activityOptions;
|
||||
syncSelects(fillActivitySelect);
|
||||
}
|
||||
|
||||
function populateEditorCalendarSelect() {
|
||||
const current = editorCalendarSelect.value;
|
||||
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
||||
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).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 = '<option value="">Все курсы</option>' +
|
||||
Array.from(courses).sort((a, b) => Number(a) - Number(b))
|
||||
.map(course => `<option value="${course}">${course} курс</option>`).join('');
|
||||
syncSelects(fillCourseSelect);
|
||||
}
|
||||
|
||||
function options(items, selectedValue) {
|
||||
@@ -850,8 +946,8 @@ export async function initSchedule() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function activityOptions() {
|
||||
return Object.entries(ACTIVITY_LABELS).map(([value, label]) => ({ value, label }));
|
||||
function toActivityOption(type) {
|
||||
return { value: type.id, label: `${type.code} - ${type.name}` };
|
||||
}
|
||||
|
||||
function toTimeSlotOption(slot) {
|
||||
@@ -871,9 +967,25 @@ 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})`;
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||
}
|
||||
|
||||
function formatSemester(rule) {
|
||||
@@ -888,7 +1000,7 @@ export async function initSchedule() {
|
||||
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);
|
||||
return name ? `${code} - ${name}` : String(code);
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
@@ -896,6 +1008,34 @@ export async function initSchedule() {
|
||||
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 }));
|
||||
|
||||
@@ -55,20 +55,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Container -->
|
||||
<!-- Контейнер таблицы -->
|
||||
<div class="workload-grid-container">
|
||||
<table class="workload-table" id="workload-table">
|
||||
<thead>
|
||||
<tr id="workload-header-row">
|
||||
<th class="top-left-cell">
|
||||
<span class="top-label">Аудитория</span>
|
||||
<span class="bottom-label">Время</span>
|
||||
<span class="top-label">Время</span>
|
||||
<span class="bottom-label">Аудитория</span>
|
||||
</th>
|
||||
<!-- Rendered by JS -->
|
||||
<!-- Заполняется через JS -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workload-tbody">
|
||||
<!-- Rendered by JS -->
|
||||
<!-- Заполняется через JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2>Профили обучения</h2>
|
||||
<form id="create-profile-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="profile-specialty">Специальность</label>
|
||||
<select id="profile-specialty" required>
|
||||
<option value="">Сначала создайте специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profile-name">Название профиля</label>
|
||||
<input type="text" id="profile-name" placeholder="Например: Безопасность автоматизированных систем" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profile-description">Описание</label>
|
||||
<input type="text" id="profile-description" placeholder="Необязательно">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать профиль</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-profile-alert" role="alert"></div>
|
||||
</form>
|
||||
<div class="table-wrap schedule-inline-table">
|
||||
<table id="profiles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Профиль</th>
|
||||
<th>Описание</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="profiles-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row">Выберите специальность</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2>Создание специальности</h2>
|
||||
<form id="create-specialty-form">
|
||||
@@ -124,3 +165,25 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modal-edit-profile">
|
||||
<div class="modal-content card">
|
||||
<h2>Редактирование профиля</h2>
|
||||
<button class="modal-close" id="modal-edit-profile-close">×</button>
|
||||
<form id="edit-profile-form">
|
||||
<input type="hidden" id="edit-profile-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-profile-name">Название профиля</label>
|
||||
<input type="text" id="edit-profile-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-profile-description">Описание</label>
|
||||
<input type="text" id="edit-profile-description">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-profile-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- ===== Groups Tab ===== -->
|
||||
<!-- ===== Вкладка групп ===== -->
|
||||
<div class="card create-card">
|
||||
<h2>Новая группа</h2>
|
||||
<form id="create-group-form">
|
||||
@@ -33,6 +33,12 @@
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-profile">Профиль обучения</label>
|
||||
<select id="new-group-profile">
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-group-alert" role="alert"></div>
|
||||
@@ -60,14 +66,114 @@
|
||||
<th>Кафедра</th>
|
||||
<th>Курс</th>
|
||||
<th>Специальность</th>
|
||||
<th>Профиль</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-tbody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading-row">Загрузка...</td>
|
||||
<td colspan="9" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2>Назначение календарного графика</h2>
|
||||
<form id="group-calendar-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-group">Группа</label>
|
||||
<select id="calendar-group">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-year">Учебный год</label>
|
||||
<select id="calendar-year">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-id">Календарный график</label>
|
||||
<select id="calendar-id">
|
||||
<option value="">Выберите группу и год</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Назначить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="group-calendar-alert" role="alert"></div>
|
||||
</form>
|
||||
<div class="table-wrap schedule-inline-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Учебный год</th>
|
||||
<th>График</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="group-calendar-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading-row">Выберите группу</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования группы -->
|
||||
<div class="modal-overlay" id="modal-edit-group">
|
||||
<div class="modal-content card">
|
||||
<h2>Редактировать группу</h2>
|
||||
<button class="modal-close" id="modal-edit-group-close">×</button>
|
||||
<form id="edit-group-form">
|
||||
<input type="hidden" id="edit-group-id">
|
||||
<div class="form-row" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="edit-group-name">Название группы</label>
|
||||
<input type="text" id="edit-group-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-size">Численность группы</label>
|
||||
<input type="number" id="edit-group-size" min="1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-ef">Форма обучения</label>
|
||||
<select id="edit-group-ef">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="edit-group-department">Кафедра</label>
|
||||
<select id="edit-group-department">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-yearStartStudy">Год начала обучения</label>
|
||||
<input type="number" id="edit-group-yearStartStudy" min="2000" max="2100" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-speciality-code">Специальность</label>
|
||||
<select id="edit-group-speciality-code">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-group-profile">Профиль обучения</label>
|
||||
<select id="edit-group-profile">
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn-primary" style="width: 100%;">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-group-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,41 +213,72 @@
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2 id="holiday-form-title">Праздники и исключения</h2>
|
||||
<form id="holiday-form" novalidate>
|
||||
<input type="hidden" id="holiday-id">
|
||||
<div class="card-header-row">
|
||||
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
|
||||
<button type="button" class="btn-edit-classroom" id="academic-calendar-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-calendar-form" novalidate>
|
||||
<input type="hidden" id="academic-calendar-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="holiday-year">Учебный год</label>
|
||||
<select id="holiday-year" required>
|
||||
<label for="academic-calendar-title">Название</label>
|
||||
<input type="text" id="academic-calendar-title" placeholder="09.03.04 очная форма 2025-2026" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-year">Учебный год</label>
|
||||
<select id="academic-calendar-year" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="holiday-date">Дата</label>
|
||||
<input type="date" id="holiday-date" required>
|
||||
<label for="academic-calendar-specialty">Специальность</label>
|
||||
<select id="academic-calendar-specialty" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="holiday-description">Описание</label>
|
||||
<input type="text" id="holiday-description" placeholder="Праздничный день">
|
||||
<label for="academic-calendar-profile">Профиль</label>
|
||||
<select id="academic-calendar-profile" required>
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить дату</button>
|
||||
<button type="button" class="btn-edit-classroom" id="holiday-reset">Очистить</button>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-study-form">Форма обучения</label>
|
||||
<select id="academic-calendar-study-form" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-alert" id="holiday-alert" role="alert"></div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendar-course-count">Курсов</label>
|
||||
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить график</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
|
||||
</form>
|
||||
<div class="table-wrap schedule-inline-table">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Календарные графики</h2>
|
||||
<button class="btn-primary" id="academic-calendars-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Описание</th>
|
||||
<th>Название</th>
|
||||
<th>Учебный год</th>
|
||||
<th>Специальность</th>
|
||||
<th>Профиль</th>
|
||||
<th>Форма</th>
|
||||
<th>Курсов</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="holidays-tbody">
|
||||
<tbody id="academic-calendars-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading-row">Выберите учебный год</td>
|
||||
<td colspan="7" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -255,27 +286,41 @@
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2>Матрица учебного графика</h2>
|
||||
<h2>Редактор годового графика</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="matrix-semester">Семестр</label>
|
||||
<select id="matrix-semester">
|
||||
<label for="calendar-editor-calendar">График</label>
|
||||
<select id="calendar-editor-calendar">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="calendar-editor-load">Загрузить сетку</button>
|
||||
<button type="button" class="btn-primary" id="calendar-editor-save">Сохранить сетку</button>
|
||||
</div>
|
||||
<div class="calendar-fill-panel">
|
||||
<div class="form-group">
|
||||
<label for="matrix-course">Курс</label>
|
||||
<input type="number" id="matrix-course" min="1" step="1" placeholder="1">
|
||||
<label for="calendar-fill-course">Курс</label>
|
||||
<select id="calendar-fill-course">
|
||||
<option value="">Все курсы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="matrix-specialty">Специальность</label>
|
||||
<select id="matrix-specialty">
|
||||
<label for="calendar-fill-start">С даты</label>
|
||||
<input type="date" id="calendar-fill-start">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-fill-end">По дату</label>
|
||||
<input type="date" id="calendar-fill-end">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-fill-activity">Код</label>
|
||||
<select id="calendar-fill-activity">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="matrix-load">Загрузить</button>
|
||||
<button type="button" class="btn-primary" id="matrix-save">Сохранить</button>
|
||||
<button type="button" class="btn-edit-classroom" id="calendar-fill-apply">Заполнить диапазон</button>
|
||||
</div>
|
||||
<div class="form-alert" id="matrix-alert" role="alert"></div>
|
||||
<div id="matrix-grid" class="matrix-grid"></div>
|
||||
<div class="form-alert" id="calendar-editor-alert" role="alert"></div>
|
||||
<div id="calendar-totals" class="calendar-totals"></div>
|
||||
<div id="calendar-grid" class="academic-calendar-grid"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user