From 43d72c52356686ada2a54d73eccfdf491579fa0c Mon Sep 17 00:00:00 2001 From: alekan Date: Mon, 1 Jun 2026 23:27:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=B8=D1=81=D1=86=D0=B8=D0=BF=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcademicCalendarController.java | 100 +++++++ .../app/controller/GroupController.java | 65 +++- .../app/dto/AcademicCalendarSubjectDto.java | 12 + .../app/dto/GroupCalendarAssignmentDto.java | 5 +- .../app/model/AcademicCalendarSubject.java | 65 ++++ .../AcademicCalendarSubjectRepository.java | 34 +++ .../main/resources/db/migration/V1__init.sql | 24 +- docs/ACADEMIC_CALENDAR.md | 4 +- docs/API.md | 38 +++ docs/BUSINESS_LOGIC.md | 2 + docs/DATABASE.md | 25 +- docs/FRONTEND.md | 6 +- frontend/admin/css/components.css | 158 ++++++++++ frontend/admin/js/views/academic-calendar.js | 280 +++++++++++++++++- frontend/admin/js/views/groups.js | 32 +- frontend/admin/views/academic-calendar.html | 53 ++++ frontend/admin/views/groups.html | 5 +- 17 files changed, 887 insertions(+), 21 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/dto/AcademicCalendarSubjectDto.java create mode 100644 backend/src/main/java/com/magistr/app/model/AcademicCalendarSubject.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 478b952..d544576 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -9,8 +9,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @RestController @RequestMapping("/api/admin/academic-calendars") @@ -24,6 +28,8 @@ public class AcademicCalendarController { private final SpecialtiesRepository specialtiesRepository; private final SpecialtyProfileRepository profileRepository; private final EducationFormRepository educationFormRepository; + private final SubjectRepository subjectRepository; + private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final ScheduleGeneratorService scheduleGeneratorService; public AcademicCalendarController(AcademicCalendarRepository calendarRepository, @@ -33,6 +39,8 @@ public class AcademicCalendarController { SpecialtiesRepository specialtiesRepository, SpecialtyProfileRepository profileRepository, EducationFormRepository educationFormRepository, + SubjectRepository subjectRepository, + AcademicCalendarSubjectRepository calendarSubjectRepository, ScheduleGeneratorService scheduleGeneratorService) { this.calendarRepository = calendarRepository; this.calendarDayRepository = calendarDayRepository; @@ -41,6 +49,8 @@ public class AcademicCalendarController { this.specialtiesRepository = specialtiesRepository; this.profileRepository = profileRepository; this.educationFormRepository = educationFormRepository; + this.subjectRepository = subjectRepository; + this.calendarSubjectRepository = calendarSubjectRepository; this.scheduleGeneratorService = scheduleGeneratorService; } @@ -136,6 +146,34 @@ public class AcademicCalendarController { } } + @GetMapping("/{id}/subjects") + public ResponseEntity getSubjects(@PathVariable Long id) { + if (!calendarRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream() + .map(this::toCalendarSubjectDto) + .toList()); + } + + @PutMapping("/{id}/subjects") + @Transactional + public ResponseEntity saveSubjects(@PathVariable Long id, + @RequestBody List rows) { + AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null); + if (calendar == null) { + return ResponseEntity.notFound().build(); + } + try { + replaceCalendarSubjects(calendar, rows == null ? List.of() : rows); + return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream() + .map(this::toCalendarSubjectDto) + .toList()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) { if (request == null) { throw new IllegalArgumentException("Передайте данные календарного графика"); @@ -146,6 +184,9 @@ public class AcademicCalendarController { if (request.courseCount() == null || request.courseCount() <= 0) { throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); } + if (request.courseCount() > 8) { + throw new IllegalArgumentException("Количество курсов не может быть больше 8"); + } if (request.academicYearId() == null || request.specialtyId() == null || request.specialtyProfileId() == null || request.studyFormId() == null) { throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны"); @@ -209,6 +250,52 @@ public class AcademicCalendarController { throw new IllegalArgumentException("Код активности обязателен"); } + private void replaceCalendarSubjects(AcademicCalendar calendar, List rows) { + if (rows.size() > 1000) { + throw new IllegalArgumentException("Слишком много дисциплин для одного графика"); + } + + int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2); + Set subjectIds = new HashSet<>(); + Set uniqueRows = new HashSet<>(); + for (AcademicCalendarSubjectDto row : rows) { + if (row == null || row.semesterNumber() == null || row.subjectId() == null) { + throw new IllegalArgumentException("Семестр и дисциплина обязательны"); + } + if (row.semesterNumber() < 1 || row.semesterNumber() > maxSemesterNumber) { + throw new IllegalArgumentException("Номер семестра выходит за пределы графика"); + } + if (!uniqueRows.add(row.semesterNumber() + ":" + row.subjectId())) { + throw new IllegalArgumentException("Дисциплина уже привязана к этому семестру"); + } + subjectIds.add(row.subjectId()); + } + + Map subjectsById = subjectRepository.findAllById(subjectIds).stream() + .collect(Collectors.toMap(Subject::getId, subject -> subject, (left, right) -> left, HashMap::new)); + for (Long subjectId : subjectIds) { + Subject subject = subjectsById.get(subjectId); + if (subject == null) { + throw new IllegalArgumentException("Дисциплина не найдена"); + } + if (subject.isArchivedRecord()) { + throw new IllegalArgumentException("Архивную дисциплину нельзя привязать к графику"); + } + } + + calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId()); + List entities = rows.stream() + .map(row -> { + AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject(); + calendarSubject.setAcademicCalendar(calendar); + calendarSubject.setSemesterNumber(row.semesterNumber()); + calendarSubject.setSubject(subjectsById.get(row.subjectId())); + return calendarSubject; + }) + .toList(); + calendarSubjectRepository.saveAll(entities); + } + private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) { AcademicYear year = calendar.getAcademicYear(); Speciality speciality = calendar.getSpeciality(); @@ -247,4 +334,17 @@ public class AcademicCalendarController { activityType.getAllowSchedule() ); } + + private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) { + Subject subject = calendarSubject.getSubject(); + return new AcademicCalendarSubjectDto( + calendarSubject.getId(), + calendarSubject.getAcademicCalendar().getId(), + calendarSubject.getSemesterNumber(), + subject.getId(), + subject.getName(), + subject.getCode(), + subject.getDepartmentId() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 25c4f4f..6650638 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -1,15 +1,18 @@ package com.magistr.app.controller; import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.AcademicCalendarSubjectDto; import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupResponse; import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.AcademicCalendarSubject; import com.magistr.app.model.AcademicYear; import com.magistr.app.model.EducationForm; import com.magistr.app.model.Role; import com.magistr.app.model.Speciality; import com.magistr.app.model.SpecialtyProfile; +import com.magistr.app.model.Subject; import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroupCalendarAssignment; import com.magistr.app.model.StudentGroupStudyState; @@ -24,9 +27,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; @RestController @RequestMapping("/api/groups") @@ -41,6 +47,7 @@ public class GroupController { private final SpecialtyProfileRepository specialtyProfileRepository; private final AcademicYearRepository academicYearRepository; private final AcademicCalendarRepository academicCalendarRepository; + private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final ScheduleGeneratorService scheduleGeneratorService; private final StudentGroupLifecycleService groupLifecycleService; @@ -51,6 +58,7 @@ public class GroupController { SpecialtyProfileRepository specialtyProfileRepository, AcademicYearRepository academicYearRepository, AcademicCalendarRepository academicCalendarRepository, + AcademicCalendarSubjectRepository calendarSubjectRepository, StudentGroupCalendarAssignmentRepository assignmentRepository, ScheduleGeneratorService scheduleGeneratorService, StudentGroupLifecycleService groupLifecycleService) { @@ -60,6 +68,7 @@ public class GroupController { this.specialtyProfileRepository = specialtyProfileRepository; this.academicYearRepository = academicYearRepository; this.academicCalendarRepository = academicCalendarRepository; + this.calendarSubjectRepository = calendarSubjectRepository; this.assignmentRepository = assignmentRepository; this.scheduleGeneratorService = scheduleGeneratorService; this.groupLifecycleService = groupLifecycleService; @@ -303,8 +312,10 @@ public class GroupController { if (!groupRepository.existsById(id)) { return ResponseEntity.notFound().build(); } - return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream() - .map(this::toAssignmentDto) + List assignments = assignmentRepository.findByGroupIdWithDetails(id); + Map> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments); + return ResponseEntity.ok(assignments.stream() + .map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId)) .toList()); } @@ -345,7 +356,13 @@ public class GroupController { assignment.setAcademicYear(academicYear); assignment.setAcademicCalendar(calendar); scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment))); + StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment); + List subjects = calendarSubjectRepository + .findByCalendarIdWithSubject(calendar.getId()) + .stream() + .map(this::toCalendarSubjectDto) + .toList(); + return ResponseEntity.ok(toAssignmentDto(saved, subjects)); } @DeleteMapping("/{id}/calendar-assignments/{assignmentId}") @@ -360,7 +377,31 @@ public class GroupController { return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено")); } - private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) { + private Map> loadCalendarSubjectsByCalendar( + List assignments) { + Set calendarIds = assignments.stream() + .map(assignment -> assignment.getAcademicCalendar().getId()) + .collect(Collectors.toSet()); + if (calendarIds.isEmpty()) { + return Collections.emptyMap(); + } + return calendarSubjectRepository.findByCalendarIdInWithSubject(calendarIds).stream() + .collect(Collectors.groupingBy( + calendarSubject -> calendarSubject.getAcademicCalendar().getId(), + Collectors.mapping(this::toCalendarSubjectDto, Collectors.toList()) + )); + } + + private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment, + Map> subjectsByCalendarId) { + return toAssignmentDto( + assignment, + subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of()) + ); + } + + private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment, + List subjects) { return new GroupCalendarAssignmentDto( assignment.getId(), assignment.getStudentGroup().getId(), @@ -368,7 +409,21 @@ public class GroupController { assignment.getAcademicYear().getId(), assignment.getAcademicYear().getTitle(), assignment.getAcademicCalendar().getId(), - assignment.getAcademicCalendar().getTitle() + assignment.getAcademicCalendar().getTitle(), + subjects + ); + } + + private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) { + Subject subject = calendarSubject.getSubject(); + return new AcademicCalendarSubjectDto( + calendarSubject.getId(), + calendarSubject.getAcademicCalendar().getId(), + calendarSubject.getSemesterNumber(), + subject.getId(), + subject.getName(), + subject.getCode(), + subject.getDepartmentId() ); } } diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarSubjectDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarSubjectDto.java new file mode 100644 index 0000000..1fa817c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarSubjectDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record AcademicCalendarSubjectDto( + Long id, + Long calendarId, + Integer semesterNumber, + Long subjectId, + String subjectName, + String subjectCode, + Long departmentId +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java index a949840..d9a239b 100644 --- a/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java @@ -1,5 +1,7 @@ package com.magistr.app.dto; +import java.util.List; + public record GroupCalendarAssignmentDto( Long id, Long groupId, @@ -7,6 +9,7 @@ public record GroupCalendarAssignmentDto( Long academicYearId, String academicYearTitle, Long calendarId, - String calendarTitle + String calendarTitle, + List subjects ) { } diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarSubject.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarSubject.java new file mode 100644 index 0000000..76fefe9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarSubject.java @@ -0,0 +1,65 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table( + name = "academic_calendar_subjects", + uniqueConstraints = @UniqueConstraint( + name = "uq_academic_calendar_subject", + columnNames = {"calendar_id", "semester_number", "subject_id"} + ), + indexes = { + @Index(name = "idx_academic_calendar_subjects_calendar_semester", columnList = "calendar_id, semester_number"), + @Index(name = "idx_academic_calendar_subjects_subject", columnList = "subject_id") + } +) +public class AcademicCalendarSubject { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "calendar_id", nullable = false) + private AcademicCalendar academicCalendar; + + @Column(name = "semester_number", nullable = false) + private Integer semesterNumber; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "subject_id", nullable = false) + private Subject subject; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AcademicCalendar getAcademicCalendar() { + return academicCalendar; + } + + public void setAcademicCalendar(AcademicCalendar academicCalendar) { + this.academicCalendar = academicCalendar; + } + + public Integer getSemesterNumber() { + return semesterNumber; + } + + public void setSemesterNumber(Integer semesterNumber) { + this.semesterNumber = semesterNumber; + } + + public Subject getSubject() { + return subject; + } + + public void setSubject(Subject subject) { + this.subject = subject; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java new file mode 100644 index 0000000..3b40083 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java @@ -0,0 +1,34 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarSubject; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; + +public interface AcademicCalendarSubjectRepository extends JpaRepository { + + @Query(""" + select calendarSubject + from AcademicCalendarSubject calendarSubject + join fetch calendarSubject.academicCalendar calendar + join fetch calendarSubject.subject subject + where calendar.id = :calendarId + order by calendarSubject.semesterNumber asc, lower(subject.name) asc + """) + List findByCalendarIdWithSubject(@Param("calendarId") Long calendarId); + + @Query(""" + select calendarSubject + from AcademicCalendarSubject calendarSubject + join fetch calendarSubject.academicCalendar calendar + join fetch calendarSubject.subject subject + where calendar.id in :calendarIds + order by calendar.id asc, calendarSubject.semesterNumber asc, lower(subject.name) asc + """) + List findByCalendarIdInWithSubject(@Param("calendarIds") Collection calendarIds); + + void deleteByAcademicCalendar_Id(Long calendarId); +} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 5332257..d9c5829 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -531,7 +531,7 @@ CREATE TABLE IF NOT EXISTS academic_calendars ( course_count INT NOT NULL DEFAULT 4, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT chk_academic_calendars_course_count CHECK (course_count > 0), + CONSTRAINT chk_academic_calendars_course_count CHECK (course_count BETWEEN 1 AND 8), CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title) ); @@ -585,6 +585,22 @@ CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number) CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date) ON CONFLICT (calendar_id, course_number, date) DO NOTHING; +CREATE TABLE IF NOT EXISTS academic_calendar_subjects ( + id BIGSERIAL PRIMARY KEY, + calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, + semester_number INT NOT NULL, + subject_id BIGINT NOT NULL REFERENCES subjects(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_academic_calendar_subjects_semester_positive CHECK (semester_number > 0), + CONSTRAINT uq_academic_calendar_subject UNIQUE (calendar_id, semester_number, subject_id) +); + +CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_calendar_semester + ON academic_calendar_subjects(calendar_id, semester_number); + +CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_subject + ON academic_calendar_subjects(subject_id); + CREATE TABLE IF NOT EXISTS student_group_calendar_assignments ( id BIGSERIAL PRIMARY KEY, group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, @@ -932,6 +948,7 @@ COMMENT ON TABLE semesters IS 'Семестры учебного года'; COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; +COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров'; COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году'; COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; @@ -1068,6 +1085,11 @@ COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO'; COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности'; +COMMENT ON COLUMN academic_calendar_subjects.calendar_id IS 'ID календарного учебного графика'; +COMMENT ON COLUMN academic_calendar_subjects.semester_number IS 'Номер учебного семестра внутри графика: 1, 2, 3 ...'; +COMMENT ON COLUMN academic_calendar_subjects.subject_id IS 'ID дисциплины из справочника subjects'; +COMMENT ON COLUMN academic_calendar_subjects.created_at IS 'Дата и время создания привязки'; + COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы'; COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года'; COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика'; diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md index 9225edb..7e3cb74 100644 --- a/docs/ACADEMIC_CALENDAR.md +++ b/docs/ACADEMIC_CALENDAR.md @@ -19,6 +19,7 @@ - `academic_calendars` хранит график профиля, формы обучения и учебного года. - Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет. - `academic_calendar_days` хранит дневную сетку графика по курсу и дате. +- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`. - `student_groups` связана со специальностью, профилем и той же таблицей форм обучения. - `student_group_calendar_assignments` назначает группе конкретный график на учебный год. @@ -64,7 +65,8 @@ 3. В разделе календарного графика администратор создаёт учебный год и семестры. 4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. 5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. -6. В разделе групп администратор назначает календарный график группе на нужный учебный год. +6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`. +7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам. После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря. diff --git a/docs/API.md b/docs/API.md index faa2506..5d725bf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -361,6 +361,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график | | `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | | `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки | +| `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров | +| `PUT` | `/api/admin/academic-calendars/{id}/subjects` | Полная замена привязок дисциплин графика | **Тело создания/обновления графика:** ```json @@ -375,6 +377,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 ``` `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. +`courseCount` должен быть в диапазоне `1..8`. **Ячейка сетки графика:** ```json @@ -388,6 +391,17 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 } ``` +**Привязка дисциплин к графику:** +```json +[ + { "semesterNumber": 1, "subjectId": 1 }, + { "semesterNumber": 1, "subjectId": 2 }, + { "semesterNumber": 2, "subjectId": 3 } +] +``` + +`semesterNumber` — номер учебного семестра внутри графика: для 4 курсов доступны значения `1..8`. API принимает только существующие неархивные дисциплины из `/api/subjects`, не допускает дубли одной дисциплины в одном семестре и возвращает сохранённые записи с `subjectName`, `subjectCode` и `departmentId`. + ### `POST /api/admin/schedule-rules` Создание правила динамического расписания. @@ -729,6 +743,30 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 } ``` +**Ответ назначения:** +```json +{ + "id": 12, + "groupId": 1, + "groupName": "ИВТ-21-1", + "academicYearId": 1, + "academicYearTitle": "2025-2026", + "calendarId": 5, + "calendarTitle": "09.03.04 очная форма 2025-2026", + "subjects": [ + { + "id": 44, + "calendarId": 5, + "semesterNumber": 1, + "subjectId": 1, + "subjectName": "Высшая математика", + "subjectCode": "Б1.О.01", + "departmentId": 1 + } + ] +} +``` + Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. --- diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 88722a7..a50b5ee 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -60,6 +60,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график +- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика - **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным. ### Аудитории (Classrooms) @@ -118,6 +119,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие | `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года | | `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | | `academic_calendar_days` | Дневная сетка по курсу и дате | +| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров | | `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | | `time_slots` | Настраиваемая сетка пар для тенанта | | `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | diff --git a/docs/DATABASE.md b/docs/DATABASE.md index bc3f180..fc71f6c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -233,6 +233,14 @@ erDiagram BIGINT activity_type_id FK } + academic_calendar_subjects { + BIGSERIAL id PK + BIGINT calendar_id FK + INT semester_number + BIGINT subject_id FK + TIMESTAMP created_at + } + student_group_calendar_assignments { BIGSERIAL id PK BIGINT group_id FK @@ -311,6 +319,7 @@ erDiagram subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ subject_comments : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id" + subjects ||--o{ academic_calendar_subjects : "subject_id" subjects ||--o{ schedule_rules : "subject_id" lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" lesson_types ||--o{ schedule_rule_slots : "lesson_type_id" @@ -325,6 +334,7 @@ erDiagram specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" education_forms ||--o{ academic_calendars : "study_form_id" academic_calendars ||--o{ academic_calendar_days : "calendar_id" + academic_calendars ||--o{ academic_calendar_subjects : "calendar_id" academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" @@ -608,7 +618,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | | `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника | -| `course_count` | INT CHECK(> 0) | Количество курсов в сетке | +| `course_count` | INT CHECK(1–8) | Количество курсов в сетке | | `created_at` | TIMESTAMP | Дата создания | | `updated_at` | TIMESTAMP | Дата обновления | @@ -623,6 +633,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `day_of_week` | INT CHECK(1–7) | День недели ISO | | `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | +#### `academic_calendar_subjects` — Дисциплины календарного графика +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | +| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... | +| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника | +| `created_at` | TIMESTAMP | Дата создания привязки | + +Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend по `academic_calendars.course_count * 2`. + #### `student_group_calendar_assignments` — Назначения графиков группам | Колонка | Тип | Описание | |---------|-----|----------| @@ -719,7 +740,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d8f54d5..1d380e3 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -139,20 +139,20 @@ frontend/ | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | | `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | -| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | +| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `database` | Тенанты | `/api/database` | | `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | ### Особенности админских вкладок -- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. +- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. -- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. +- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index c760698..16fce42 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -1181,6 +1181,150 @@ } } +.academic-calendar-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.academic-calendar-tab { + min-height: 40px; + padding: 0.55rem 0.9rem; + border: 1px solid transparent; + border-bottom: none; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-weight: 700; + cursor: pointer; + transition: color var(--transition), background var(--transition), border-color var(--transition); +} + +.academic-calendar-tab:hover, +.academic-calendar-tab:focus-visible { + color: var(--text-primary); + background: var(--bg-hover); + outline: none; +} + +.academic-calendar-tab.active { + color: var(--accent-hover); + background: var(--bg-card); + border-color: var(--bg-card-border); +} + +.academic-calendar-tab-panel { + display: grid; + gap: 1rem; +} + +.academic-calendar-tab-panel[hidden] { + display: none; +} + +.calendar-subject-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 0.75rem; +} + +.calendar-subject-semester { + min-height: 104px; + padding: 0.8rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-input) 84%, var(--bg-card)); +} + +.calendar-subject-semester-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.65rem; +} + +.calendar-subject-semester-head strong { + color: var(--text-primary); + font-size: 0.92rem; +} + +.calendar-subject-semester-head span, +.calendar-subject-empty { + color: var(--text-secondary); + font-size: 0.84rem; +} + +.calendar-subject-chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.calendar-subject-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 30px; + padding: 0.25rem 0.28rem 0.25rem 0.55rem; + gap: 0.35rem; + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--bg-card-border)); + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 9%, var(--bg-card)); + color: var(--text-primary); + font-size: 0.84rem; +} + +.calendar-subject-chip span { + overflow: hidden; + text-overflow: ellipsis; +} + +.calendar-subject-remove { + width: 22px; + height: 22px; + flex: 0 0 22px; + border: none; + border-radius: 50%; + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.calendar-subject-remove:hover, +.calendar-subject-remove:focus-visible { + background: var(--danger); + color: white; + outline: none; +} + +.assignment-subjects { + display: grid; + gap: 0.45rem; + min-width: 220px; +} + +.assignment-subject-semester { + display: grid; + gap: 0.15rem; +} + +.assignment-subject-semester strong { + color: var(--accent-hover); + font-size: 0.78rem; +} + +.assignment-subject-semester span { + color: var(--text-primary); + font-size: 0.86rem; + line-height: 1.35; +} + .calendar-fill-panel { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)) auto; @@ -1715,6 +1859,10 @@ grid-template-columns: repeat(2, minmax(180px, 1fr)); } + .calendar-subject-summary { + grid-template-columns: repeat(2, minmax(220px, 1fr)); + } + .calendar-semesters-grid { grid-template-columns: 1fr; grid-auto-rows: auto; @@ -1769,6 +1917,16 @@ grid-template-columns: 1fr; } + .academic-calendar-tab { + flex: 1 1 100%; + border-radius: var(--radius-sm); + border: 1px solid var(--bg-card-border); + } + + .calendar-subject-summary { + grid-template-columns: 1fr; + } + .calendar-day-dialog-grid { grid-template-columns: 1fr; } diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 9c8eede..2116d6c 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -70,6 +70,16 @@ export async function initAcademicCalendar() { const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope'); const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save'); const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear'); + const calendarTabButtons = Array.from(document.querySelectorAll('[data-calendar-tab]')); + const calendarTabPanels = Array.from(document.querySelectorAll('[data-calendar-tab-panel]')); + const subjectCalendarSelect = document.getElementById('calendar-subject-calendar'); + const subjectSemesterSelect = document.getElementById('calendar-subject-semester'); + const subjectSelect = document.getElementById('calendar-subject-id'); + const subjectAddButton = document.getElementById('calendar-subject-add'); + const subjectSaveButton = document.getElementById('calendar-subject-save'); + const subjectRefreshButton = document.getElementById('calendar-subject-refresh'); + const subjectSummary = document.getElementById('calendar-subject-summary'); + const subjectCount = document.getElementById('calendar-subject-count'); let academicYears = []; let semesters = []; @@ -78,6 +88,8 @@ export async function initAcademicCalendar() { let studyForms = []; let activityTypes = []; let calendars = []; + let subjects = []; + let calendarSubjectRows = []; let timeSlotScopes = []; let timeSlotDateAssignments = []; let activeCalendarDay = null; @@ -95,6 +107,7 @@ export async function initAcademicCalendar() { await Promise.all([ loadBaseLists(), loadYears(), + loadSubjects(), loadStudyForms(), loadActivityTypes(), loadTimeSlotScopes(), @@ -106,6 +119,9 @@ export async function initAcademicCalendar() { } function bindEvents() { + calendarTabButtons.forEach(button => { + button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab)); + }); academicYearsRefreshButton?.addEventListener('click', loadYears); calendarsRefreshButton?.addEventListener('click', loadCalendars); academicYearResetButton?.addEventListener('click', resetAcademicYearForm); @@ -115,6 +131,11 @@ export async function initAcademicCalendar() { editorLoadButton?.addEventListener('click', loadCalendarGrid); editorSaveButton?.addEventListener('click', saveCalendarGrid); fillApplyButton?.addEventListener('click', applyCalendarFill); + subjectCalendarSelect?.addEventListener('change', loadCalendarSubjects); + subjectAddButton?.addEventListener('click', addCalendarSubject); + subjectSaveButton?.addEventListener('click', saveCalendarSubjects); + subjectRefreshButton?.addEventListener('click', loadCalendarSubjects); + subjectSummary?.addEventListener('click', handleSubjectSummaryClick); calendarGrid?.addEventListener('change', (event) => { if (event.target.matches('.calendar-day-activity')) { @@ -361,6 +382,11 @@ export async function initAcademicCalendar() { populateStudyFormSelect(); } + async function loadSubjects() { + subjects = await api.get('/api/subjects'); + populateSubjectSelect(); + } + async function loadActivityTypes() { activityTypes = await api.get('/api/admin/calendar/activity-types'); populateActivitySelects(); @@ -380,6 +406,7 @@ export async function initAcademicCalendar() { calendars = await api.get('/api/admin/academic-calendars'); renderCalendars(); populateEditorCalendarSelect(); + populateSubjectCalendarSelect(); } catch (error) { calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; throw error; @@ -402,6 +429,7 @@ export async function initAcademicCalendar() { + @@ -440,9 +468,11 @@ export async function initAcademicCalendar() { async function handleCalendarTableClick(event) { const editButton = event.target.closest('.btn-edit-calendar'); const gridButton = event.target.closest('.btn-open-calendar-grid'); + const subjectsButton = event.target.closest('.btn-open-calendar-subjects'); const deleteButton = event.target.closest('.btn-delete-calendar'); if (editButton) { + activateCalendarTab('manage'); const calendar = calendars.find(item => item.id == editButton.dataset.id); if (!calendar) return; calendarFormTitle.textContent = 'Редактирование календарного графика'; @@ -460,13 +490,23 @@ export async function initAcademicCalendar() { } if (gridButton) { + activateCalendarTab('grid'); editorCalendarSelect.value = gridButton.dataset.id; - syncSelects(editorCalendarSelect); + refreshSelectVisuals(editorCalendarSelect); await loadCalendarGrid(); calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } + if (subjectsButton) { + activateCalendarTab('subjects'); + subjectCalendarSelect.value = subjectsButton.dataset.id; + refreshSelectVisuals(subjectCalendarSelect); + await loadCalendarSubjects(); + subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + if (deleteButton) { if (!confirm('Удалить календарный график?')) return; try { @@ -489,6 +529,149 @@ export async function initAcademicCalendar() { hideAlert('academic-calendar-alert'); } + function activateCalendarTab(tabName) { + const target = tabName || 'manage'; + calendarTabButtons.forEach(button => { + const active = button.dataset.calendarTab === target; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + calendarTabPanels.forEach(panel => { + const active = panel.dataset.calendarTabPanel === target; + panel.classList.toggle('active', active); + panel.hidden = !active; + }); + } + + async function loadCalendarSubjects() { + hideAlert('calendar-subject-alert'); + const calendar = selectedSubjectCalendar(); + populateSubjectSemesterSelect(); + calendarSubjectRows = []; + if (!calendar) { + renderCalendarSubjects(); + return; + } + subjectSummary.innerHTML = '
Загрузка...
'; + try { + const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); + calendarSubjectRows = sortCalendarSubjectRows(rows || []); + renderCalendarSubjects(); + } catch (error) { + subjectSummary.innerHTML = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; + showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error'); + } + } + + function addCalendarSubject() { + hideAlert('calendar-subject-alert'); + const calendar = selectedSubjectCalendar(); + const semesterNumber = Number(subjectSemesterSelect.value); + const subjectId = Number(subjectSelect.value); + if (!calendar || !semesterNumber || !subjectId) { + showAlert('calendar-subject-alert', 'Выберите график, семестр и дисциплину', 'error'); + return; + } + if (calendarSubjectRows.some(row => + Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId + )) { + showAlert('calendar-subject-alert', 'Дисциплина уже добавлена в этот семестр', 'error'); + return; + } + const subject = subjects.find(item => Number(item.id) === subjectId); + if (!subject) { + showAlert('calendar-subject-alert', 'Дисциплина не найдена в справочнике', 'error'); + return; + } + calendarSubjectRows = sortCalendarSubjectRows([ + ...calendarSubjectRows, + { + id: null, + calendarId: calendar.id, + semesterNumber, + subjectId, + subjectName: subject.name, + subjectCode: subject.code, + departmentId: subject.departmentId + } + ]); + renderCalendarSubjects(); + showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success'); + } + + async function saveCalendarSubjects() { + hideAlert('calendar-subject-alert'); + const calendar = selectedSubjectCalendar(); + if (!calendar) { + showAlert('calendar-subject-alert', 'Выберите календарный график', 'error'); + return; + } + const payload = calendarSubjectRows.map(row => ({ + semesterNumber: Number(row.semesterNumber), + subjectId: Number(row.subjectId) + })); + try { + const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload); + calendarSubjectRows = sortCalendarSubjectRows(saved || []); + renderCalendarSubjects(); + showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success'); + } catch (error) { + showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error'); + } + } + + function handleSubjectSummaryClick(event) { + const removeButton = event.target.closest('.calendar-subject-remove'); + if (!removeButton) return; + const semesterNumber = Number(removeButton.dataset.semester); + const subjectId = Number(removeButton.dataset.subject); + calendarSubjectRows = calendarSubjectRows.filter(row => + Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId + ); + renderCalendarSubjects(); + showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success'); + } + + function renderCalendarSubjects() { + const calendar = selectedSubjectCalendar(); + if (!calendar) { + subjectCount.textContent = ''; + subjectSummary.innerHTML = '
Выберите календарный график
'; + return; + } + + const semesterCount = calendarSemesterCount(calendar); + subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`; + subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => { + const semesterNumber = index + 1; + const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber); + return ` +
+
+ ${escapeHtml(semesterOptionLabel(semesterNumber))} + ${semesterRows.length} дисциплин +
+ ${semesterRows.length + ? `
${semesterRows.map(renderSubjectChip).join('')}
` + : '
Нет дисциплин
'} +
+ `; + }).join(''); + } + + function renderSubjectChip(row) { + return ` + + ${escapeHtml(subjectRowLabel(row))} + + + `; + } + async function loadCalendarGrid() { hideAlert('calendar-editor-alert'); const calendar = selectedEditorCalendar(); @@ -568,8 +751,8 @@ export async function initAcademicCalendar() { selectedCalendarDayKey = null; selectedCalendarDays = []; const grouped = groupBy(rows, row => row.courseNumber); - calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => ` -
+ calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` +
@@ -1104,6 +1287,21 @@ export async function initAcademicCalendar() { syncSelects(calendarStudyFormSelect); } + function populateSubjectSelect() { + if (!subjectSelect) return; + const current = subjectSelect.value; + const sortedSubjects = [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru')); + subjectSelect.innerHTML = sortedSubjects.length + ? '' + sortedSubjects.map(subject => + `` + ).join('') + : ''; + if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) { + subjectSelect.value = current; + } + syncSelects(subjectSelect); + } + function populateActivitySelects() { const activityOptions = '' + activityTypes.map(type => ``).join(''); @@ -1121,6 +1319,41 @@ export async function initAcademicCalendar() { syncSelects(editorCalendarSelect); } + function populateSubjectCalendarSelect() { + if (!subjectCalendarSelect) return; + const current = subjectCalendarSelect.value; + subjectCalendarSelect.innerHTML = calendars.length + ? '' + calendars.map(calendar => + `` + ).join('') + : ''; + if (current && calendars.some(calendar => String(calendar.id) === String(current))) { + subjectCalendarSelect.value = current; + } + populateSubjectSemesterSelect(); + syncSelects(subjectCalendarSelect); + } + + function populateSubjectSemesterSelect() { + if (!subjectSemesterSelect) return; + const calendar = selectedSubjectCalendar(); + const current = subjectSemesterSelect.value; + if (!calendar) { + subjectSemesterSelect.innerHTML = ''; + syncSelects(subjectSemesterSelect); + return; + } + const semesterCount = calendarSemesterCount(calendar); + subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => { + const semesterNumber = index + 1; + return ``; + }).join(''); + if (current && Number(current) >= 1 && Number(current) <= semesterCount) { + subjectSemesterSelect.value = current; + } + syncSelects(subjectSemesterSelect); + } + function populateFillCourseSelect() { const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); fillCourseSelect.innerHTML = '' + @@ -1145,6 +1378,38 @@ export async function initAcademicCalendar() { return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); } + function selectedSubjectCalendar() { + return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value)); + } + + function calendarSemesterCount(calendar) { + return Math.max(1, Number(calendar?.courseCount || 0) * 2); + } + + function semesterOptionLabel(semesterNumber) { + const course = Math.ceil(Number(semesterNumber) / 2); + const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний'; + return `${semesterNumber} семестр · ${course} курс, ${type}`; + } + + function sortCalendarSubjectRows(rows) { + return [...rows].sort((a, b) => { + const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber); + if (semesterDiff !== 0) return semesterDiff; + return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru'); + }); + } + + function subjectLabel(subject) { + const code = subject.code ? `${subject.code} · ` : ''; + return `${code}${subject.name || 'Без названия'}`; + } + + function subjectRowLabel(row) { + const code = row.subjectCode ? `${row.subjectCode} · ` : ''; + return `${code}${row.subjectName || row.subjectId || 'Без названия'}`; + } + function theoryActivityId() { return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; } @@ -1265,7 +1530,16 @@ export async function initAcademicCalendar() { function syncSelects(...selects) { selects.filter(Boolean).forEach(select => { + refreshSelectVisuals(select); select.dispatchEvent(new Event('change', { bubbles: true })); }); } + + function refreshSelectVisuals(...selects) { + selects.filter(Boolean).forEach(select => { + if (select.customSelectInstance) { + select.customSelectInstance.rebuildMenu(); + } + }); + } } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index 8d93e0b..121f358 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -522,24 +522,25 @@ export async function initGroups() { } async function loadAssignmentsList(groupId) { - manageCalendarTbody.innerHTML = 'Загрузка...'; + manageCalendarTbody.innerHTML = 'Загрузка...'; try { currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`); renderAssignmentsList(); } catch (error) { - manageCalendarTbody.innerHTML = `Ошибка: ${escapeHtml(error.message)}`; + manageCalendarTbody.innerHTML = `Ошибка: ${escapeHtml(error.message)}`; } } function renderAssignmentsList() { if (!currentAssignments.length) { - manageCalendarTbody.innerHTML = 'Календарные графики не назначены'; + manageCalendarTbody.innerHTML = 'Календарные графики не назначены'; return; } manageCalendarTbody.innerHTML = currentAssignments.map(assignment => ` ${escapeHtml(assignment.academicYearTitle)} ${escapeHtml(assignment.calendarTitle)} + ${renderAssignmentSubjects(assignment.subjects || [])} @@ -547,6 +548,26 @@ export async function initGroups() { `).join(''); } + function renderAssignmentSubjects(subjects) { + if (!subjects.length) { + return 'Не привязаны'; + } + const grouped = subjects.reduce((acc, subject) => { + const key = String(subject.semesterNumber || '-'); + if (!acc.has(key)) acc.set(key, []); + acc.get(key).push(subject); + return acc; + }, new Map()); + return `
${Array.from(grouped.entries()) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([semesterNumber, items]) => ` +
+ ${escapeHtml(semesterNumber)} семестр + ${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')} +
+ `).join('')}
`; + } + async function saveAssignment(event) { event.preventDefault(); hideAlert('manage-calendar-alert'); @@ -602,6 +623,11 @@ export async function initGroups() { return name ? `${code} — ${name}` : code; } + function subjectLabel(subject) { + const code = subject.subjectCode ? `${subject.subjectCode} · ` : ''; + return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`; + } + function statusLabel(group) { if (group.status === 'ARCHIVED') return 'Архив'; return group.studyStateName || (group.active ? 'Активна' : 'Неактивна'); diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html index 8e47802..915f0c7 100644 --- a/frontend/admin/views/academic-calendar.html +++ b/frontend/admin/views/academic-calendar.html @@ -1,3 +1,10 @@ +
+ + + +
+ +

Учебный год

@@ -152,6 +159,9 @@
+
+ + + +