начало работы построения динамического расписания и документация
This commit is contained in:
21
.codex/config.toml
Normal file
21
.codex/config.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
#:schema https://developers.openai.com/codex/config-schema.json
|
||||
|
||||
web_search = "live"
|
||||
|
||||
developer_instructions = """
|
||||
Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент.
|
||||
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
|
||||
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
|
||||
Используй проектные скиллы из .agents/skills, когда задача соответствует их description.
|
||||
"""
|
||||
|
||||
[skills]
|
||||
include_instructions = true
|
||||
|
||||
[[skills.config]]
|
||||
path = "../.agents/skills/auto-update-docs"
|
||||
enabled = true
|
||||
|
||||
[[skills.config]]
|
||||
path = "../.agents/skills/frontend-design"
|
||||
enabled = true
|
||||
317
DYNAMIC_SCHEDULE_IMPLEMENTATION.md
Normal file
317
DYNAMIC_SCHEDULE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Реализация динамического расписания
|
||||
|
||||
Этот файл фиксирует текущий результат внедрения динамической системы расписания: что уже добавлено в проект, как связаны база данных, backend, API и frontend, как работает генератор расписания и какие ограничения остаются для следующего этапа.
|
||||
|
||||
## Статус внедрения
|
||||
|
||||
Реализован первый рабочий срез динамического расписания. Новая модель теперь является базовой схемой проекта и создаётся сразу в `V1__init.sql`.
|
||||
|
||||
Старые таблицы `lessons` и `schedule_data` пока физически остаются в `V1__init.sql`, потому что связанный старый код будет удаляться отдельным шагом. Они больше не заполняются тестовыми данными и не являются источником для новой модели расписания.
|
||||
|
||||
## Что добавлено
|
||||
|
||||
### База данных
|
||||
|
||||
Динамическая схема встроена в базовую Flyway-миграцию `V1__init.sql`.
|
||||
|
||||
Она создаёт следующие таблицы:
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---------|------------|
|
||||
| `time_slots` | Настраиваемая сетка пар: номер, время начала, время окончания, длительность |
|
||||
| `academic_years` | Учебные годы |
|
||||
| `semesters` | Семестры учебного года, от `start_date` считается первая неделя |
|
||||
| `holidays` | Праздники и исключённые даты, когда занятия не проводятся |
|
||||
| `academic_calendar_matrix` | Матрица учебного графика по семестру, курсу, специальности и неделе |
|
||||
| `schedule_rules` | Правила проведения дисциплины с лимитом академических часов |
|
||||
| `schedule_rule_groups` | Привязка правила к одной или нескольким группам |
|
||||
| `schedule_rule_slots` | Конкретные шаблонные слоты правила: день, чётность, пара, преподаватель, аудитория, тип и формат |
|
||||
|
||||
Миграция также создаёт тестовые данные сразу в новой модели:
|
||||
|
||||
1. Создаёт базовые временные слоты.
|
||||
2. Создаёт учебные годы `2024-2025` и `2025-2026`.
|
||||
3. Заполняет матрицу учебного графика базовым статусом `THEORY`.
|
||||
4. Создаёт тестовые `schedule_rules` для групп и дисциплин.
|
||||
5. Создаёт привязки групп в `schedule_rule_groups`, включая потоковую лекцию.
|
||||
6. Создаёт шаблонные занятия в `schedule_rule_slots`.
|
||||
|
||||
### Backend
|
||||
|
||||
Добавлены JPA-модели для новой схемы:
|
||||
|
||||
- `TimeSlot`
|
||||
- `AcademicYear`
|
||||
- `Semester`
|
||||
- `Holiday`
|
||||
- `AcademicCalendarMatrix`
|
||||
- `ScheduleRule`
|
||||
- `ScheduleRuleSlot`
|
||||
- `AcademicActivityType`
|
||||
- `ScheduleParity`
|
||||
|
||||
Добавлены DTO для API:
|
||||
|
||||
- `TimeSlotDto`
|
||||
- `AcademicYearDto`
|
||||
- `SemesterDto`
|
||||
- `HolidayDto`
|
||||
- `AcademicCalendarMatrixDto`
|
||||
- `ScheduleRuleDto`
|
||||
- `ScheduleRuleSlotDto`
|
||||
- `RenderedLessonDto`
|
||||
|
||||
Добавлены репозитории для новых таблиц:
|
||||
|
||||
- `TimeSlotRepository`
|
||||
- `AcademicYearRepository`
|
||||
- `SemesterRepository`
|
||||
- `HolidayRepository`
|
||||
- `AcademicCalendarMatrixRepository`
|
||||
- `ScheduleRuleRepository`
|
||||
- `ScheduleRuleSlotRepository`
|
||||
|
||||
Добавлены сервисы:
|
||||
|
||||
| Сервис | Назначение |
|
||||
|--------|------------|
|
||||
| `AcademicDateService` | Календарная математика: семестр по дате, номер недели, чётность, праздник, курс группы, тип активности |
|
||||
| `ScheduleGeneratorService` | Генерация расписания группы или преподавателя на диапазон дат |
|
||||
|
||||
Старый `LessonsController` пока физически присутствует в коде и помечен как `@Deprecated`. Это технический хвост до удаления старых экранов и таблиц, а не целевой слой совместимости.
|
||||
|
||||
### API
|
||||
|
||||
Добавлен новый endpoint просмотра расписания:
|
||||
|
||||
```http
|
||||
GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
|
||||
```
|
||||
|
||||
Правило запроса: нужно передать ровно один идентификатор — `groupId` или `teacherId`.
|
||||
|
||||
Ответ — массив `RenderedLessonDto`, где у каждого занятия есть:
|
||||
|
||||
- полная дата занятия;
|
||||
- номер дня недели;
|
||||
- название дня;
|
||||
- номер недели семестра;
|
||||
- чётность;
|
||||
- временной слот;
|
||||
- дисциплина;
|
||||
- преподаватель;
|
||||
- аудитория;
|
||||
- тип и формат занятия;
|
||||
- группы;
|
||||
- лимит часов правила;
|
||||
- уже списанные часы перед занятием;
|
||||
- остаток часов после занятия.
|
||||
|
||||
Добавлены административные API:
|
||||
|
||||
| API | Назначение |
|
||||
|-----|------------|
|
||||
| `/api/admin/time-slots` | CRUD временных слотов |
|
||||
| `/api/admin/calendar/years` | CRUD учебных годов |
|
||||
| `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года |
|
||||
| `/api/admin/calendar/semesters/{id}` | Обновление семестра |
|
||||
| `/api/admin/calendar/holidays` | CRUD праздников |
|
||||
| `/api/admin/calendar/matrix` | Получение и массовое сохранение матрицы учебного графика |
|
||||
| `/api/admin/schedule-rules` | CRUD правил расписания |
|
||||
|
||||
### Авторизация
|
||||
|
||||
Ответ `POST /api/auth/login` расширен полем `userId`.
|
||||
|
||||
Frontend сохраняет его в `localStorage`, чтобы кабинет преподавателя мог запрашивать личное расписание через:
|
||||
|
||||
```http
|
||||
GET /api/schedule?teacherId={userId}&startDate=...&endDate=...
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
Заглушки кабинетов заменены на базовые экраны просмотра расписания.
|
||||
|
||||
#### Кабинет студента
|
||||
|
||||
Файл: `frontend/student/index.html`.
|
||||
|
||||
Текущая модель пользователя-студента не содержит прямой связи с учебной группой, поэтому экран работает через выбор группы:
|
||||
|
||||
1. Загружает список групп из `/api/groups`.
|
||||
2. Сохраняет выбранную группу в `localStorage.studentGroupId`.
|
||||
3. Строит недельный диапазон дат.
|
||||
4. Запрашивает расписание через `GET /api/schedule?groupId=...`.
|
||||
5. Показывает занятия по дням недели.
|
||||
|
||||
#### Кабинет преподавателя
|
||||
|
||||
Файл: `frontend/teacher/index.html`.
|
||||
|
||||
Экран использует `localStorage.userId`, который появляется после нового ответа авторизации:
|
||||
|
||||
1. Определяет текущую неделю.
|
||||
2. Позволяет переключать неделю стрелками или через выбор даты.
|
||||
3. Запрашивает расписание через `GET /api/schedule?teacherId=...`.
|
||||
4. Показывает занятия преподавателя в недельной сетке.
|
||||
5. В занятии отображаются дисциплина, время, тип, аудитория и группы.
|
||||
|
||||
## Как работает генерация расписания
|
||||
|
||||
Генерация выполняется на backend по запросу клиента. Фактические занятия не хранятся отдельными строками на каждую дату, а вычисляются из правил.
|
||||
|
||||
### Расписание группы
|
||||
|
||||
Алгоритм `buildScheduleForGroup(groupId, startDate, endDate)`:
|
||||
|
||||
1. Проверяет корректность диапазона дат.
|
||||
2. Находит группу.
|
||||
3. Для каждой даты диапазона определяет семестр.
|
||||
4. Вычисляет номер недели семестра.
|
||||
5. Вычисляет текущий курс группы по `year_start_study`.
|
||||
6. Проверяет матрицу учебного графика.
|
||||
7. Пропускает дату, если это праздник или не `THEORY`.
|
||||
8. Загружает правила группы для найденного семестра.
|
||||
9. Для каждого правила считает, сколько академических часов уже проведено до текущей даты.
|
||||
10. Если лимит `total_academic_hours` ещё не исчерпан, проецирует подходящие слоты правила на текущую дату.
|
||||
|
||||
### Расписание преподавателя
|
||||
|
||||
Алгоритм `buildScheduleForTeacher(teacherId, startDate, endDate)` похож, но правила ищутся не по группе, а по преподавателю в `schedule_rule_slots.teacher_id`.
|
||||
|
||||
В ответе занятие дополнительно содержит все группы, привязанные к правилу. Это поддерживает потоковые лекции.
|
||||
|
||||
### Чётность недели
|
||||
|
||||
Чётность вычисляется от `semesters.start_date`.
|
||||
|
||||
Настройка:
|
||||
|
||||
```properties
|
||||
app.schedule.even-week-is-upper=false
|
||||
```
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- нечётная неделя семестра соответствует `ODD`;
|
||||
- чётная неделя семестра соответствует `EVEN`.
|
||||
|
||||
Если `app.schedule.even-week-is-upper=true`, соответствие инвертируется.
|
||||
|
||||
### Праздники
|
||||
|
||||
Праздник считается пропуском занятия:
|
||||
|
||||
- занятие не отображается в расписании;
|
||||
- академические часы не списываются;
|
||||
- дисциплина фактически продолжается дольше, пока не будет исчерпан лимит часов.
|
||||
|
||||
### Лимит часов
|
||||
|
||||
Каждый `schedule_rules.total_academic_hours` задаёт общий лимит академических часов дисциплины.
|
||||
|
||||
Генератор симулирует период от `active_from_date` до запрошенной даты и считает только реально проведённые слоты:
|
||||
|
||||
- один слот = 2 академических часа;
|
||||
- праздники не считаются;
|
||||
- недели с активностью не `THEORY` не считаются;
|
||||
- после достижения лимита правило перестаёт выводиться.
|
||||
|
||||
## Поток данных
|
||||
|
||||
Новая базовая БД сразу создаёт рабочую модель так:
|
||||
|
||||
```text
|
||||
V1__init.sql
|
||||
-> time_slots
|
||||
-> academic_years / semesters / holidays / academic_calendar_matrix
|
||||
-> schedule_rules
|
||||
-> schedule_rule_groups
|
||||
-> schedule_rule_slots
|
||||
```
|
||||
|
||||
Для просмотра:
|
||||
|
||||
```text
|
||||
frontend
|
||||
-> GET /api/schedule
|
||||
-> ScheduleController
|
||||
-> ScheduleGeneratorService
|
||||
-> schedule_rules + schedule_rule_slots + calendar tables
|
||||
-> RenderedLessonDto[]
|
||||
```
|
||||
|
||||
## Что проверено
|
||||
|
||||
Проверка backend:
|
||||
|
||||
```bash
|
||||
docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile
|
||||
```
|
||||
|
||||
Результат: компиляция backend прошла успешно.
|
||||
|
||||
Проверка миграции:
|
||||
|
||||
1. Поднят временный PostgreSQL-контейнер на образе `postgres:alpine3.23`.
|
||||
2. Применён `V1__init.sql`.
|
||||
3. Проверены контрольные количества строк новой модели.
|
||||
|
||||
Контрольный результат:
|
||||
|
||||
| Объект | Количество |
|
||||
|--------|------------|
|
||||
| `schedule_rules` | 4 |
|
||||
| `schedule_rule_groups` | 5 |
|
||||
| `schedule_rule_slots` | 4 |
|
||||
| `time_slots` | 7 |
|
||||
| `academic_calendar_matrix` | 132 |
|
||||
| `lessons` | 0 |
|
||||
| `schedule_data` | 0 |
|
||||
|
||||
Также выполнен:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Результат: замечаний по whitespace нет.
|
||||
|
||||
## Ограничения текущего среза
|
||||
|
||||
Этот срез создаёт рабочее ядро динамического расписания, но ещё не закрывает весь продуктовый объём.
|
||||
|
||||
Ограничения:
|
||||
|
||||
- полноценный UI конструктора правил в админке ещё не реализован;
|
||||
- визуальный редактор матрицы учебного графика в админке ещё не реализован;
|
||||
- UI управления праздниками и учебными годами пока доступен только через API;
|
||||
- строгая проверка конфликтов преподавателей, аудиторий и групп при сохранении правил ещё не реализована;
|
||||
- студент пока выбирает группу вручную, потому что в модели пользователя нет связи `student -> group`;
|
||||
- старые таблицы и контроллеры `lessons` / `schedule_data` ещё физически есть, но больше не используются для seed-расписания.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
Рекомендуемый следующий этап:
|
||||
|
||||
1. Добавить в админку вкладку настройки временных слотов.
|
||||
2. Добавить UI учебных годов, семестров и праздников.
|
||||
3. Добавить визуальную матрицу учебного графика.
|
||||
4. Добавить конструктор правил расписания.
|
||||
5. Реализовать backend-валидацию конфликтов:
|
||||
- преподаватель не может вести разные занятия одновременно;
|
||||
- аудитория не может быть занята двумя разными занятиями одновременно;
|
||||
- группа не может быть на двух занятиях одновременно, кроме корректного деления по подгруппам.
|
||||
6. Удалить старые экраны, контроллеры, DTO, модели и репозитории `lessons` / `schedule_data`.
|
||||
7. Удалить старые таблицы `lessons` и `schedule_data` из `V1__init.sql`.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `SCHEDULE_PROPOSAL.md` — исходная концепция динамического расписания.
|
||||
- `SCHEDULE_TASKS.md` — исходная декомпозиция задач.
|
||||
- `docs/API.md` — описание REST API.
|
||||
- `docs/DATABASE.md` — описание схемы БД.
|
||||
- `docs/BUSINESS_LOGIC.md` — бизнес-правила.
|
||||
- `docs/FRONTEND.md` — frontend-архитектура.
|
||||
@@ -0,0 +1,317 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/calendar")
|
||||
public class AcademicCalendarAdminController {
|
||||
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final HolidayRepository holidayRepository;
|
||||
private final AcademicCalendarMatrixRepository calendarMatrixRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
HolidayRepository holidayRepository,
|
||||
AcademicCalendarMatrixRepository calendarMatrixRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.holidayRepository = holidayRepository;
|
||||
this.calendarMatrixRepository = calendarMatrixRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping("/years")
|
||||
public List<AcademicYearDto> getYears() {
|
||||
return academicYearRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(AcademicYear::getStartDate).reversed())
|
||||
.map(this::toAcademicYearDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/years")
|
||||
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, request);
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
}
|
||||
|
||||
@PutMapping("/years/{id}")
|
||||
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(id).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applyYear(year, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/years/{id}")
|
||||
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
|
||||
if (!academicYearRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
academicYearRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
|
||||
}
|
||||
|
||||
@GetMapping("/years/{academicYearId}/semesters")
|
||||
public List<SemesterDto> getSemesters(@PathVariable Long academicYearId) {
|
||||
return semesterRepository.findByAcademicYearIdOrderByStartDateAsc(academicYearId).stream()
|
||||
.map(this::toSemesterDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/years/{academicYearId}/semesters")
|
||||
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
|
||||
}
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
}
|
||||
|
||||
@PutMapping("/semesters/{id}")
|
||||
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
|
||||
Semester semester = semesterRepository.findById(id).orElse(null);
|
||||
if (semester == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
}
|
||||
|
||||
@GetMapping("/holidays")
|
||||
public ResponseEntity<?> getHolidays(@RequestParam Long academicYearId) {
|
||||
if (!academicYearRepository.existsById(academicYearId)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
return ResponseEntity.ok(holidayRepository.findByAcademicYearIdOrderByDateAsc(academicYearId).stream()
|
||||
.map(this::toHolidayDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/holidays")
|
||||
public ResponseEntity<?> createHoliday(@RequestBody HolidayDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(request.academicYearId()).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
if (request.date() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна"));
|
||||
}
|
||||
if (holidayRepository.existsByAcademicYearIdAndDate(year.getId(), request.date())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Эта дата уже добавлена в исключения"));
|
||||
}
|
||||
|
||||
Holiday holiday = new Holiday();
|
||||
holiday.setAcademicYear(year);
|
||||
holiday.setDate(request.date());
|
||||
holiday.setDescription(request.description());
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday)));
|
||||
}
|
||||
|
||||
@PutMapping("/holidays/{id}")
|
||||
public ResponseEntity<?> updateHoliday(@PathVariable Long id, @RequestBody HolidayDto request) {
|
||||
Holiday holiday = holidayRepository.findById(id).orElse(null);
|
||||
if (holiday == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (request.date() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна"));
|
||||
}
|
||||
holiday.setDate(request.date());
|
||||
holiday.setDescription(request.description());
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/holidays/{id}")
|
||||
public ResponseEntity<?> deleteHoliday(@PathVariable Long id) {
|
||||
if (!holidayRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
holidayRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Праздник удалён"));
|
||||
}
|
||||
|
||||
@GetMapping("/matrix")
|
||||
public ResponseEntity<?> getMatrix(@RequestParam Long semesterId,
|
||||
@RequestParam Integer courseNumber,
|
||||
@RequestParam Long specialtyId) {
|
||||
if (!semesterRepository.existsById(semesterId)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Семестр не найден"));
|
||||
}
|
||||
return ResponseEntity.ok(calendarMatrixRepository
|
||||
.findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc(semesterId, courseNumber, specialtyId)
|
||||
.stream()
|
||||
.map(this::toMatrixDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PutMapping("/matrix")
|
||||
@Transactional
|
||||
public ResponseEntity<?> saveMatrix(@RequestBody List<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", "Матрица учебного графика сохранена"));
|
||||
}
|
||||
|
||||
private String validateYear(AcademicYearDto request) {
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
return "Название учебного года обязательно";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания учебного года обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateSemester(SemesterDto request) {
|
||||
if (request.semesterType() == null) {
|
||||
return "Тип семестра обязателен";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания семестра обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания семестра не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyYear(AcademicYear year, AcademicYearDto request) {
|
||||
year.setTitle(request.title().trim());
|
||||
year.setStartDate(request.startDate());
|
||||
year.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
semester.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private AcademicYearDto toAcademicYearDto(AcademicYear year) {
|
||||
List<SemesterDto> semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId())
|
||||
.stream()
|
||||
.map(this::toSemesterDto)
|
||||
.toList();
|
||||
return new AcademicYearDto(year.getId(), year.getTitle(), year.getStartDate(), year.getEndDate(), semesters);
|
||||
}
|
||||
|
||||
private SemesterDto toSemesterDto(Semester semester) {
|
||||
return new SemesterDto(
|
||||
semester.getId(),
|
||||
semester.getAcademicYear().getId(),
|
||||
semester.getAcademicYear().getTitle(),
|
||||
semester.getSemesterType(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
);
|
||||
}
|
||||
|
||||
private HolidayDto toHolidayDto(Holiday holiday) {
|
||||
return new HolidayDto(
|
||||
holiday.getId(),
|
||||
holiday.getDate(),
|
||||
holiday.getAcademicYear().getId(),
|
||||
holiday.getAcademicYear().getTitle(),
|
||||
holiday.getDescription()
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicCalendarMatrixDto toMatrixDto(AcademicCalendarMatrix matrix) {
|
||||
return new AcademicCalendarMatrixDto(
|
||||
matrix.getId(),
|
||||
matrix.getSemester().getId(),
|
||||
matrix.getCourseNumber(),
|
||||
matrix.getSpecialtyId(),
|
||||
matrix.getWeekNumber(),
|
||||
matrix.getActivityType()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,6 @@ public class AuthController {
|
||||
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
||||
Long departmentId = user.getDepartmentId();
|
||||
|
||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId));
|
||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ public class LessonsController {
|
||||
}
|
||||
|
||||
//Запрос для получения всего списка занятий
|
||||
@Deprecated
|
||||
@GetMapping
|
||||
public List<LessonResponse> getAllLessons() {
|
||||
logger.info("Запрос на получение всех занятий");
|
||||
@@ -221,6 +222,7 @@ public class LessonsController {
|
||||
}
|
||||
|
||||
//Запрос на получение всех занятий для конкретного преподавателя
|
||||
@Deprecated
|
||||
@GetMapping("/{teacherId}")
|
||||
public ResponseEntity<?> getLessonsById(@PathVariable Long teacherId) {
|
||||
logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId);
|
||||
@@ -504,4 +506,4 @@ public class LessonsController {
|
||||
response.put("time", lesson.getTime());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/schedule")
|
||||
public class ScheduleController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
|
||||
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public ScheduleController(ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> getSchedule(
|
||||
@RequestParam(required = false) Long groupId,
|
||||
@RequestParam(required = false) Long teacherId,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
|
||||
) {
|
||||
logger.info("Запрос динамического расписания: groupId={}, teacherId={}, startDate={}, endDate={}",
|
||||
groupId, teacherId, startDate, endDate);
|
||||
|
||||
if ((groupId == null && teacherId == null) || (groupId != null && teacherId != null)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
||||
}
|
||||
|
||||
try {
|
||||
List<RenderedLessonDto> lessons = groupId != null
|
||||
? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate)
|
||||
: scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
return ResponseEntity.ok(lessons);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("message", "Произошла ошибка при генерации расписания"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/schedule-rules")
|
||||
public class ScheduleRuleAdminController {
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final LessonTypesRepository lessonTypesRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
GroupRepository groupRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
UserRepository userRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
LessonTypesRepository lessonTypesRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.lessonTypesRepository = lessonTypesRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleRuleDto> getAll(@RequestParam(required = false) Long semesterId,
|
||||
@RequestParam(required = false) Long groupId) {
|
||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
|
||||
.filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId)))
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<?> getById(@PathVariable Long id) {
|
||||
return scheduleRuleRepository.findByIdWithDetails(id)
|
||||
.<ResponseEntity<?>>map(rule -> ResponseEntity.ok(toDto(rule)))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
|
||||
String validationError = validateRule(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
try {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
applyRule(rule, request);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null);
|
||||
if (rule == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateRule(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
try {
|
||||
applyRule(rule, request);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!scheduleRuleRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
scheduleRuleRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Правило расписания удалено"));
|
||||
}
|
||||
|
||||
private String validateRule(ScheduleRuleDto request) {
|
||||
if (request.subjectId() == null) {
|
||||
return "Дисциплина обязательна";
|
||||
}
|
||||
if (request.semesterId() == null) {
|
||||
return "Семестр обязателен";
|
||||
}
|
||||
if (request.activeFromDate() == null) {
|
||||
return "Дата начала правила обязательна";
|
||||
}
|
||||
if (request.totalAcademicHours() == null || request.totalAcademicHours() <= 0) {
|
||||
return "Количество академических часов должно быть больше нуля";
|
||||
}
|
||||
if (request.groupIds() == null || request.groupIds().isEmpty()) {
|
||||
return "Нужно выбрать хотя бы одну группу";
|
||||
}
|
||||
if (request.slots() == null || request.slots().isEmpty()) {
|
||||
return "Добавьте хотя бы один слот занятия";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyRule(ScheduleRule rule, ScheduleRuleDto request) {
|
||||
Subject subject = subjectRepository.findById(request.subjectId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
||||
Semester semester = semesterRepository.findById(request.semesterId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||
List<StudentGroup> groups = groupRepository.findAllById(request.groupIds());
|
||||
if (groups.size() != new HashSet<>(request.groupIds()).size()) {
|
||||
throw new IllegalArgumentException("Одна или несколько групп не найдены");
|
||||
}
|
||||
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(semester);
|
||||
rule.setActiveFromDate(request.activeFromDate());
|
||||
rule.setTotalAcademicHours(request.totalAcademicHours());
|
||||
rule.getGroups().clear();
|
||||
rule.getGroups().addAll(groups);
|
||||
|
||||
rule.getSlots().clear();
|
||||
for (ScheduleRuleSlotDto slotDto : request.slots()) {
|
||||
rule.getSlots().add(buildSlot(rule, slotDto));
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
|
||||
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
|
||||
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
|
||||
}
|
||||
if (slotDto.parity() == null) {
|
||||
throw new IllegalArgumentException("Чётность недели обязательна");
|
||||
}
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||
User teacher = userRepository.findById(slotDto.teacherId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||
}
|
||||
|
||||
ScheduleRuleSlot slot = new ScheduleRuleSlot();
|
||||
slot.setScheduleRule(rule);
|
||||
slot.setDayOfWeek(slotDto.dayOfWeek());
|
||||
slot.setParity(slotDto.parity());
|
||||
slot.setTimeSlot(timeSlot);
|
||||
slot.setSubgroupId(slotDto.subgroupId());
|
||||
slot.setTeacher(teacher);
|
||||
slot.setClassroom(classroom);
|
||||
slot.setLessonType(lessonType);
|
||||
slot.setLessonFormat(slotDto.lessonFormat().trim());
|
||||
return slot;
|
||||
}
|
||||
|
||||
private ScheduleRuleDto toDto(ScheduleRule rule) {
|
||||
List<StudentGroup> groups = rule.getGroups().stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
List<ScheduleRuleSlotDto> slots = rule.getSlots().stream()
|
||||
.sorted(Comparator
|
||||
.comparing(ScheduleRuleSlot::getDayOfWeek)
|
||||
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber())
|
||||
.thenComparing(ScheduleRuleSlot::getId))
|
||||
.map(this::toSlotDto)
|
||||
.toList();
|
||||
|
||||
return new ScheduleRuleDto(
|
||||
rule.getId(),
|
||||
rule.getSubject().getId(),
|
||||
rule.getSubject().getName(),
|
||||
rule.getSemester().getId(),
|
||||
rule.getSemester().getAcademicYear().getTitle(),
|
||||
rule.getSemester().getSemesterType(),
|
||||
rule.getActiveFromDate(),
|
||||
rule.getTotalAcademicHours(),
|
||||
groups.stream().map(StudentGroup::getId).toList(),
|
||||
groups.stream().map(StudentGroup::getName).toList(),
|
||||
slots
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
return new ScheduleRuleSlotDto(
|
||||
slot.getId(),
|
||||
slot.getDayOfWeek(),
|
||||
dayName(slot.getDayOfWeek()),
|
||||
slot.getParity(),
|
||||
timeSlot.getId(),
|
||||
timeSlot.getOrderNumber(),
|
||||
formatTimeSlot(timeSlot),
|
||||
slot.getSubgroupId(),
|
||||
slot.getTeacher().getId(),
|
||||
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
|
||||
slot.getClassroom().getId(),
|
||||
slot.getClassroom().getName(),
|
||||
slot.getLessonType().getId(),
|
||||
slot.getLessonType().getLessonType(),
|
||||
slot.getLessonFormat()
|
||||
);
|
||||
}
|
||||
|
||||
private String dayName(Integer dayOfWeek) {
|
||||
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) {
|
||||
return "Неизвестно";
|
||||
}
|
||||
return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek));
|
||||
}
|
||||
|
||||
private String formatTimeSlot(TimeSlot timeSlot) {
|
||||
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.TimeSlotDto;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/time-slots")
|
||||
public class TimeSlotAdminController {
|
||||
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
|
||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TimeSlotDto> getAll() {
|
||||
return timeSlotRepository.findAllByOrderByOrderNumberAsc().stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
|
||||
}
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
|
||||
if (timeSlot == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())
|
||||
&& !timeSlot.getOrderNumber().equals(request.orderNumber())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
|
||||
}
|
||||
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!timeSlotRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
timeSlotRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
}
|
||||
|
||||
private String validate(TimeSlotDto request) {
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
return "Номер пары должен быть больше нуля";
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
return "Время начала и окончания обязательно";
|
||||
}
|
||||
if (!request.startTime().isBefore(request.endTime())) {
|
||||
return "Время начала должно быть раньше времени окончания";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
||||
? request.durationMinutes()
|
||||
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
|
||||
timeSlot.setDurationMinutes(duration);
|
||||
}
|
||||
|
||||
private TimeSlotDto toDto(TimeSlot timeSlot) {
|
||||
return new TimeSlotDto(
|
||||
timeSlot.getId(),
|
||||
timeSlot.getOrderNumber(),
|
||||
timeSlot.getStartTime(),
|
||||
timeSlot.getEndTime(),
|
||||
timeSlot.getDurationMinutes()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.AcademicActivityType;
|
||||
|
||||
public record AcademicCalendarMatrixDto(
|
||||
Long id,
|
||||
Long semesterId,
|
||||
Integer courseNumber,
|
||||
Long specialtyId,
|
||||
Integer weekNumber,
|
||||
AcademicActivityType activityType
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record AcademicYearDto(
|
||||
Long id,
|
||||
String title,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
List<SemesterDto> semesters
|
||||
) {
|
||||
}
|
||||
12
backend/src/main/java/com/magistr/app/dto/HolidayDto.java
Normal file
12
backend/src/main/java/com/magistr/app/dto/HolidayDto.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record HolidayDto(
|
||||
Long id,
|
||||
LocalDate date,
|
||||
Long academicYearId,
|
||||
String academicYearTitle,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -8,17 +8,23 @@ public class LoginResponse {
|
||||
private String role;
|
||||
private String redirect;
|
||||
private Long departmentId;
|
||||
private Long userId;
|
||||
|
||||
public LoginResponse() {
|
||||
}
|
||||
|
||||
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) {
|
||||
this(success, message, token, role, redirect, departmentId, null);
|
||||
}
|
||||
|
||||
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId, Long userId) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
this.token = token;
|
||||
this.role = role;
|
||||
this.redirect = redirect;
|
||||
this.departmentId = departmentId;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
@@ -68,4 +74,12 @@ public class LoginResponse {
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.AcademicActivityType;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
public record RenderedLessonDto(
|
||||
Long scheduleRuleId,
|
||||
Long scheduleRuleSlotId,
|
||||
LocalDate date,
|
||||
Integer dayOfWeek,
|
||||
String dayName,
|
||||
Integer weekNumber,
|
||||
ScheduleParity parity,
|
||||
Long timeSlotId,
|
||||
Integer timeSlotOrder,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
String classroomName,
|
||||
Long lessonTypeId,
|
||||
String lessonTypeName,
|
||||
String lessonFormat,
|
||||
Long subgroupId,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
AcademicActivityType activityType,
|
||||
Integer totalAcademicHours,
|
||||
Integer consumedAcademicHoursBeforeLesson,
|
||||
Integer remainingAcademicHoursAfterLesson
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.SemesterType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRuleDto(
|
||||
Long id,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Long semesterId,
|
||||
String academicYearTitle,
|
||||
SemesterType semesterType,
|
||||
LocalDate activeFromDate,
|
||||
Integer totalAcademicHours,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
List<ScheduleRuleSlotDto> slots
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
|
||||
public record ScheduleRuleSlotDto(
|
||||
Long id,
|
||||
Integer dayOfWeek,
|
||||
String dayName,
|
||||
ScheduleParity parity,
|
||||
Long timeSlotId,
|
||||
Integer timeSlotOrder,
|
||||
String timeSlotLabel,
|
||||
Long subgroupId,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
String classroomName,
|
||||
Long lessonTypeId,
|
||||
String lessonTypeName,
|
||||
String lessonFormat
|
||||
) {
|
||||
}
|
||||
15
backend/src/main/java/com/magistr/app/dto/SemesterDto.java
Normal file
15
backend/src/main/java/com/magistr/app/dto/SemesterDto.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.SemesterType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record SemesterDto(
|
||||
Long id,
|
||||
Long academicYearId,
|
||||
String academicYearTitle,
|
||||
SemesterType semesterType,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
}
|
||||
12
backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java
Normal file
12
backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalTime;
|
||||
|
||||
public record TimeSlotDto(
|
||||
Long id,
|
||||
Integer orderNumber,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Integer durationMinutes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum AcademicActivityType {
|
||||
THEORY,
|
||||
EXAM,
|
||||
VACATION,
|
||||
PRACTICE
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "academic_calendar_matrix")
|
||||
public class AcademicCalendarMatrix {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "semester_id", nullable = false)
|
||||
private Semester semester;
|
||||
|
||||
@Column(name = "course_number", nullable = false)
|
||||
private Integer courseNumber;
|
||||
|
||||
@Column(name = "specialty_id", nullable = false)
|
||||
private Long specialtyId;
|
||||
|
||||
@Column(name = "week_number", nullable = false)
|
||||
private Integer weekNumber;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "activity_type", nullable = false, length = 20)
|
||||
private AcademicActivityType activityType;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Semester getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(Semester semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public Integer getCourseNumber() {
|
||||
return courseNumber;
|
||||
}
|
||||
|
||||
public void setCourseNumber(Integer courseNumber) {
|
||||
this.courseNumber = courseNumber;
|
||||
}
|
||||
|
||||
public Long getSpecialtyId() {
|
||||
return specialtyId;
|
||||
}
|
||||
|
||||
public void setSpecialtyId(Long specialtyId) {
|
||||
this.specialtyId = specialtyId;
|
||||
}
|
||||
|
||||
public Integer getWeekNumber() {
|
||||
return weekNumber;
|
||||
}
|
||||
|
||||
public void setWeekNumber(Integer weekNumber) {
|
||||
this.weekNumber = weekNumber;
|
||||
}
|
||||
|
||||
public AcademicActivityType getActivityType() {
|
||||
return activityType;
|
||||
}
|
||||
|
||||
public void setActivityType(AcademicActivityType activityType) {
|
||||
this.activityType = activityType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "academic_years")
|
||||
public class AcademicYear {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 20)
|
||||
private String title;
|
||||
|
||||
@Column(name = "start_date", nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
}
|
||||
56
backend/src/main/java/com/magistr/app/model/Holiday.java
Normal file
56
backend/src/main/java/com/magistr/app/model/Holiday.java
Normal file
@@ -0,0 +1,56 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "holidays")
|
||||
public class Holiday {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate date;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "academic_year_id", nullable = false)
|
||||
private AcademicYear academicYear;
|
||||
|
||||
@Column(length = 255)
|
||||
private String description;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public LocalDate getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public void setDate(LocalDate date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public AcademicYear getAcademicYear() {
|
||||
return academicYear;
|
||||
}
|
||||
|
||||
public void setAcademicYear(AcademicYear academicYear) {
|
||||
this.academicYear = academicYear;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum ScheduleParity {
|
||||
BOTH,
|
||||
EVEN,
|
||||
ODD
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_rules")
|
||||
public class ScheduleRule {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "subject_id", nullable = false)
|
||||
private Subject subject;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "semester_id", nullable = false)
|
||||
private Semester semester;
|
||||
|
||||
@Column(name = "active_from_date", nullable = false)
|
||||
private LocalDate activeFromDate;
|
||||
|
||||
@Column(name = "total_academic_hours", nullable = false)
|
||||
private Integer totalAcademicHours;
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
name = "schedule_rule_groups",
|
||||
joinColumns = @JoinColumn(name = "schedule_rule_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "group_id")
|
||||
)
|
||||
private Set<StudentGroup> groups = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "scheduleRule", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<ScheduleRuleSlot> slots = new HashSet<>();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Subject getSubject() {
|
||||
return subject;
|
||||
}
|
||||
|
||||
public void setSubject(Subject subject) {
|
||||
this.subject = subject;
|
||||
}
|
||||
|
||||
public Semester getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(Semester semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public LocalDate getActiveFromDate() {
|
||||
return activeFromDate;
|
||||
}
|
||||
|
||||
public void setActiveFromDate(LocalDate activeFromDate) {
|
||||
this.activeFromDate = activeFromDate;
|
||||
}
|
||||
|
||||
public Integer getTotalAcademicHours() {
|
||||
return totalAcademicHours;
|
||||
}
|
||||
|
||||
public void setTotalAcademicHours(Integer totalAcademicHours) {
|
||||
this.totalAcademicHours = totalAcademicHours;
|
||||
}
|
||||
|
||||
public Set<StudentGroup> getGroups() {
|
||||
return groups;
|
||||
}
|
||||
|
||||
public void setGroups(Set<StudentGroup> groups) {
|
||||
this.groups = groups;
|
||||
}
|
||||
|
||||
public Set<ScheduleRuleSlot> getSlots() {
|
||||
return slots;
|
||||
}
|
||||
|
||||
public void setSlots(Set<ScheduleRuleSlot> slots) {
|
||||
this.slots = slots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_rule_slots")
|
||||
public class ScheduleRuleSlot {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "schedule_rule_id", nullable = false)
|
||||
private ScheduleRule scheduleRule;
|
||||
|
||||
@Column(name = "day_of_week", nullable = false)
|
||||
private Integer dayOfWeek;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 10)
|
||||
private ScheduleParity parity;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "time_slot_id", nullable = false)
|
||||
private TimeSlot timeSlot;
|
||||
|
||||
@Column(name = "subgroup_id")
|
||||
private Long subgroupId;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "teacher_id", nullable = false)
|
||||
private User teacher;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "classroom_id", nullable = false)
|
||||
private Classroom classroom;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "lesson_type_id", nullable = false)
|
||||
private LessonType lessonType;
|
||||
|
||||
@Column(name = "lesson_format", nullable = false, length = 30)
|
||||
private String lessonFormat;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public ScheduleRule getScheduleRule() {
|
||||
return scheduleRule;
|
||||
}
|
||||
|
||||
public void setScheduleRule(ScheduleRule scheduleRule) {
|
||||
this.scheduleRule = scheduleRule;
|
||||
}
|
||||
|
||||
public Integer getDayOfWeek() {
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
public void setDayOfWeek(Integer dayOfWeek) {
|
||||
this.dayOfWeek = dayOfWeek;
|
||||
}
|
||||
|
||||
public ScheduleParity getParity() {
|
||||
return parity;
|
||||
}
|
||||
|
||||
public void setParity(ScheduleParity parity) {
|
||||
this.parity = parity;
|
||||
}
|
||||
|
||||
public TimeSlot getTimeSlot() {
|
||||
return timeSlot;
|
||||
}
|
||||
|
||||
public void setTimeSlot(TimeSlot timeSlot) {
|
||||
this.timeSlot = timeSlot;
|
||||
}
|
||||
|
||||
public Long getSubgroupId() {
|
||||
return subgroupId;
|
||||
}
|
||||
|
||||
public void setSubgroupId(Long subgroupId) {
|
||||
this.subgroupId = subgroupId;
|
||||
}
|
||||
|
||||
public User getTeacher() {
|
||||
return teacher;
|
||||
}
|
||||
|
||||
public void setTeacher(User teacher) {
|
||||
this.teacher = teacher;
|
||||
}
|
||||
|
||||
public Classroom getClassroom() {
|
||||
return classroom;
|
||||
}
|
||||
|
||||
public void setClassroom(Classroom classroom) {
|
||||
this.classroom = classroom;
|
||||
}
|
||||
|
||||
public LessonType getLessonType() {
|
||||
return lessonType;
|
||||
}
|
||||
|
||||
public void setLessonType(LessonType lessonType) {
|
||||
this.lessonType = lessonType;
|
||||
}
|
||||
|
||||
public String getLessonFormat() {
|
||||
return lessonFormat;
|
||||
}
|
||||
|
||||
public void setLessonFormat(String lessonFormat) {
|
||||
this.lessonFormat = lessonFormat;
|
||||
}
|
||||
}
|
||||
68
backend/src/main/java/com/magistr/app/model/Semester.java
Normal file
68
backend/src/main/java/com/magistr/app/model/Semester.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "semesters")
|
||||
public class Semester {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "academic_year_id", nullable = false)
|
||||
private AcademicYear academicYear;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "semester_type", nullable = false, length = 20)
|
||||
private SemesterType semesterType;
|
||||
|
||||
@Column(name = "start_date", nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public AcademicYear getAcademicYear() {
|
||||
return academicYear;
|
||||
}
|
||||
|
||||
public void setAcademicYear(AcademicYear academicYear) {
|
||||
this.academicYear = academicYear;
|
||||
}
|
||||
|
||||
public SemesterType getSemesterType() {
|
||||
return semesterType;
|
||||
}
|
||||
|
||||
public void setSemesterType(SemesterType semesterType) {
|
||||
this.semesterType = semesterType;
|
||||
}
|
||||
|
||||
public LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
}
|
||||
66
backend/src/main/java/com/magistr/app/model/TimeSlot.java
Normal file
66
backend/src/main/java/com/magistr/app/model/TimeSlot.java
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "time_slots")
|
||||
public class TimeSlot {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "order_number", nullable = false)
|
||||
private Integer orderNumber;
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
private LocalTime startTime;
|
||||
|
||||
@Column(name = "end_time", nullable = false)
|
||||
private LocalTime endTime;
|
||||
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Integer getOrderNumber() {
|
||||
return orderNumber;
|
||||
}
|
||||
|
||||
public void setOrderNumber(Integer orderNumber) {
|
||||
this.orderNumber = orderNumber;
|
||||
}
|
||||
|
||||
public LocalTime getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(LocalTime startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public LocalTime getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(LocalTime endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public Integer getDurationMinutes() {
|
||||
return durationMinutes;
|
||||
}
|
||||
|
||||
public void setDurationMinutes(Integer durationMinutes) {
|
||||
this.durationMinutes = durationMinutes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicCalendarMatrix;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AcademicCalendarMatrixRepository extends JpaRepository<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,11 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
|
||||
|
||||
Optional<AcademicYear> findByTitle(String title);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Holiday;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public interface HolidayRepository extends JpaRepository<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,85 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups g
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where g.id = :groupId
|
||||
and sem.id = :semesterId
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdAndSemesterId(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("semesterId") Long semesterId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id = :semesterId
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterId(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("semesterId") Long semesterId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
order by r.id desc
|
||||
""")
|
||||
List<ScheduleRule> findAllWithDetails();
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where r.id = :id
|
||||
""")
|
||||
java.util.Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SemesterRepository extends JpaRepository<Semester, Long> {
|
||||
|
||||
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
|
||||
|
||||
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
|
||||
|
||||
List<TimeSlot> findAllByOrderByOrderNumberAsc();
|
||||
|
||||
boolean existsByOrderNumber(Integer orderNumber);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.AcademicCalendarMatrixRepository;
|
||||
import com.magistr.app.repository.HolidayRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class AcademicDateService {
|
||||
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final HolidayRepository holidayRepository;
|
||||
private final AcademicCalendarMatrixRepository calendarMatrixRepository;
|
||||
private final boolean evenWeekIsUpper;
|
||||
|
||||
public AcademicDateService(SemesterRepository semesterRepository,
|
||||
HolidayRepository holidayRepository,
|
||||
AcademicCalendarMatrixRepository calendarMatrixRepository,
|
||||
@Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) {
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.holidayRepository = holidayRepository;
|
||||
this.calendarMatrixRepository = calendarMatrixRepository;
|
||||
this.evenWeekIsUpper = evenWeekIsUpper;
|
||||
}
|
||||
|
||||
public Optional<Semester> findSemester(LocalDate date) {
|
||||
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date);
|
||||
}
|
||||
|
||||
public int getWeekNumber(Semester semester, LocalDate date) {
|
||||
long days = ChronoUnit.DAYS.between(semester.getStartDate(), date);
|
||||
if (days < 0) {
|
||||
return 0;
|
||||
}
|
||||
return (int) (days / 7) + 1;
|
||||
}
|
||||
|
||||
public ScheduleParity getParity(Semester semester, LocalDate date) {
|
||||
int weekNumber = getWeekNumber(semester, date);
|
||||
boolean evenWeek = weekNumber % 2 == 0;
|
||||
if (evenWeekIsUpper) {
|
||||
return evenWeek ? ScheduleParity.ODD : ScheduleParity.EVEN;
|
||||
}
|
||||
return evenWeek ? ScheduleParity.EVEN : ScheduleParity.ODD;
|
||||
}
|
||||
|
||||
public boolean isHoliday(Semester semester, LocalDate date) {
|
||||
if (semester == null || semester.getAcademicYear() == null) {
|
||||
return false;
|
||||
}
|
||||
return holidayRepository.existsByAcademicYearIdAndDate(semester.getAcademicYear().getId(), date);
|
||||
}
|
||||
|
||||
public int getCourseNumber(StudentGroup group, LocalDate date) {
|
||||
int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1;
|
||||
return academicYearStart - group.getYearStartStudy() + 1;
|
||||
}
|
||||
|
||||
public AcademicActivityType getActivityType(StudentGroup group, Semester semester, LocalDate date) {
|
||||
int weekNumber = getWeekNumber(semester, date);
|
||||
int courseNumber = getCourseNumber(group, date);
|
||||
if (weekNumber < 1 || courseNumber < 1) {
|
||||
return AcademicActivityType.VACATION;
|
||||
}
|
||||
|
||||
return calendarMatrixRepository
|
||||
.findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber(
|
||||
semester.getId(),
|
||||
courseNumber,
|
||||
group.getSpecialityCode(),
|
||||
weekNumber
|
||||
)
|
||||
.map(AcademicCalendarMatrix::getActivityType)
|
||||
.orElse(AcademicActivityType.THEORY);
|
||||
}
|
||||
|
||||
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
|
||||
return getActivityType(group, semester, date) == AcademicActivityType.THEORY
|
||||
&& !isHoliday(semester, date);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.TextStyle;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ScheduleGeneratorService {
|
||||
|
||||
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
private static final long MAX_RANGE_DAYS = 120;
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AcademicDateService academicDateService;
|
||||
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
|
||||
|
||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
GroupRepository groupRepository,
|
||||
UserRepository userRepository,
|
||||
AcademicDateService academicDateService) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.academicDateService = academicDateService;
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
StudentGroup group = groupRepository.findById(groupId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
||||
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
if (semesterOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
if (!academicDateService.isTheoryDay(group, semester, date)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
||||
semester.getId(),
|
||||
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
||||
);
|
||||
|
||||
for (ScheduleRule rule : sortRules(rules)) {
|
||||
renderRuleForDate(rule, date, group, null, result);
|
||||
}
|
||||
}
|
||||
|
||||
return sortRenderedLessons(result);
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
if (!userRepository.existsById(teacherId)) {
|
||||
throw new IllegalArgumentException("Преподаватель не найден");
|
||||
}
|
||||
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
if (semesterOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
||||
semester.getId(),
|
||||
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
||||
);
|
||||
|
||||
for (ScheduleRule rule : sortRules(rules)) {
|
||||
renderRuleForDate(rule, date, null, teacherId, result);
|
||||
}
|
||||
}
|
||||
|
||||
return sortRenderedLessons(result);
|
||||
}
|
||||
|
||||
public void clearCache() {
|
||||
consumedHoursCache.clear();
|
||||
}
|
||||
|
||||
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null) {
|
||||
throw new IllegalArgumentException("Дата начала и дата окончания обязательны");
|
||||
}
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
||||
}
|
||||
if (java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||
}
|
||||
}
|
||||
|
||||
private List<ScheduleRule> sortRules(List<ScheduleRule> rules) {
|
||||
return rules.stream()
|
||||
.sorted(Comparator
|
||||
.comparing((ScheduleRule rule) -> rule.getSubject().getName(), Comparator.nullsLast(String::compareTo))
|
||||
.thenComparing(ScheduleRule::getId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> sortRenderedLessons(List<RenderedLessonDto> lessons) {
|
||||
return lessons.stream()
|
||||
.sorted(Comparator
|
||||
.comparing(RenderedLessonDto::date)
|
||||
.thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo))
|
||||
.thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void renderRuleForDate(ScheduleRule rule,
|
||||
LocalDate date,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
List<RenderedLessonDto> result) {
|
||||
if (rule.getActiveFromDate() != null && date.isBefore(rule.getActiveFromDate())) {
|
||||
return;
|
||||
}
|
||||
if (rule.getTotalAcademicHours() == null || rule.getTotalAcademicHours() <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
if (academicDateService.isHoliday(semester, date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||
if (eligibleGroups.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScheduleParity dateParity = academicDateService.getParity(semester, date);
|
||||
int dayOfWeek = date.getDayOfWeek().getValue();
|
||||
List<ScheduleRuleSlot> slots = rule.getSlots().stream()
|
||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
||||
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
||||
.filter(slot -> targetTeacherId == null || Objects.equals(slot.getTeacher().getId(), targetTeacherId))
|
||||
.sorted(Comparator
|
||||
.comparing((ScheduleRuleSlot slot) -> slot.getTimeSlot().getOrderNumber(), Comparator.nullsLast(Integer::compareTo))
|
||||
.thenComparing(ScheduleRuleSlot::getId))
|
||||
.toList();
|
||||
|
||||
if (slots.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int consumedHours = calculateConsumedHoursBeforeDate(rule, targetGroup, date);
|
||||
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
||||
|
||||
for (ScheduleRuleSlot slot : slots) {
|
||||
if (consumedHours >= rule.getTotalAcademicHours()) {
|
||||
break;
|
||||
}
|
||||
int remainingAfterLesson = Math.max(0, rule.getTotalAcademicHours() - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, consumedHours, remainingAfterLesson));
|
||||
consumedHours += ACADEMIC_HOURS_PER_SLOT;
|
||||
}
|
||||
}
|
||||
|
||||
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
|
||||
if (targetGroup != null) {
|
||||
return academicDateService.isTheoryDay(targetGroup, semester, date) ? List.of(targetGroup) : List.of();
|
||||
}
|
||||
|
||||
return rule.getGroups().stream()
|
||||
.filter(group -> academicDateService.isTheoryDay(group, semester, date))
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int calculateConsumedHoursBeforeDate(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) {
|
||||
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
||||
String cacheKey = rule.getId() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate;
|
||||
return consumedHoursCache.computeIfAbsent(cacheKey, ignored -> calculateConsumedHours(rule, targetGroup, targetDate));
|
||||
}
|
||||
|
||||
private int calculateConsumedHours(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) {
|
||||
LocalDate start = rule.getActiveFromDate() == null
|
||||
? rule.getSemester().getStartDate()
|
||||
: rule.getActiveFromDate();
|
||||
if (start.isBefore(rule.getSemester().getStartDate())) {
|
||||
start = rule.getSemester().getStartDate();
|
||||
}
|
||||
|
||||
int consumed = 0;
|
||||
for (LocalDate date = start; date.isBefore(targetDate); date = date.plusDays(1)) {
|
||||
Semester semester = rule.getSemester();
|
||||
if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
|
||||
continue;
|
||||
}
|
||||
if (academicDateService.isHoliday(semester, date)) {
|
||||
continue;
|
||||
}
|
||||
if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int dayOfWeek = date.getDayOfWeek().getValue();
|
||||
ScheduleParity dateParity = academicDateService.getParity(semester, date);
|
||||
long activeSlots = rule.getSlots().stream()
|
||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
||||
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
||||
.count();
|
||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
||||
if (consumed >= rule.getTotalAcademicHours()) {
|
||||
return consumed;
|
||||
}
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
|
||||
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
|
||||
ScheduleRuleSlot slot,
|
||||
LocalDate date,
|
||||
int weekNumber,
|
||||
ScheduleParity dateParity,
|
||||
List<StudentGroup> groups,
|
||||
int consumedBeforeLesson,
|
||||
int remainingAfterLesson) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
List<StudentGroup> sortedGroups = groups.stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
|
||||
return new RenderedLessonDto(
|
||||
rule.getId(),
|
||||
slot.getId(),
|
||||
date,
|
||||
date.getDayOfWeek().getValue(),
|
||||
dayName(date.getDayOfWeek()),
|
||||
weekNumber,
|
||||
dateParity,
|
||||
timeSlot.getId(),
|
||||
timeSlot.getOrderNumber(),
|
||||
timeSlot.getStartTime(),
|
||||
timeSlot.getEndTime(),
|
||||
rule.getSubject().getId(),
|
||||
rule.getSubject().getName(),
|
||||
slot.getTeacher().getId(),
|
||||
displayUserName(slot.getTeacher()),
|
||||
slot.getClassroom().getId(),
|
||||
slot.getClassroom().getName(),
|
||||
slot.getLessonType().getId(),
|
||||
slot.getLessonType().getLessonType(),
|
||||
slot.getLessonFormat(),
|
||||
slot.getSubgroupId(),
|
||||
sortedGroups.stream().map(StudentGroup::getId).toList(),
|
||||
sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
|
||||
AcademicActivityType.THEORY,
|
||||
rule.getTotalAcademicHours(),
|
||||
consumedBeforeLesson,
|
||||
remainingAfterLesson
|
||||
);
|
||||
}
|
||||
|
||||
public static String dayName(DayOfWeek dayOfWeek) {
|
||||
String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU"));
|
||||
return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1);
|
||||
}
|
||||
|
||||
public static String displayUserName(User user) {
|
||||
if (user.getFullName() != null && !user.getFullName().isBlank()) {
|
||||
return user.getFullName();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,227 @@ CREATE TABLE IF NOT EXISTS teacher_lesson_types (
|
||||
);
|
||||
|
||||
-- ==========================================
|
||||
-- Основная таблица Расписания (Lessons)
|
||||
-- Динамическое расписание: календарь, слоты и правила
|
||||
-- ==========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_slots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
order_number INT NOT NULL,
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
duration_minutes INT NOT NULL,
|
||||
CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0),
|
||||
CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time),
|
||||
CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0),
|
||||
CONSTRAINT uq_time_slots_order UNIQUE (order_number),
|
||||
CONSTRAINT uq_time_slots_range UNIQUE (start_time, end_time)
|
||||
);
|
||||
|
||||
INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VALUES
|
||||
(1, '08:00', '09:30', 90),
|
||||
(2, '09:40', '11:10', 90),
|
||||
(3, '11:40', '13:10', 90),
|
||||
(4, '13:20', '14:50', 90),
|
||||
(5, '15:00', '16:30', 90),
|
||||
(6, '16:50', '18:20', 90),
|
||||
(7, '18:30', '20:00', 90)
|
||||
ON CONFLICT (start_time, end_time) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_years (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(20) NOT NULL UNIQUE,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
||||
);
|
||||
|
||||
INSERT INTO academic_years (title, start_date, end_date) VALUES
|
||||
('2024-2025', '2024-09-01', '2025-06-30'),
|
||||
('2025-2026', '2025-09-01', '2026-06-30')
|
||||
ON CONFLICT (title) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semesters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||||
semester_type VARCHAR(20) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
CONSTRAINT chk_semesters_type CHECK (semester_type IN ('autumn', 'spring')),
|
||||
CONSTRAINT chk_semesters_dates CHECK (start_date <= end_date),
|
||||
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
||||
);
|
||||
|
||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||||
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
|
||||
FROM academic_years
|
||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||||
|
||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||||
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
|
||||
FROM academic_years
|
||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS holidays (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||||
description VARCHAR(255),
|
||||
CONSTRAINT uq_holidays_year_date UNIQUE (academic_year_id, date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS academic_calendar_matrix (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
||||
course_number INT NOT NULL,
|
||||
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
|
||||
week_number INT NOT NULL,
|
||||
activity_type VARCHAR(20) NOT NULL,
|
||||
CONSTRAINT chk_calendar_course_positive CHECK (course_number > 0),
|
||||
CONSTRAINT chk_calendar_week_positive CHECK (week_number > 0),
|
||||
CONSTRAINT chk_calendar_activity_type CHECK (activity_type IN ('THEORY', 'EXAM', 'VACATION', 'PRACTICE')),
|
||||
CONSTRAINT uq_calendar_matrix UNIQUE (semester_id, course_number, specialty_id, week_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_lookup
|
||||
ON academic_calendar_matrix(semester_id, course_number, specialty_id, week_number);
|
||||
|
||||
INSERT INTO academic_calendar_matrix (semester_id, course_number, specialty_id, week_number, activity_type)
|
||||
SELECT
|
||||
s.id,
|
||||
SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 AS course_number,
|
||||
sg.specialty_code AS specialty_id,
|
||||
weeks.week_number,
|
||||
'THEORY'
|
||||
FROM semesters s
|
||||
JOIN academic_years ay ON ay.id = s.academic_year_id
|
||||
CROSS JOIN student_groups sg
|
||||
CROSS JOIN GENERATE_SERIES(1, 22) AS weeks(week_number)
|
||||
WHERE SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 > 0
|
||||
ON CONFLICT (semester_id, course_number, specialty_id, week_number) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
||||
active_from_date DATE NOT NULL,
|
||||
total_academic_hours INT NOT NULL,
|
||||
CONSTRAINT chk_schedule_rules_hours_positive CHECK (total_academic_hours > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_subject ON schedule_rules(subject_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_rule_groups (
|
||||
schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
|
||||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (schedule_rule_id, group_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_groups_group ON schedule_rule_groups(group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_rule_slots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
|
||||
day_of_week INT NOT NULL,
|
||||
parity VARCHAR(10) NOT NULL,
|
||||
time_slot_id BIGINT NOT NULL REFERENCES time_slots(id),
|
||||
subgroup_id BIGINT NULL REFERENCES subgroups(id) ON DELETE SET NULL,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
|
||||
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
|
||||
lesson_format VARCHAR(30) NOT NULL,
|
||||
CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')),
|
||||
CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id);
|
||||
|
||||
CREATE TEMP TABLE tmp_default_rules AS
|
||||
SELECT
|
||||
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
|
||||
rule_key,
|
||||
subject_id,
|
||||
semester_id,
|
||||
active_from_date,
|
||||
total_academic_hours
|
||||
FROM (
|
||||
SELECT
|
||||
data.rule_key,
|
||||
subj.id AS subject_id,
|
||||
sem.id AS semester_id,
|
||||
data.active_from_date,
|
||||
data.total_academic_hours
|
||||
FROM (VALUES
|
||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', DATE '2026-02-02', 54)
|
||||
) AS data(rule_key, subject_name, year_title, semester_type, active_from_date, total_academic_hours)
|
||||
JOIN subjects subj ON subj.name = data.subject_name
|
||||
JOIN academic_years ay ON ay.title = data.year_title
|
||||
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
||||
) prepared_rules;
|
||||
|
||||
INSERT INTO schedule_rules (id, subject_id, semester_id, active_from_date, total_academic_hours)
|
||||
SELECT schedule_rule_id, subject_id, semester_id, active_from_date, total_academic_hours
|
||||
FROM tmp_default_rules;
|
||||
|
||||
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
||||
SELECT
|
||||
rules.schedule_rule_id,
|
||||
sg.id
|
||||
FROM (VALUES
|
||||
('stream_math', 'ИВТ-21-1'),
|
||||
('stream_math', 'ИБ-41м'),
|
||||
('ivt_informatics_lab', 'ИВТ-21-1'),
|
||||
('ivt_databases_practice', 'ИВТ-21-1'),
|
||||
('ib_philosophy', 'ИБ-41м')
|
||||
) AS data(rule_key, group_name)
|
||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||||
JOIN student_groups sg ON sg.name = data.group_name
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO schedule_rule_slots (
|
||||
schedule_rule_id,
|
||||
day_of_week,
|
||||
parity,
|
||||
time_slot_id,
|
||||
teacher_id,
|
||||
classroom_id,
|
||||
lesson_type_id,
|
||||
lesson_format
|
||||
)
|
||||
SELECT
|
||||
rules.schedule_rule_id,
|
||||
data.day_of_week,
|
||||
data.parity,
|
||||
ts.id,
|
||||
teacher.id,
|
||||
classroom.id,
|
||||
lt.id,
|
||||
data.lesson_format
|
||||
FROM (VALUES
|
||||
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
|
||||
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
|
||||
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
|
||||
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
||||
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||||
JOIN time_slots ts ON ts.order_number = data.time_slot_order
|
||||
JOIN users teacher ON teacher.username = data.teacher_username
|
||||
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
||||
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
||||
|
||||
-- ==========================================
|
||||
-- Временные таблицы старой модели. Будут удалены после очистки старого кода.
|
||||
-- ==========================================
|
||||
CREATE TABLE IF NOT EXISTS lessons (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -225,17 +445,6 @@ CREATE TABLE IF NOT EXISTS lessons (
|
||||
time VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES
|
||||
(2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'),
|
||||
(1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'),
|
||||
(2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'),
|
||||
(1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'),
|
||||
(2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'),
|
||||
(1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30');
|
||||
|
||||
-- ===============================
|
||||
-- Создание таблицы данных расписания (schedule_data)
|
||||
-- ===============================
|
||||
CREATE TABLE IF NOT EXISTS schedule_data (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
@@ -249,18 +458,6 @@ CREATE TABLE IF NOT EXISTS schedule_data (
|
||||
period VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO schedule_data (department_id, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period)
|
||||
VALUES (1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'),
|
||||
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
|
||||
(3, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'),
|
||||
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
|
||||
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
|
||||
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
|
||||
(1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'),
|
||||
(1, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'),
|
||||
(1, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'),
|
||||
(1, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025');
|
||||
|
||||
-- ==========================================
|
||||
-- Функция обновления timestamp
|
||||
-- ==========================================
|
||||
@@ -281,10 +478,18 @@ CREATE TRIGGER update_users_updated_at
|
||||
-- Комментарии к таблицам и полям (для документации)
|
||||
-- ==========================================
|
||||
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
|
||||
COMMENT ON TABLE lessons IS 'Основное расписание занятий';
|
||||
COMMENT ON TABLE lessons IS 'Временная таблица старой модели расписания до удаления старого кода';
|
||||
COMMENT ON TABLE departments IS 'Кафедры';
|
||||
COMMENT ON TABLE specialties IS 'Специальности';
|
||||
COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания';
|
||||
COMMENT ON TABLE schedule_data IS 'Временная таблица старой модели плановой нагрузки до удаления старого кода';
|
||||
COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий';
|
||||
COMMENT ON TABLE academic_years IS 'Учебные годы';
|
||||
COMMENT ON TABLE semesters IS 'Семестры учебного года';
|
||||
COMMENT ON TABLE holidays IS 'Праздники и неучебные даты';
|
||||
COMMENT ON TABLE academic_calendar_matrix IS 'Матрица учебного графика по курсу, специальности и неделе';
|
||||
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
||||
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
|
||||
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
|
||||
COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры';
|
||||
COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы';
|
||||
COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета';
|
||||
@@ -324,6 +529,8 @@ COMMENT ON COLUMN student_groups.name IS 'Название группы';
|
||||
COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов';
|
||||
COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа';
|
||||
COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры';
|
||||
COMMENT ON COLUMN student_groups.specialty_code IS 'ID специальности';
|
||||
COMMENT ON COLUMN student_groups.year_start_study IS 'Год начала обучения';
|
||||
COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания';
|
||||
|
||||
COMMENT ON COLUMN subgroups.id IS 'ID подгруппы';
|
||||
@@ -388,4 +595,4 @@ COMMENT ON COLUMN specialties.specialty_code IS 'Код специальност
|
||||
|
||||
COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя';
|
||||
COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета';
|
||||
COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия';
|
||||
COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия';
|
||||
|
||||
148
docs/API.md
148
docs/API.md
@@ -25,7 +25,9 @@
|
||||
"message": "OK",
|
||||
"token": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role": "ADMIN",
|
||||
"redirect": "/admin/"
|
||||
"redirect": "/admin/",
|
||||
"departmentId": 1,
|
||||
"userId": 1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -36,7 +38,9 @@
|
||||
"message": "Неверное имя пользователя или пароль",
|
||||
"token": null,
|
||||
"role": null,
|
||||
"redirect": null
|
||||
"redirect": null,
|
||||
"departmentId": null,
|
||||
"userId": null
|
||||
}
|
||||
```
|
||||
|
||||
@@ -95,7 +99,145 @@
|
||||
|
||||
---
|
||||
|
||||
## Расписание (Lessons)
|
||||
## Динамическое расписание
|
||||
|
||||
Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат.
|
||||
|
||||
### `GET /api/schedule`
|
||||
|
||||
Получение расписания группы или преподавателя за период.
|
||||
|
||||
**Параметры:**
|
||||
|
||||
| Параметр | Обязателен | Описание |
|
||||
|----------|------------|----------|
|
||||
| `groupId` | Да, если нет `teacherId` | ID учебной группы |
|
||||
| `teacherId` | Да, если нет `groupId` | ID преподавателя |
|
||||
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
||||
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
||||
|
||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней.
|
||||
|
||||
**Пример:**
|
||||
```http
|
||||
GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"scheduleRuleId": 10,
|
||||
"scheduleRuleSlotId": 31,
|
||||
"date": "2026-04-27",
|
||||
"dayOfWeek": 1,
|
||||
"dayName": "Понедельник",
|
||||
"weekNumber": 13,
|
||||
"parity": "ODD",
|
||||
"timeSlotId": 3,
|
||||
"timeSlotOrder": 3,
|
||||
"startTime": "11:40:00",
|
||||
"endTime": "13:10:00",
|
||||
"subjectId": 1,
|
||||
"subjectName": "Высшая математика",
|
||||
"teacherId": 2,
|
||||
"teacherName": "Петров Препод Петрович",
|
||||
"classroomId": 1,
|
||||
"classroomName": "101 Ленинская",
|
||||
"lessonTypeId": 1,
|
||||
"lessonTypeName": "Лекция",
|
||||
"lessonFormat": "Очно",
|
||||
"subgroupId": null,
|
||||
"groupIds": [1],
|
||||
"groupNames": ["ИВТ-21-1"],
|
||||
"activityType": "THEORY",
|
||||
"totalAcademicHours": 72,
|
||||
"consumedAcademicHoursBeforeLesson": 24,
|
||||
"remainingAcademicHoursAfterLesson": 46
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/admin/time-slots`
|
||||
|
||||
Список временных слотов занятий. CRUD доступен по:
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/time-slots` | Список слотов |
|
||||
| `POST` | `/api/admin/time-slots` | Создать слот |
|
||||
| `PUT` | `/api/admin/time-slots/{id}` | Обновить слот |
|
||||
| `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот |
|
||||
|
||||
**Тело создания/обновления:**
|
||||
```json
|
||||
{
|
||||
"orderNumber": 1,
|
||||
"startTime": "08:00:00",
|
||||
"endTime": "09:30:00",
|
||||
"durationMinutes": 90
|
||||
}
|
||||
```
|
||||
|
||||
### Календарный график администратора
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/calendar/years` | Учебные годы с семестрами |
|
||||
| `POST` | `/api/admin/calendar/years` | Создать учебный год |
|
||||
| `PUT` | `/api/admin/calendar/years/{id}` | Обновить учебный год |
|
||||
| `DELETE` | `/api/admin/calendar/years/{id}` | Удалить учебный год |
|
||||
| `GET` | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года |
|
||||
| `POST` | `/api/admin/calendar/years/{academicYearId}/semesters` | Создать семестр |
|
||||
| `PUT` | `/api/admin/calendar/semesters/{id}` | Обновить семестр |
|
||||
| `GET` | `/api/admin/calendar/holidays?academicYearId=1` | Праздники учебного года |
|
||||
| `POST` | `/api/admin/calendar/holidays` | Создать праздник |
|
||||
| `PUT` | `/api/admin/calendar/holidays/{id}` | Обновить праздник |
|
||||
| `DELETE` | `/api/admin/calendar/holidays/{id}` | Удалить праздник |
|
||||
| `GET` | `/api/admin/calendar/matrix?semesterId=1&courseNumber=1&specialtyId=2` | Матрица учебного графика |
|
||||
| `PUT` | `/api/admin/calendar/matrix` | Массовое сохранение матрицы |
|
||||
|
||||
### `POST /api/admin/schedule-rules`
|
||||
|
||||
Создание правила динамического расписания.
|
||||
|
||||
```json
|
||||
{
|
||||
"subjectId": 1,
|
||||
"semesterId": 1,
|
||||
"activeFromDate": "2026-02-01",
|
||||
"totalAcademicHours": 72,
|
||||
"groupIds": [1, 2],
|
||||
"slots": [
|
||||
{
|
||||
"dayOfWeek": 1,
|
||||
"parity": "BOTH",
|
||||
"timeSlotId": 3,
|
||||
"subgroupId": null,
|
||||
"teacherId": 2,
|
||||
"classroomId": 1,
|
||||
"lessonTypeId": 1,
|
||||
"lessonFormat": "Очно"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
CRUD доступен по:
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` |
|
||||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||
| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило |
|
||||
|
||||
---
|
||||
|
||||
## Расписание (Lessons, deprecated)
|
||||
|
||||
Старые эндпоинты ещё физически есть в коде до удаления старых контроллеров и таблиц. Новый просмотр расписания использует `GET /api/schedule`, а новые seed-данные создаются сразу в динамической модели.
|
||||
|
||||
### `GET /api/users/lessons`
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
|
||||
### Учебные группы (Student Groups)
|
||||
|
||||
- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (1–6)
|
||||
- **Поля:** Название (уникальное), численность, форма обучения, кафедра, специальность, год начала обучения
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`
|
||||
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
||||
|
||||
### Аудитории (Classrooms)
|
||||
@@ -66,7 +67,33 @@
|
||||
|
||||
## Логика расписания
|
||||
|
||||
### Сущность «Занятие» (Lesson)
|
||||
### Динамическая модель расписания
|
||||
|
||||
Основная модель расписания строится из правил, а не из отдельных статических пар.
|
||||
|
||||
| Компонент | Назначение |
|
||||
|-----------|------------|
|
||||
| `academic_years` / `semesters` | Учебные годы и семестры. Неделя 1 считается от `semesters.start_date` |
|
||||
| `holidays` | Даты, когда занятия не проводятся и часы не списываются |
|
||||
| `academic_calendar_matrix` | Тип недели для курса и специальности: теория, сессия, каникулы, практика |
|
||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||
| `schedule_rules` | Лимит часов дисциплины в семестре |
|
||||
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
||||
|
||||
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
||||
1. Определяет семестр для каждой даты диапазона.
|
||||
2. Вычисляет номер недели и чётность.
|
||||
3. Проверяет праздники и матрицу учебного графика.
|
||||
4. Загружает правила группы или преподавателя.
|
||||
5. Симулирует уже проведённые занятия от `active_from_date`.
|
||||
6. Останавливает вывод правила, когда достигнут `total_academic_hours`.
|
||||
|
||||
Праздник считается пропуском: занятие не переносится и не списывает академические часы.
|
||||
|
||||
### Временная старая сущность «Занятие» (Lesson)
|
||||
|
||||
`lessons` пока физически остаётся в схеме до удаления старого кода, но базовая миграция больше не заполняет её тестовыми данными. Новые экраны просмотра используют `GET /api/schedule`.
|
||||
|
||||
Каждая запись в расписании содержит:
|
||||
|
||||
@@ -84,7 +111,7 @@
|
||||
|
||||
### Временны́е слоты
|
||||
|
||||
Система использует 7 фиксированных слотов по 90 минут:
|
||||
Сетка пар хранится в `time_slots` и настраивается для каждого тенанта. При миграции создаются базовые слоты:
|
||||
|
||||
| № | Время |
|
||||
|---|-------|
|
||||
@@ -104,9 +131,9 @@
|
||||
- **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа`
|
||||
- Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0
|
||||
|
||||
### Данные к составлению расписания (Schedule Data)
|
||||
### Временные старые данные к составлению расписания (Schedule Data)
|
||||
|
||||
Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания:
|
||||
Таблица `schedule_data` пока физически остаётся в схеме до удаления старого кода. Новая базовая миграция создаёт `schedule_rules` и `schedule_rule_groups` напрямую, без ETL из `schedule_data`.
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
|
||||
173
docs/DATABASE.md
173
docs/DATABASE.md
@@ -51,7 +51,8 @@ erDiagram
|
||||
BIGINT group_size
|
||||
BIGINT education_form_id FK
|
||||
BIGINT department_id FK
|
||||
INT course
|
||||
BIGINT specialty_code FK
|
||||
BIGINT year_start_study
|
||||
TIMESTAMP created_at
|
||||
}
|
||||
|
||||
@@ -142,15 +143,82 @@ erDiagram
|
||||
VARCHAR semester_type
|
||||
VARCHAR period
|
||||
}
|
||||
|
||||
time_slots {
|
||||
BIGSERIAL id PK
|
||||
INT order_number UK
|
||||
TIME start_time
|
||||
TIME end_time
|
||||
INT duration_minutes
|
||||
}
|
||||
|
||||
academic_years {
|
||||
BIGSERIAL id PK
|
||||
VARCHAR title UK
|
||||
DATE start_date
|
||||
DATE end_date
|
||||
}
|
||||
|
||||
semesters {
|
||||
BIGSERIAL id PK
|
||||
BIGINT academic_year_id FK
|
||||
VARCHAR semester_type
|
||||
DATE start_date
|
||||
DATE end_date
|
||||
}
|
||||
|
||||
holidays {
|
||||
BIGSERIAL id PK
|
||||
DATE date
|
||||
BIGINT academic_year_id FK
|
||||
VARCHAR description
|
||||
}
|
||||
|
||||
academic_calendar_matrix {
|
||||
BIGSERIAL id PK
|
||||
BIGINT semester_id FK
|
||||
INT course_number
|
||||
BIGINT specialty_id FK
|
||||
INT week_number
|
||||
VARCHAR activity_type
|
||||
}
|
||||
|
||||
schedule_rules {
|
||||
BIGSERIAL id PK
|
||||
BIGINT subject_id FK
|
||||
BIGINT semester_id FK
|
||||
DATE active_from_date
|
||||
INT total_academic_hours
|
||||
}
|
||||
|
||||
schedule_rule_groups {
|
||||
BIGINT schedule_rule_id FK,PK
|
||||
BIGINT group_id FK,PK
|
||||
}
|
||||
|
||||
schedule_rule_slots {
|
||||
BIGSERIAL id PK
|
||||
BIGINT schedule_rule_id FK
|
||||
INT day_of_week
|
||||
VARCHAR parity
|
||||
BIGINT time_slot_id FK
|
||||
BIGINT subgroup_id FK
|
||||
BIGINT teacher_id FK
|
||||
BIGINT classroom_id FK
|
||||
BIGINT lesson_type_id FK
|
||||
VARCHAR lesson_format
|
||||
}
|
||||
|
||||
departments ||--o{ users : "department_id"
|
||||
departments ||--o{ student_groups : "department_id"
|
||||
departments ||--o{ subjects : "department_id"
|
||||
departments ||--o{ schedule_data : "department_id"
|
||||
education_forms ||--o{ student_groups : "education_form_id"
|
||||
specialties ||--o{ student_groups : "specialty_code"
|
||||
student_groups ||--o{ subgroups : "group_id"
|
||||
student_groups ||--o{ lessons : "group_id"
|
||||
student_groups ||--o{ schedule_data : "group_id"
|
||||
student_groups ||--o{ schedule_rule_groups : "group_id"
|
||||
users ||--o{ lessons : "teacher_id"
|
||||
users ||--o{ teacher_subjects : "user_id"
|
||||
users ||--o{ teacher_lesson_types : "user_id"
|
||||
@@ -159,11 +227,24 @@ erDiagram
|
||||
subjects ||--o{ teacher_subjects : "subject_id"
|
||||
subjects ||--o{ teacher_lesson_types : "subject_id"
|
||||
subjects ||--o{ schedule_data : "subjects_id"
|
||||
subjects ||--o{ schedule_rules : "subject_id"
|
||||
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
|
||||
lesson_types ||--o{ schedule_data : "lesson_type_id"
|
||||
lesson_types ||--o{ schedule_rule_slots : "lesson_type_id"
|
||||
classrooms ||--o{ lessons : "classroom_id"
|
||||
classrooms ||--o{ schedule_rule_slots : "classroom_id"
|
||||
classrooms ||--o{ classroom_equipments : "classroom_id"
|
||||
equipments ||--o{ classroom_equipments : "equipment_id"
|
||||
academic_years ||--o{ semesters : "academic_year_id"
|
||||
academic_years ||--o{ holidays : "academic_year_id"
|
||||
semesters ||--o{ academic_calendar_matrix : "semester_id"
|
||||
semesters ||--o{ schedule_rules : "semester_id"
|
||||
specialties ||--o{ academic_calendar_matrix : "specialty_id"
|
||||
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
||||
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
||||
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
||||
subgroups ||--o{ schedule_rule_slots : "subgroup_id"
|
||||
users ||--o{ schedule_rule_slots : "teacher_id"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -220,7 +301,8 @@ erDiagram
|
||||
| `group_size` | BIGINT | Количество студентов |
|
||||
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
|
||||
| `department_id` | BIGINT FK → departments | Кафедра |
|
||||
| `course` | INT CHECK(1–6) | Курс |
|
||||
| `specialty_code` | BIGINT FK → specialties | Специальность |
|
||||
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
|
||||
|
||||
#### `subgroups` — Подгруппы
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -270,7 +352,10 @@ erDiagram
|
||||
|
||||
### Расписание
|
||||
|
||||
#### `lessons` — Основное расписание занятий
|
||||
#### `lessons` — Временная таблица старой модели
|
||||
|
||||
Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не заполняет её тестовыми данными. Целевой источник расписания — `schedule_rules` и `schedule_rule_slots`.
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
@@ -309,7 +394,10 @@ erDiagram
|
||||
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
|
||||
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
|
||||
|
||||
#### `schedule_data` — Данные к составлению расписания
|
||||
#### `schedule_data` — Временная таблица старой модели нагрузки
|
||||
|
||||
Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не использует её как источник seed-данных.
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
@@ -324,6 +412,81 @@ erDiagram
|
||||
| `semester_type` | VARCHAR(255) | Весенний / Осенний |
|
||||
| `period` | VARCHAR(255) | Учебный год |
|
||||
|
||||
### Динамическое расписание
|
||||
|
||||
#### `time_slots` — Временные слоты занятий
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `order_number` | INT UNIQUE | Номер пары в дне |
|
||||
| `start_time` | TIME | Время начала |
|
||||
| `end_time` | TIME | Время окончания |
|
||||
| `duration_minutes` | INT | Длительность в минутах |
|
||||
|
||||
#### `academic_years` — Учебные годы
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `title` | VARCHAR(20) UNIQUE | Название, например `2025-2026` |
|
||||
| `start_date` | DATE | Дата начала |
|
||||
| `end_date` | DATE | Дата окончания |
|
||||
|
||||
#### `semesters` — Семестры
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `academic_year_id` | BIGINT FK → academic_years | Учебный год |
|
||||
| `semester_type` | VARCHAR(20) | `autumn` или `spring` |
|
||||
| `start_date` | DATE | Дата начала, от неё считается неделя 1 |
|
||||
| `end_date` | DATE | Дата окончания |
|
||||
|
||||
#### `holidays` — Праздники и исключения
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `date` | DATE | Неучебная дата |
|
||||
| `academic_year_id` | BIGINT FK → academic_years | Учебный год |
|
||||
| `description` | VARCHAR(255) | Описание |
|
||||
|
||||
#### `academic_calendar_matrix` — Матрица учебного графика
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `semester_id` | BIGINT FK → semesters | Семестр |
|
||||
| `course_number` | INT | Курс |
|
||||
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
||||
| `week_number` | INT | Номер недели семестра |
|
||||
| `activity_type` | VARCHAR(20) | `THEORY`, `EXAM`, `VACATION`, `PRACTICE` |
|
||||
|
||||
#### `schedule_rules` — Правила расписания
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||
| `semester_id` | BIGINT FK → semesters | Семестр |
|
||||
| `active_from_date` | DATE | Дата начала действия правила |
|
||||
| `total_academic_hours` | INT | Лимит академических часов |
|
||||
|
||||
#### `schedule_rule_groups` — Группы правила
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `schedule_rule_id` | BIGINT PK, FK → schedule_rules | Правило |
|
||||
| `group_id` | BIGINT PK, FK → student_groups | Группа |
|
||||
|
||||
#### `schedule_rule_slots` — Слоты правила
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило |
|
||||
| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник |
|
||||
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
|
||||
| `time_slot_id` | BIGINT FK → time_slots | Временной слот |
|
||||
| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа |
|
||||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||||
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
||||
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
|
||||
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
|
||||
|
||||
---
|
||||
|
||||
## Flyway миграции
|
||||
@@ -340,7 +503,7 @@ erDiagram
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии |
|
||||
| `V1__init.sql` | Инициализация: справочники, динамическое расписание, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| **Фреймворк** | Нет (Vanilla JavaScript) |
|
||||
| **Модульная система** | ES6 Modules (`import`/`export`) |
|
||||
| **Стили** | CSS (модульный подход) |
|
||||
| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) |
|
||||
| **Шрифт** | Inter на странице входа, системный шрифт в кабинетах расписания |
|
||||
| **Веб-сервер** | Apache httpd:alpine |
|
||||
|
||||
---
|
||||
@@ -70,10 +70,10 @@ frontend/
|
||||
│ └── general.html # Общие настройки (заглушка)
|
||||
│
|
||||
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
||||
│ └── index.html # Просмотр расписания
|
||||
│ └── index.html # Недельный просмотр динамического расписания преподавателя
|
||||
│
|
||||
└── student/ # 🎓 Интерфейс студента
|
||||
└── index.html # Просмотр расписания (read-only)
|
||||
└── index.html # Недельный просмотр динамического расписания группы
|
||||
```
|
||||
|
||||
---
|
||||
@@ -106,7 +106,7 @@ frontend/
|
||||
| `equipments` | Оборудование | `/api/equipments` |
|
||||
| `classrooms` | Аудитории | `/api/classrooms` |
|
||||
| `subjects` | Дисциплины | `/api/subjects` |
|
||||
| `schedule` | Расписание | `/api/users/lessons` |
|
||||
| `schedule` | Старый админский экран расписания до удаления | `/api/users/lessons`; новые правила — `/api/admin/schedule-rules` |
|
||||
| `database` | Тенанты | `/api/database` |
|
||||
| `department` | Кафедры | `/api/departments` |
|
||||
| `departments-data` | Создание кафедры/специальности | `/api/departments` |
|
||||
@@ -166,6 +166,8 @@ export const api = {
|
||||
3. При успехе сохраняет в `localStorage`:
|
||||
- `token` — UUID-токен
|
||||
- `role` — роль пользователя
|
||||
- `departmentId` — кафедра пользователя
|
||||
- `userId` — ID пользователя для личного расписания преподавателя
|
||||
4. Перенаправляет на соответствующий интерфейс:
|
||||
- `ADMIN` → `/admin/`
|
||||
- `TEACHER` → `/teacher/`
|
||||
@@ -188,6 +190,32 @@ export function isAuthenticatedAsAdmin() {
|
||||
|
||||
---
|
||||
|
||||
## Кабинеты расписания
|
||||
|
||||
### Преподаватель (`/teacher/`)
|
||||
|
||||
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`.
|
||||
|
||||
Основные элементы:
|
||||
- навигация по неделям: предыдущая, текущая, следующая;
|
||||
- выбор даты через `input[type="date"]`;
|
||||
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||
- отображение дисциплины, времени, типа занятия, аудитории и всех групп правила.
|
||||
|
||||
Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново.
|
||||
|
||||
### Студент (`/student/`)
|
||||
|
||||
В текущей модели студент не связан с конкретной группой, поэтому страница использует выбор группы из `/api/groups`.
|
||||
|
||||
Основные элементы:
|
||||
- селект группы с сохранением выбора в `localStorage.studentGroupId`;
|
||||
- недельная сетка по дням;
|
||||
- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||
- отображение дисциплины, времени, преподавателя, аудитории, формата и типа занятия.
|
||||
|
||||
---
|
||||
|
||||
## CSS-архитектура
|
||||
|
||||
### Модульный подход
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
if (data.token) localStorage.setItem('token', data.token);
|
||||
if (data.role) localStorage.setItem('role', data.role);
|
||||
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
|
||||
if (data.userId) localStorage.setItem('userId', data.userId);
|
||||
|
||||
const redirect = data.redirect || '/';
|
||||
setTimeout(() => { window.location.href = redirect; }, 400);
|
||||
|
||||
650
frontend/student/index.html
Executable file → Normal file
650
frontend/student/index.html
Executable file → Normal file
@@ -3,240 +3,498 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Панель студента</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Расписание студента</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Deep dark premium background */
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--bg-card-border: rgba(255, 255, 255, 0.05);
|
||||
/* Typography */
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
/* Vibrant Accents */
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #a78bfa;
|
||||
--accent-glow: rgba(139, 92, 246, 0.4);
|
||||
--accent-secondary: #ec4899;
|
||||
--bg: #f4f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-border: #dbe3ef;
|
||||
--text: #162033;
|
||||
--muted: #66758f;
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #0b5d57;
|
||||
--blue: #2563eb;
|
||||
--warning: #b45309;
|
||||
--shadow: 0 12px 34px rgba(22, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-card: rgba(255, 255, 255, 0.7);
|
||||
--bg-card-border: rgba(0, 0, 0, 0.08);
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--accent-secondary: #d946ef;
|
||||
[data-theme="dark"] {
|
||||
--bg: #0e131d;
|
||||
--panel: #151d2b;
|
||||
--panel-border: #263449;
|
||||
--text: #edf3fb;
|
||||
--muted: #9cadc5;
|
||||
--accent: #2dd4bf;
|
||||
--accent-strong: #5eead4;
|
||||
--blue: #60a5fa;
|
||||
--warning: #f59e0b;
|
||||
--shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: background 0.4s ease, color 0.4s ease;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* ===== Animated Background ===== */
|
||||
.background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
opacity: 0.4;
|
||||
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
|
||||
transition: opacity 0.5s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-theme="light"] .shape { opacity: 0.15; }
|
||||
|
||||
.shape-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, var(--accent), transparent 60%);
|
||||
top: -20%;
|
||||
left: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
|
||||
bottom: -20%;
|
||||
right: -10%;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-30px, 30px) scale(0.95); }
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from { opacity: 0; transform: scale(0.9) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(32px);
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 24px;
|
||||
padding: 4rem 3rem;
|
||||
text-align: center;
|
||||
box-shadow:
|
||||
0 24px 48px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
[data-theme="light"] .placeholder-card {
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.placeholder-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
}
|
||||
|
||||
.placeholder-card .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 0 30px var(--accent-glow);
|
||||
}
|
||||
|
||||
.placeholder-card h1 {
|
||||
font-size: 1.75rem;
|
||||
.title h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.placeholder-card p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
.title span {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn,
|
||||
.select,
|
||||
.date-input {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 2rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
color: var(--text-primary);
|
||||
min-width: 40px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.select,
|
||||
.date-input {
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.week-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
||||
z-index: 100;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: transform 0.4s ease;
|
||||
.status {
|
||||
min-height: 22px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
.schedule {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.95);
|
||||
.day {
|
||||
min-height: 260px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-toggle--fixed {
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
.day-head {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.day-date {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lesson-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.lesson {
|
||||
border-left: 4px solid var(--blue);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--blue) 9%, transparent);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.lesson-time {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lesson-title {
|
||||
margin-top: 5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.lesson-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.schedule {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.actions,
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.actions {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.week-nav {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.week-nav .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="background">
|
||||
<div class="shape shape-1"></div>
|
||||
<div class="shape shape-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-card">
|
||||
<div class="icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
</svg>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Расписание студента</h1>
|
||||
<span id="week-label">Загрузка периода...</span>
|
||||
</div>
|
||||
<h1>Панель студента</h1>
|
||||
<p>Раздел в разработке.<br>Ожидайте обновлений!</p>
|
||||
<a href="/" class="btn-logout" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему">◐</button>
|
||||
<a class="btn" href="/" id="logout-link">Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script src="/theme-toggle.js"></script>
|
||||
<section class="toolbar">
|
||||
<select class="select" id="group-select" aria-label="Группа">
|
||||
<option value="">Загрузка групп...</option>
|
||||
</select>
|
||||
<div class="week-nav">
|
||||
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя">‹</button>
|
||||
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя">›</button>
|
||||
</div>
|
||||
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
|
||||
</section>
|
||||
|
||||
<div class="status" id="status-line"></div>
|
||||
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const groupSelect = document.getElementById('group-select');
|
||||
const grid = document.getElementById('schedule-grid');
|
||||
const statusLine = document.getElementById('status-line');
|
||||
const weekLabel = document.getElementById('week-label');
|
||||
const datePicker = document.getElementById('date-picker');
|
||||
let weekStart = startOfWeek(new Date());
|
||||
|
||||
document.getElementById('logout-link').addEventListener('click', () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('departmentId');
|
||||
localStorage.removeItem('userId');
|
||||
});
|
||||
|
||||
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
|
||||
|
||||
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
|
||||
document.getElementById('today-week').addEventListener('click', () => {
|
||||
weekStart = startOfWeek(new Date());
|
||||
loadSchedule();
|
||||
});
|
||||
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
|
||||
datePicker.addEventListener('change', () => {
|
||||
if (datePicker.value) {
|
||||
weekStart = startOfWeek(new Date(datePicker.value));
|
||||
loadSchedule();
|
||||
}
|
||||
});
|
||||
groupSelect.addEventListener('change', () => {
|
||||
localStorage.setItem('studentGroupId', groupSelect.value);
|
||||
loadSchedule();
|
||||
});
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
await loadGroups();
|
||||
await loadSchedule();
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const groups = await fetchJson('/api/groups');
|
||||
if (!groups.length) {
|
||||
groupSelect.innerHTML = '<option value="">Группы не найдены</option>';
|
||||
return;
|
||||
}
|
||||
const savedGroupId = localStorage.getItem('studentGroupId');
|
||||
groupSelect.innerHTML = groups.map(group => (
|
||||
`<option value="${escapeHtml(group.id)}">${escapeHtml(group.name)}</option>`
|
||||
)).join('');
|
||||
groupSelect.value = savedGroupId && groups.some(group => String(group.id) === savedGroupId)
|
||||
? savedGroupId
|
||||
: String(groups[0].id);
|
||||
} catch (error) {
|
||||
groupSelect.innerHTML = '<option value="">Ошибка загрузки групп</option>';
|
||||
showStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
const groupId = groupSelect.value;
|
||||
const startDate = toIsoDate(weekStart);
|
||||
const endDate = toIsoDate(addDays(weekStart, 6));
|
||||
datePicker.value = startDate;
|
||||
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
|
||||
|
||||
renderEmptyWeek('Загрузка...');
|
||||
if (!groupId) {
|
||||
showStatus('Выберите группу', false);
|
||||
renderEmptyWeek('Нет выбранной группы');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
|
||||
renderWeek(lessons);
|
||||
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
|
||||
} catch (error) {
|
||||
renderEmptyWeek('Ошибка загрузки');
|
||||
showStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWeek(lessons) {
|
||||
const lessonsByDate = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
|
||||
lessonsByDate.get(lesson.date).push(lesson);
|
||||
});
|
||||
|
||||
grid.innerHTML = daysOfWeek().map(date => {
|
||||
const key = toIsoDate(date);
|
||||
const dayLessons = lessonsByDate.get(key) || [];
|
||||
return renderDay(date, dayLessons);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderEmptyWeek(message) {
|
||||
grid.innerHTML = daysOfWeek().map(date => renderDay(date, [], message)).join('');
|
||||
}
|
||||
|
||||
function renderDay(date, lessons, emptyMessage = 'Занятий нет') {
|
||||
return `
|
||||
<article class="day">
|
||||
<div class="day-head">
|
||||
<div class="day-name">${escapeHtml(dayName(date))}</div>
|
||||
<div class="day-date">${escapeHtml(formatDate(date))}</div>
|
||||
</div>
|
||||
<div class="lesson-list">
|
||||
${lessons.length ? lessons.map(renderLesson).join('') : `<div class="empty">${escapeHtml(emptyMessage)}</div>`}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLesson(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
return `
|
||||
<div class="lesson">
|
||||
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
|
||||
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
||||
<div class="lesson-meta">
|
||||
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function moveWeek(delta) {
|
||||
weekStart = addDays(weekStart, delta * 7);
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function daysOfWeek() {
|
||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showStatus(message, isError) {
|
||||
statusLine.textContent = message;
|
||||
statusLine.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function startOfWeek(date) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const day = copy.getDay() || 7;
|
||||
copy.setDate(copy.getDate() - day + 1);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function toIsoDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function dayName(date) {
|
||||
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
694
frontend/teacher/index.html
Executable file → Normal file
694
frontend/teacher/index.html
Executable file → Normal file
@@ -1,263 +1,527 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Панель преподавателя</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Расписание преподавателя</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Deep dark premium background */
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--bg-card-border: rgba(255, 255, 255, 0.05);
|
||||
/* Typography */
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
/* Vibrant Accents */
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #a78bfa;
|
||||
--accent-glow: rgba(139, 92, 246, 0.4);
|
||||
--accent-secondary: #ec4899;
|
||||
--bg: #f5f6f8;
|
||||
--panel: #ffffff;
|
||||
--panel-border: #d8dee8;
|
||||
--text: #172033;
|
||||
--muted: #67758d;
|
||||
--accent: #1d4ed8;
|
||||
--accent-strong: #1e40af;
|
||||
--green: #047857;
|
||||
--orange: #b45309;
|
||||
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-card: rgba(255, 255, 255, 0.7);
|
||||
--bg-card-border: rgba(0, 0, 0, 0.08);
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--accent-secondary: #d946ef;
|
||||
[data-theme="dark"] {
|
||||
--bg: #10151f;
|
||||
--panel: #171f2e;
|
||||
--panel-border: #2a374b;
|
||||
--text: #eef4fb;
|
||||
--muted: #a2b0c5;
|
||||
--accent: #60a5fa;
|
||||
--accent-strong: #93c5fd;
|
||||
--green: #34d399;
|
||||
--orange: #f59e0b;
|
||||
--shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: background 0.4s ease, color 0.4s ease;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ===== Animated Background ===== */
|
||||
.background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
.topbar {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
opacity: 0.4;
|
||||
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
|
||||
transition: opacity 0.5s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-theme="light"] .shape {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, var(--accent), transparent 60%);
|
||||
top: -20%;
|
||||
left: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
|
||||
bottom: -20%;
|
||||
right: -10%;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-30px, 30px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(32px);
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 24px;
|
||||
padding: 4rem 3rem;
|
||||
text-align: center;
|
||||
box-shadow:
|
||||
0 24px 48px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
[data-theme="light"] .placeholder-card {
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.placeholder-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
}
|
||||
|
||||
.placeholder-card .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 0 30px var(--accent-glow);
|
||||
}
|
||||
|
||||
.placeholder-card h1 {
|
||||
font-size: 1.75rem;
|
||||
.title h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.placeholder-card p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
.title span {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.week-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn,
|
||||
.date-input {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 2rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
color: var(--text-primary);
|
||||
min-width: 40px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 22px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
display: grid;
|
||||
grid-template-columns: 96px repeat(7, minmax(0, 1fr));
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.corner,
|
||||
.day-head,
|
||||
.time-cell,
|
||||
.day-cell {
|
||||
border-right: 1px solid var(--panel-border);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.corner,
|
||||
.day-head {
|
||||
min-height: 58px;
|
||||
padding: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.day-head strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.day-head span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
padding: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
background: color-mix(in srgb, var(--panel-border) 26%, transparent);
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
min-height: 118px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.lesson {
|
||||
height: 100%;
|
||||
min-height: 96px;
|
||||
border-left: 4px solid var(--green);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--green) 10%, transparent);
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.lesson-title {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.lesson-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
||||
z-index: 100;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: transform 0.4s ease;
|
||||
.chip {
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.95);
|
||||
.error {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.theme-toggle--fixed {
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
@media (max-width: 980px) {
|
||||
.schedule {
|
||||
display: block;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-day {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-day .day-head {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.mobile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.toolbar,
|
||||
.actions {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.week-nav .btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="background">
|
||||
<div class="shape shape-1"></div>
|
||||
<div class="shape shape-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-card">
|
||||
<div class="icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c3 3 9 3 12 0v-5" />
|
||||
</svg>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Расписание преподавателя</h1>
|
||||
<span id="week-label">Загрузка периода...</span>
|
||||
</div>
|
||||
<h1>Панель преподавателя</h1>
|
||||
<p>Раздел в разработке.<br>Ожидайте обновлений!</p>
|
||||
<a href="/" class="btn-logout"
|
||||
onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему">◐</button>
|
||||
<a class="btn" href="/" id="logout-link">Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script src="/theme-toggle.js"></script>
|
||||
<section class="toolbar">
|
||||
<div class="week-nav">
|
||||
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя">‹</button>
|
||||
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя">›</button>
|
||||
</div>
|
||||
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
|
||||
</section>
|
||||
|
||||
<div class="status" id="status-line"></div>
|
||||
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const teacherId = localStorage.getItem('userId');
|
||||
const grid = document.getElementById('schedule-grid');
|
||||
const statusLine = document.getElementById('status-line');
|
||||
const weekLabel = document.getElementById('week-label');
|
||||
const datePicker = document.getElementById('date-picker');
|
||||
let weekStart = startOfWeek(new Date());
|
||||
|
||||
document.getElementById('logout-link').addEventListener('click', () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('departmentId');
|
||||
localStorage.removeItem('userId');
|
||||
});
|
||||
|
||||
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
|
||||
|
||||
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
|
||||
document.getElementById('today-week').addEventListener('click', () => {
|
||||
weekStart = startOfWeek(new Date());
|
||||
loadSchedule();
|
||||
});
|
||||
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
|
||||
datePicker.addEventListener('change', () => {
|
||||
if (datePicker.value) {
|
||||
weekStart = startOfWeek(new Date(datePicker.value));
|
||||
loadSchedule();
|
||||
}
|
||||
});
|
||||
|
||||
loadSchedule();
|
||||
|
||||
async function loadSchedule() {
|
||||
const startDate = toIsoDate(weekStart);
|
||||
const endDate = toIsoDate(addDays(weekStart, 6));
|
||||
datePicker.value = startDate;
|
||||
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
|
||||
|
||||
if (!teacherId) {
|
||||
renderEmptyWeek('Не удалось определить преподавателя');
|
||||
showStatus('Выполните вход заново, чтобы получить идентификатор пользователя', true);
|
||||
return;
|
||||
}
|
||||
|
||||
renderEmptyWeek('Загрузка...');
|
||||
try {
|
||||
const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`);
|
||||
renderWeek(lessons);
|
||||
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
|
||||
} catch (error) {
|
||||
renderEmptyWeek('Ошибка загрузки');
|
||||
showStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWeek(lessons) {
|
||||
if (window.matchMedia('(max-width: 980px)').matches) {
|
||||
renderMobileWeek(lessons);
|
||||
return;
|
||||
}
|
||||
|
||||
const slots = collectSlots(lessons);
|
||||
const lessonsByKey = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
|
||||
});
|
||||
|
||||
grid.innerHTML = [
|
||||
'<div class="corner"></div>',
|
||||
...daysOfWeek().map(date => renderHead(date)),
|
||||
...slots.flatMap(slot => [
|
||||
`<div class="time-cell">${escapeHtml(slot.label)}</div>`,
|
||||
...daysOfWeek().map(date => renderCell(lessonsByKey.get(`${toIsoDate(date)}:${slot.order}`)))
|
||||
])
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderMobileWeek(lessons) {
|
||||
const lessonsByDate = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
|
||||
lessonsByDate.get(lesson.date).push(lesson);
|
||||
});
|
||||
grid.innerHTML = daysOfWeek().map(date => {
|
||||
const key = toIsoDate(date);
|
||||
const dayLessons = lessonsByDate.get(key) || [];
|
||||
return `
|
||||
<article class="mobile-day">
|
||||
${renderHead(date)}
|
||||
<div class="mobile-list">
|
||||
${dayLessons.length ? dayLessons.map(renderLesson).join('') : '<div class="empty">Занятий нет</div>'}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderEmptyWeek(message) {
|
||||
if (window.matchMedia('(max-width: 980px)').matches) {
|
||||
grid.innerHTML = daysOfWeek().map(date => `
|
||||
<article class="mobile-day">
|
||||
${renderHead(date)}
|
||||
<div class="mobile-list"><div class="empty">${escapeHtml(message)}</div></div>
|
||||
</article>
|
||||
`).join('');
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = [
|
||||
'<div class="corner"></div>',
|
||||
...daysOfWeek().map(date => renderHead(date)),
|
||||
`<div class="time-cell">Неделя</div>`,
|
||||
...daysOfWeek().map(() => `<div class="day-cell"><div class="empty">${escapeHtml(message)}</div></div>`)
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderHead(date) {
|
||||
return `
|
||||
<div class="day-head">
|
||||
<strong>${escapeHtml(dayName(date))}</strong>
|
||||
<span>${escapeHtml(formatDate(date))}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCell(lesson) {
|
||||
return `<div class="day-cell">${lesson ? renderLesson(lesson) : '<div class="empty">Нет занятия</div>'}</div>`;
|
||||
}
|
||||
|
||||
function renderLesson(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
return `
|
||||
<div class="lesson">
|
||||
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
||||
<div class="lesson-meta">
|
||||
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
|
||||
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function collectSlots(lessons) {
|
||||
const slots = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
slots.set(lesson.timeSlotOrder, {
|
||||
order: lesson.timeSlotOrder,
|
||||
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
|
||||
});
|
||||
});
|
||||
if (!slots.size) {
|
||||
slots.set(1, { order: 1, label: 'Расписание' });
|
||||
}
|
||||
return [...slots.values()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
function moveWeek(delta) {
|
||||
weekStart = addDays(weekStart, delta * 7);
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function daysOfWeek() {
|
||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showStatus(message, isError) {
|
||||
statusLine.textContent = message;
|
||||
statusLine.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function startOfWeek(date) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const day = copy.getDay() || 7;
|
||||
copy.setDate(copy.getDate() - day + 1);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function toIsoDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function dayName(date) {
|
||||
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user