начало работы построения динамического расписания и документация

This commit is contained in:
Zuev
2026-04-29 15:42:05 +03:00
parent 813e81be70
commit 8f71b9b2b5
43 changed files with 3894 additions and 457 deletions

21
.codex/config.toml Normal file
View 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

View 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-архитектура.

View File

@@ -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()
);
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}
}
}

View File

@@ -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", "Произошла ошибка при генерации расписания"));
}
}
}

View File

@@ -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();
}
}

View File

@@ -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()
);
}
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View 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
) {
}

View File

@@ -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;
}
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View 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
) {
}

View 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
) {
}

View File

@@ -0,0 +1,8 @@
package com.magistr.app.model;
public enum AcademicActivityType {
THEORY,
EXAM,
VACATION,
PRACTICE
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum ScheduleParity {
BOTH,
EVEN,
ODD
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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
);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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 типа занятия';

View File

@@ -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`

View File

@@ -41,7 +41,8 @@
### Учебные группы (Student Groups)
- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (16)
- **Поля:** Название (уникальное), численность, форма обучения, кафедра, специальность, год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - 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`.
| Поле | Описание |
|------|----------|

View File

@@ -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(16) | Курс |
| `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(17) | День недели: 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` | Инициализация: справочники, динамическое расписание, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов

View File

@@ -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-архитектура
### Модульный подход

View File

@@ -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
View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
</script>
</body>
</html>
</html>

694
frontend/teacher/index.html Executable file → Normal file
View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
</script>
</body>
</html>
</html>