Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign
This commit is contained in:
@@ -9,8 +9,12 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/academic-calendars")
|
@RequestMapping("/api/admin/academic-calendars")
|
||||||
@@ -24,6 +28,8 @@ public class AcademicCalendarController {
|
|||||||
private final SpecialtiesRepository specialtiesRepository;
|
private final SpecialtiesRepository specialtiesRepository;
|
||||||
private final SpecialtyProfileRepository profileRepository;
|
private final SpecialtyProfileRepository profileRepository;
|
||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
|
private final SubjectRepository subjectRepository;
|
||||||
|
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
|
||||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||||
@@ -33,6 +39,8 @@ public class AcademicCalendarController {
|
|||||||
SpecialtiesRepository specialtiesRepository,
|
SpecialtiesRepository specialtiesRepository,
|
||||||
SpecialtyProfileRepository profileRepository,
|
SpecialtyProfileRepository profileRepository,
|
||||||
EducationFormRepository educationFormRepository,
|
EducationFormRepository educationFormRepository,
|
||||||
|
SubjectRepository subjectRepository,
|
||||||
|
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService) {
|
ScheduleGeneratorService scheduleGeneratorService) {
|
||||||
this.calendarRepository = calendarRepository;
|
this.calendarRepository = calendarRepository;
|
||||||
this.calendarDayRepository = calendarDayRepository;
|
this.calendarDayRepository = calendarDayRepository;
|
||||||
@@ -41,6 +49,8 @@ public class AcademicCalendarController {
|
|||||||
this.specialtiesRepository = specialtiesRepository;
|
this.specialtiesRepository = specialtiesRepository;
|
||||||
this.profileRepository = profileRepository;
|
this.profileRepository = profileRepository;
|
||||||
this.educationFormRepository = educationFormRepository;
|
this.educationFormRepository = educationFormRepository;
|
||||||
|
this.subjectRepository = subjectRepository;
|
||||||
|
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
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<AcademicCalendarSubjectDto> 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) {
|
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
throw new IllegalArgumentException("Передайте данные календарного графика");
|
throw new IllegalArgumentException("Передайте данные календарного графика");
|
||||||
@@ -146,6 +184,9 @@ public class AcademicCalendarController {
|
|||||||
if (request.courseCount() == null || request.courseCount() <= 0) {
|
if (request.courseCount() == null || request.courseCount() <= 0) {
|
||||||
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
||||||
}
|
}
|
||||||
|
if (request.courseCount() > 8) {
|
||||||
|
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
|
||||||
|
}
|
||||||
if (request.academicYearId() == null || request.specialtyId() == null
|
if (request.academicYearId() == null || request.specialtyId() == null
|
||||||
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
|
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
|
||||||
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
|
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
|
||||||
@@ -209,6 +250,52 @@ public class AcademicCalendarController {
|
|||||||
throw new IllegalArgumentException("Код активности обязателен");
|
throw new IllegalArgumentException("Код активности обязателен");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
|
||||||
|
if (rows.size() > 1000) {
|
||||||
|
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2);
|
||||||
|
Set<Long> subjectIds = new HashSet<>();
|
||||||
|
Set<String> 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<Long, Subject> 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<AcademicCalendarSubject> 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) {
|
private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
|
||||||
AcademicYear year = calendar.getAcademicYear();
|
AcademicYear year = calendar.getAcademicYear();
|
||||||
Speciality speciality = calendar.getSpeciality();
|
Speciality speciality = calendar.getSpeciality();
|
||||||
@@ -247,4 +334,17 @@ public class AcademicCalendarController {
|
|||||||
activityType.getAllowSchedule()
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
|
import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||||
import com.magistr.app.dto.CreateGroupRequest;
|
import com.magistr.app.dto.CreateGroupRequest;
|
||||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||||
import com.magistr.app.dto.GroupResponse;
|
import com.magistr.app.dto.GroupResponse;
|
||||||
import com.magistr.app.model.AcademicCalendar;
|
import com.magistr.app.model.AcademicCalendar;
|
||||||
|
import com.magistr.app.model.AcademicCalendarSubject;
|
||||||
import com.magistr.app.model.AcademicYear;
|
import com.magistr.app.model.AcademicYear;
|
||||||
import com.magistr.app.model.EducationForm;
|
import com.magistr.app.model.EducationForm;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.Speciality;
|
import com.magistr.app.model.Speciality;
|
||||||
import com.magistr.app.model.SpecialtyProfile;
|
import com.magistr.app.model.SpecialtyProfile;
|
||||||
|
import com.magistr.app.model.Subject;
|
||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
import com.magistr.app.model.StudentGroupStudyState;
|
import com.magistr.app.model.StudentGroupStudyState;
|
||||||
@@ -24,9 +27,12 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/groups")
|
@RequestMapping("/api/groups")
|
||||||
@@ -41,6 +47,7 @@ public class GroupController {
|
|||||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||||
private final AcademicYearRepository academicYearRepository;
|
private final AcademicYearRepository academicYearRepository;
|
||||||
private final AcademicCalendarRepository academicCalendarRepository;
|
private final AcademicCalendarRepository academicCalendarRepository;
|
||||||
|
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
private final StudentGroupLifecycleService groupLifecycleService;
|
private final StudentGroupLifecycleService groupLifecycleService;
|
||||||
@@ -51,6 +58,7 @@ public class GroupController {
|
|||||||
SpecialtyProfileRepository specialtyProfileRepository,
|
SpecialtyProfileRepository specialtyProfileRepository,
|
||||||
AcademicYearRepository academicYearRepository,
|
AcademicYearRepository academicYearRepository,
|
||||||
AcademicCalendarRepository academicCalendarRepository,
|
AcademicCalendarRepository academicCalendarRepository,
|
||||||
|
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||||
ScheduleGeneratorService scheduleGeneratorService,
|
ScheduleGeneratorService scheduleGeneratorService,
|
||||||
StudentGroupLifecycleService groupLifecycleService) {
|
StudentGroupLifecycleService groupLifecycleService) {
|
||||||
@@ -60,6 +68,7 @@ public class GroupController {
|
|||||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||||
this.academicYearRepository = academicYearRepository;
|
this.academicYearRepository = academicYearRepository;
|
||||||
this.academicCalendarRepository = academicCalendarRepository;
|
this.academicCalendarRepository = academicCalendarRepository;
|
||||||
|
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||||
this.assignmentRepository = assignmentRepository;
|
this.assignmentRepository = assignmentRepository;
|
||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
this.groupLifecycleService = groupLifecycleService;
|
this.groupLifecycleService = groupLifecycleService;
|
||||||
@@ -303,8 +312,10 @@ public class GroupController {
|
|||||||
if (!groupRepository.existsById(id)) {
|
if (!groupRepository.existsById(id)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream()
|
List<StudentGroupCalendarAssignment> assignments = assignmentRepository.findByGroupIdWithDetails(id);
|
||||||
.map(this::toAssignmentDto)
|
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments);
|
||||||
|
return ResponseEntity.ok(assignments.stream()
|
||||||
|
.map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId))
|
||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +356,13 @@ public class GroupController {
|
|||||||
assignment.setAcademicYear(academicYear);
|
assignment.setAcademicYear(academicYear);
|
||||||
assignment.setAcademicCalendar(calendar);
|
assignment.setAcademicCalendar(calendar);
|
||||||
scheduleGeneratorService.clearCache();
|
scheduleGeneratorService.clearCache();
|
||||||
return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment)));
|
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
|
||||||
|
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
|
||||||
|
.findByCalendarIdWithSubject(calendar.getId())
|
||||||
|
.stream()
|
||||||
|
.map(this::toCalendarSubjectDto)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(toAssignmentDto(saved, subjects));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
|
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
|
||||||
@@ -360,7 +377,31 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
|
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) {
|
private Map<Long, List<AcademicCalendarSubjectDto>> loadCalendarSubjectsByCalendar(
|
||||||
|
List<StudentGroupCalendarAssignment> assignments) {
|
||||||
|
Set<Long> 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<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId) {
|
||||||
|
return toAssignmentDto(
|
||||||
|
assignment,
|
||||||
|
subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
|
||||||
|
List<AcademicCalendarSubjectDto> subjects) {
|
||||||
return new GroupCalendarAssignmentDto(
|
return new GroupCalendarAssignmentDto(
|
||||||
assignment.getId(),
|
assignment.getId(),
|
||||||
assignment.getStudentGroup().getId(),
|
assignment.getStudentGroup().getId(),
|
||||||
@@ -368,7 +409,21 @@ public class GroupController {
|
|||||||
assignment.getAcademicYear().getId(),
|
assignment.getAcademicYear().getId(),
|
||||||
assignment.getAcademicYear().getTitle(),
|
assignment.getAcademicYear().getTitle(),
|
||||||
assignment.getAcademicCalendar().getId(),
|
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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public record GroupCalendarAssignmentDto(
|
public record GroupCalendarAssignmentDto(
|
||||||
Long id,
|
Long id,
|
||||||
Long groupId,
|
Long groupId,
|
||||||
@@ -7,6 +9,7 @@ public record GroupCalendarAssignmentDto(
|
|||||||
Long academicYearId,
|
Long academicYearId,
|
||||||
String academicYearTitle,
|
String academicYearTitle,
|
||||||
Long calendarId,
|
Long calendarId,
|
||||||
String calendarTitle
|
String calendarTitle,
|
||||||
|
List<AcademicCalendarSubjectDto> subjects
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AcademicCalendarSubject, Long> {
|
||||||
|
|
||||||
|
@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<AcademicCalendarSubject> 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<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
|
||||||
|
|
||||||
|
void deleteByAcademicCalendar_Id(Long calendarId);
|
||||||
|
}
|
||||||
@@ -563,7 +563,7 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
|
|||||||
course_count INT NOT NULL DEFAULT 4,
|
course_count INT NOT NULL DEFAULT 4,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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)
|
CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -617,6 +617,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)
|
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;
|
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 (
|
CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||||||
@@ -964,6 +980,7 @@ COMMENT ON TABLE semesters IS 'Семестры учебного года';
|
|||||||
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
|
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
|
||||||
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
|
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
|
||||||
COMMENT ON TABLE academic_calendar_days 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 student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
|
||||||
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
||||||
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
|
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
|
||||||
@@ -1101,6 +1118,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.day_of_week IS 'День недели ISO';
|
||||||
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности';
|
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.group_id IS 'ID учебной группы';
|
||||||
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года';
|
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года';
|
||||||
COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';
|
COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
- `academic_calendars` хранит график профиля, формы обучения и учебного года.
|
- `academic_calendars` хранит график профиля, формы обучения и учебного года.
|
||||||
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
|
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
|
||||||
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате.
|
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате.
|
||||||
|
- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`.
|
||||||
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
|
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
|
||||||
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
|
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
|
||||||
|
|
||||||
@@ -64,7 +65,8 @@
|
|||||||
3. В разделе календарного графика администратор создаёт учебный год и семестры.
|
3. В разделе календарного графика администратор создаёт учебный год и семестры.
|
||||||
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов.
|
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов.
|
||||||
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
|
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
|
||||||
6. В разделе групп администратор назначает календарный график группе на нужный учебный год.
|
6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
|
||||||
|
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.
|
||||||
|
|
||||||
После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря.
|
После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря.
|
||||||
|
|
||||||
|
|||||||
38
docs/API.md
38
docs/API.md
@@ -396,6 +396,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
|
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
|
||||||
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
|
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
|
||||||
| `PUT` | `/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
|
```json
|
||||||
@@ -410,6 +412,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
```
|
```
|
||||||
|
|
||||||
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
|
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
|
||||||
|
`courseCount` должен быть в диапазоне `1..8`.
|
||||||
|
|
||||||
**Ячейка сетки графика:**
|
**Ячейка сетки графика:**
|
||||||
```json
|
```json
|
||||||
@@ -423,6 +426,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`
|
### `POST /api/admin/schedule-rules`
|
||||||
|
|
||||||
Создание правила динамического расписания.
|
Создание правила динамического расписания.
|
||||||
@@ -787,6 +801,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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
|
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||||
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
||||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||||
|
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
|
||||||
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||||
|
|
||||||
### Аудитории (Classrooms)
|
### Аудитории (Classrooms)
|
||||||
@@ -125,6 +126,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
|
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
|
||||||
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
|
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
|
||||||
| `academic_calendar_days` | Дневная сетка по курсу и дате |
|
| `academic_calendar_days` | Дневная сетка по курсу и дате |
|
||||||
|
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
|
||||||
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
|
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
|
||||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||||
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
||||||
|
|||||||
@@ -248,6 +248,14 @@ erDiagram
|
|||||||
BIGINT activity_type_id FK
|
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 {
|
student_group_calendar_assignments {
|
||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
BIGINT group_id FK
|
BIGINT group_id FK
|
||||||
@@ -328,6 +336,7 @@ erDiagram
|
|||||||
subjects ||--o{ teacher_subjects : "subject_id"
|
subjects ||--o{ teacher_subjects : "subject_id"
|
||||||
subjects ||--o{ subject_comments : "subject_id"
|
subjects ||--o{ subject_comments : "subject_id"
|
||||||
subjects ||--o{ teacher_lesson_types : "subject_id"
|
subjects ||--o{ teacher_lesson_types : "subject_id"
|
||||||
|
subjects ||--o{ academic_calendar_subjects : "subject_id"
|
||||||
subjects ||--o{ schedule_rules : "subject_id"
|
subjects ||--o{ schedule_rules : "subject_id"
|
||||||
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
|
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
|
||||||
lesson_types ||--o{ schedule_rule_slots : "lesson_type_id"
|
lesson_types ||--o{ schedule_rule_slots : "lesson_type_id"
|
||||||
@@ -342,6 +351,7 @@ erDiagram
|
|||||||
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
|
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
|
||||||
education_forms ||--o{ academic_calendars : "study_form_id"
|
education_forms ||--o{ academic_calendars : "study_form_id"
|
||||||
academic_calendars ||--o{ academic_calendar_days : "calendar_id"
|
academic_calendars ||--o{ academic_calendar_days : "calendar_id"
|
||||||
|
academic_calendars ||--o{ academic_calendar_subjects : "calendar_id"
|
||||||
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
|
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
|
||||||
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
||||||
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
||||||
@@ -645,7 +655,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
||||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
|
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
|
||||||
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
|
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
|
||||||
| `course_count` | INT CHECK(> 0) | Количество курсов в сетке |
|
| `course_count` | INT CHECK(1–8) | Количество курсов в сетке |
|
||||||
| `created_at` | TIMESTAMP | Дата создания |
|
| `created_at` | TIMESTAMP | Дата создания |
|
||||||
| `updated_at` | TIMESTAMP | Дата обновления |
|
| `updated_at` | TIMESTAMP | Дата обновления |
|
||||||
|
|
||||||
@@ -660,6 +670,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
|
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
|
||||||
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
|
| `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` — Назначения графиков группам
|
#### `student_group_calendar_assignments` — Назначения графиков группам
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
|---------|-----|----------|
|
|---------|-----|----------|
|
||||||
@@ -756,7 +777,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
|
|
||||||
| Файл | Описание |
|
| Файл | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
|
<<<<<<< HEAD
|
||||||
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||||
|
=======
|
||||||
|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||||
|
>>>>>>> 43d72c52356686ada2a54d73eccfdf491579fa0c
|
||||||
|
|
||||||
### Накатывание на существующих тенантов
|
### Накатывание на существующих тенантов
|
||||||
|
|
||||||
|
|||||||
@@ -139,22 +139,26 @@ frontend/
|
|||||||
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
|
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
|
||||||
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
|
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
|
||||||
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
|
| `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` |
|
| `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` |
|
| `database` | Тенанты | `/api/database` |
|
||||||
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
|
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
|
||||||
|
|
||||||
### Особенности админских вкладок
|
### Особенности админских вкладок
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
- Вкладка `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`.
|
||||||
- Вкладка `users` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`.
|
- Вкладка `users` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`.
|
||||||
|
=======
|
||||||
|
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||||
|
>>>>>>> 43d72c52356686ada2a54d73eccfdf491579fa0c
|
||||||
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||||
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
|
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
|
||||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
- Вкладка `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` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||||
|
|||||||
@@ -1216,6 +1216,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 {
|
.calendar-fill-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
|
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
|
||||||
@@ -1750,6 +1894,10 @@
|
|||||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-subject-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-semesters-grid {
|
.calendar-semesters-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-auto-rows: auto;
|
grid-auto-rows: auto;
|
||||||
@@ -1804,6 +1952,16 @@
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.calendar-day-dialog-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export async function initAcademicCalendar() {
|
|||||||
const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope');
|
const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope');
|
||||||
const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save');
|
const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save');
|
||||||
const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear');
|
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 academicYears = [];
|
||||||
let semesters = [];
|
let semesters = [];
|
||||||
@@ -78,6 +88,8 @@ export async function initAcademicCalendar() {
|
|||||||
let studyForms = [];
|
let studyForms = [];
|
||||||
let activityTypes = [];
|
let activityTypes = [];
|
||||||
let calendars = [];
|
let calendars = [];
|
||||||
|
let subjects = [];
|
||||||
|
let calendarSubjectRows = [];
|
||||||
let timeSlotScopes = [];
|
let timeSlotScopes = [];
|
||||||
let timeSlotDateAssignments = [];
|
let timeSlotDateAssignments = [];
|
||||||
let activeCalendarDay = null;
|
let activeCalendarDay = null;
|
||||||
@@ -95,6 +107,7 @@ export async function initAcademicCalendar() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadBaseLists(),
|
loadBaseLists(),
|
||||||
loadYears(),
|
loadYears(),
|
||||||
|
loadSubjects(),
|
||||||
loadStudyForms(),
|
loadStudyForms(),
|
||||||
loadActivityTypes(),
|
loadActivityTypes(),
|
||||||
loadTimeSlotScopes(),
|
loadTimeSlotScopes(),
|
||||||
@@ -106,6 +119,9 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
|
calendarTabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
|
||||||
|
});
|
||||||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||||||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||||||
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
||||||
@@ -115,6 +131,11 @@ export async function initAcademicCalendar() {
|
|||||||
editorLoadButton?.addEventListener('click', loadCalendarGrid);
|
editorLoadButton?.addEventListener('click', loadCalendarGrid);
|
||||||
editorSaveButton?.addEventListener('click', saveCalendarGrid);
|
editorSaveButton?.addEventListener('click', saveCalendarGrid);
|
||||||
fillApplyButton?.addEventListener('click', applyCalendarFill);
|
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) => {
|
calendarGrid?.addEventListener('change', (event) => {
|
||||||
if (event.target.matches('.calendar-day-activity')) {
|
if (event.target.matches('.calendar-day-activity')) {
|
||||||
@@ -361,6 +382,11 @@ export async function initAcademicCalendar() {
|
|||||||
populateStudyFormSelect();
|
populateStudyFormSelect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSubjects() {
|
||||||
|
subjects = await api.get('/api/subjects');
|
||||||
|
populateSubjectSelect();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadActivityTypes() {
|
async function loadActivityTypes() {
|
||||||
activityTypes = await api.get('/api/admin/calendar/activity-types');
|
activityTypes = await api.get('/api/admin/calendar/activity-types');
|
||||||
populateActivitySelects();
|
populateActivitySelects();
|
||||||
@@ -380,6 +406,7 @@ export async function initAcademicCalendar() {
|
|||||||
calendars = await api.get('/api/admin/academic-calendars');
|
calendars = await api.get('/api/admin/academic-calendars');
|
||||||
renderCalendars();
|
renderCalendars();
|
||||||
populateEditorCalendarSelect();
|
populateEditorCalendarSelect();
|
||||||
|
populateSubjectCalendarSelect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -402,6 +429,7 @@ export async function initAcademicCalendar() {
|
|||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
<button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||||||
<button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
<button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||||||
|
<button class="btn btn-sm btn-secondary btn-open-calendar-subjects" data-id="${calendar.id}">Дисциплины</button>
|
||||||
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -440,9 +468,11 @@ export async function initAcademicCalendar() {
|
|||||||
async function handleCalendarTableClick(event) {
|
async function handleCalendarTableClick(event) {
|
||||||
const editButton = event.target.closest('.btn-edit-calendar');
|
const editButton = event.target.closest('.btn-edit-calendar');
|
||||||
const gridButton = event.target.closest('.btn-open-calendar-grid');
|
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');
|
const deleteButton = event.target.closest('.btn-delete-calendar');
|
||||||
|
|
||||||
if (editButton) {
|
if (editButton) {
|
||||||
|
activateCalendarTab('manage');
|
||||||
const calendar = calendars.find(item => item.id == editButton.dataset.id);
|
const calendar = calendars.find(item => item.id == editButton.dataset.id);
|
||||||
if (!calendar) return;
|
if (!calendar) return;
|
||||||
calendarFormTitle.textContent = 'Редактирование календарного графика';
|
calendarFormTitle.textContent = 'Редактирование календарного графика';
|
||||||
@@ -460,13 +490,23 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gridButton) {
|
if (gridButton) {
|
||||||
|
activateCalendarTab('grid');
|
||||||
editorCalendarSelect.value = gridButton.dataset.id;
|
editorCalendarSelect.value = gridButton.dataset.id;
|
||||||
syncSelects(editorCalendarSelect);
|
refreshSelectVisuals(editorCalendarSelect);
|
||||||
await loadCalendarGrid();
|
await loadCalendarGrid();
|
||||||
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subjectsButton) {
|
||||||
|
activateCalendarTab('subjects');
|
||||||
|
subjectCalendarSelect.value = subjectsButton.dataset.id;
|
||||||
|
refreshSelectVisuals(subjectCalendarSelect);
|
||||||
|
await loadCalendarSubjects();
|
||||||
|
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (deleteButton) {
|
if (deleteButton) {
|
||||||
if (!confirm('Удалить календарный график?')) return;
|
if (!confirm('Удалить календарный график?')) return;
|
||||||
try {
|
try {
|
||||||
@@ -489,6 +529,149 @@ export async function initAcademicCalendar() {
|
|||||||
hideAlert('academic-calendar-alert');
|
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 = '<div class="loading-row">Загрузка...</div>';
|
||||||
|
try {
|
||||||
|
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
||||||
|
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
||||||
|
renderCalendarSubjects();
|
||||||
|
} catch (error) {
|
||||||
|
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
||||||
|
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 = '<div class="loading-row">Выберите календарный график</div>';
|
||||||
|
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 `
|
||||||
|
<section class="calendar-subject-semester">
|
||||||
|
<div class="calendar-subject-semester-head">
|
||||||
|
<strong>${escapeHtml(semesterOptionLabel(semesterNumber))}</strong>
|
||||||
|
<span>${semesterRows.length} дисциплин</span>
|
||||||
|
</div>
|
||||||
|
${semesterRows.length
|
||||||
|
? `<div class="calendar-subject-chips">${semesterRows.map(renderSubjectChip).join('')}</div>`
|
||||||
|
: '<div class="calendar-subject-empty">Нет дисциплин</div>'}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubjectChip(row) {
|
||||||
|
return `
|
||||||
|
<span class="calendar-subject-chip">
|
||||||
|
<span>${escapeHtml(subjectRowLabel(row))}</span>
|
||||||
|
<button type="button"
|
||||||
|
class="calendar-subject-remove"
|
||||||
|
data-semester="${escapeHtml(String(row.semesterNumber))}"
|
||||||
|
data-subject="${escapeHtml(String(row.subjectId))}"
|
||||||
|
aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCalendarGrid() {
|
async function loadCalendarGrid() {
|
||||||
hideAlert('calendar-editor-alert');
|
hideAlert('calendar-editor-alert');
|
||||||
const calendar = selectedEditorCalendar();
|
const calendar = selectedEditorCalendar();
|
||||||
@@ -568,8 +751,8 @@ export async function initAcademicCalendar() {
|
|||||||
selectedCalendarDayKey = null;
|
selectedCalendarDayKey = null;
|
||||||
selectedCalendarDays = [];
|
selectedCalendarDays = [];
|
||||||
const grouped = groupBy(rows, row => row.courseNumber);
|
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]) => `
|
||||||
<details class="calendar-course-block" ${index === 0 ? 'open' : ''}>
|
<details class="calendar-course-block">
|
||||||
<summary class="calendar-course-header">
|
<summary class="calendar-course-header">
|
||||||
<span class="calendar-course-title">
|
<span class="calendar-course-title">
|
||||||
<span class="calendar-course-chevron" aria-hidden="true"></span>
|
<span class="calendar-course-chevron" aria-hidden="true"></span>
|
||||||
@@ -1104,6 +1287,21 @@ export async function initAcademicCalendar() {
|
|||||||
syncSelects(calendarStudyFormSelect);
|
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
|
||||||
|
? '<option value="">Выберите дисциплину</option>' + sortedSubjects.map(subject =>
|
||||||
|
`<option value="${subject.id}">${escapeHtml(subjectLabel(subject))}</option>`
|
||||||
|
).join('')
|
||||||
|
: '<option value="">Нет дисциплин</option>';
|
||||||
|
if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) {
|
||||||
|
subjectSelect.value = current;
|
||||||
|
}
|
||||||
|
syncSelects(subjectSelect);
|
||||||
|
}
|
||||||
|
|
||||||
function populateActivitySelects() {
|
function populateActivitySelects() {
|
||||||
const activityOptions = '<option value="">Выберите код</option>' +
|
const activityOptions = '<option value="">Выберите код</option>' +
|
||||||
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
|
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
|
||||||
@@ -1121,6 +1319,41 @@ export async function initAcademicCalendar() {
|
|||||||
syncSelects(editorCalendarSelect);
|
syncSelects(editorCalendarSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function populateSubjectCalendarSelect() {
|
||||||
|
if (!subjectCalendarSelect) return;
|
||||||
|
const current = subjectCalendarSelect.value;
|
||||||
|
subjectCalendarSelect.innerHTML = calendars.length
|
||||||
|
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
||||||
|
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
||||||
|
).join('')
|
||||||
|
: '<option value="">Нет графиков</option>';
|
||||||
|
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 = '<option value="">Выберите график</option>';
|
||||||
|
syncSelects(subjectSemesterSelect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const semesterCount = calendarSemesterCount(calendar);
|
||||||
|
subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
|
||||||
|
const semesterNumber = index + 1;
|
||||||
|
return `<option value="${semesterNumber}">${escapeHtml(semesterOptionLabel(semesterNumber))}</option>`;
|
||||||
|
}).join('');
|
||||||
|
if (current && Number(current) >= 1 && Number(current) <= semesterCount) {
|
||||||
|
subjectSemesterSelect.value = current;
|
||||||
|
}
|
||||||
|
syncSelects(subjectSemesterSelect);
|
||||||
|
}
|
||||||
|
|
||||||
function populateFillCourseSelect() {
|
function populateFillCourseSelect() {
|
||||||
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
|
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
|
||||||
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
|
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
|
||||||
@@ -1145,6 +1378,38 @@ export async function initAcademicCalendar() {
|
|||||||
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
|
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() {
|
function theoryActivityId() {
|
||||||
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
|
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
|
||||||
}
|
}
|
||||||
@@ -1265,7 +1530,16 @@ export async function initAcademicCalendar() {
|
|||||||
|
|
||||||
function syncSelects(...selects) {
|
function syncSelects(...selects) {
|
||||||
selects.filter(Boolean).forEach(select => {
|
selects.filter(Boolean).forEach(select => {
|
||||||
|
refreshSelectVisuals(select);
|
||||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshSelectVisuals(...selects) {
|
||||||
|
selects.filter(Boolean).forEach(select => {
|
||||||
|
if (select.customSelectInstance) {
|
||||||
|
select.customSelectInstance.rebuildMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,24 +522,25 @@ export async function initGroups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAssignmentsList(groupId) {
|
async function loadAssignmentsList(groupId) {
|
||||||
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||||
try {
|
try {
|
||||||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||||||
renderAssignmentsList();
|
renderAssignmentsList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
manageCalendarTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAssignmentsList() {
|
function renderAssignmentsList() {
|
||||||
if (!currentAssignments.length) {
|
if (!currentAssignments.length) {
|
||||||
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Календарные графики не назначены</td></tr>';
|
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||||
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
||||||
|
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -547,6 +548,26 @@ export async function initGroups() {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAssignmentSubjects(subjects) {
|
||||||
|
if (!subjects.length) {
|
||||||
|
return '<span class="muted">Не привязаны</span>';
|
||||||
|
}
|
||||||
|
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 `<div class="assignment-subjects">${Array.from(grouped.entries())
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([semesterNumber, items]) => `
|
||||||
|
<div class="assignment-subject-semester">
|
||||||
|
<strong>${escapeHtml(semesterNumber)} семестр</strong>
|
||||||
|
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAssignment(event) {
|
async function saveAssignment(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
hideAlert('manage-calendar-alert');
|
hideAlert('manage-calendar-alert');
|
||||||
@@ -602,6 +623,11 @@ export async function initGroups() {
|
|||||||
return name ? `${code} — ${name}` : code;
|
return name ? `${code} — ${name}` : code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subjectLabel(subject) {
|
||||||
|
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
|
||||||
|
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function statusLabel(group) {
|
function statusLabel(group) {
|
||||||
if (group.status === 'ARCHIVED') return 'Архив';
|
if (group.status === 'ARCHIVED') return 'Архив';
|
||||||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<div class="academic-calendar-tabs" role="tablist" aria-label="Разделы календарных учебных графиков">
|
||||||
|
<button type="button" class="academic-calendar-tab active" data-calendar-tab="manage" role="tab" aria-selected="true" aria-controls="academic-calendar-tab-manage">Графики</button>
|
||||||
|
<button type="button" class="academic-calendar-tab" data-calendar-tab="grid" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-grid">Сетки</button>
|
||||||
|
<button type="button" class="academic-calendar-tab" data-calendar-tab="subjects" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-subjects">Дисциплины</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
|
||||||
<div class="card create-card">
|
<div class="card create-card">
|
||||||
<h2 id="academic-year-form-title">Учебный год</h2>
|
<h2 id="academic-year-form-title">Учебный год</h2>
|
||||||
<form id="academic-year-form" novalidate>
|
<form id="academic-year-form" novalidate>
|
||||||
@@ -152,6 +159,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>
|
||||||
<div class="card create-card">
|
<div class="card create-card">
|
||||||
<h2>Редактор годового графика</h2>
|
<h2>Редактор годового графика</h2>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -191,6 +201,49 @@
|
|||||||
<div id="calendar-totals" class="calendar-totals"></div>
|
<div id="calendar-totals" class="calendar-totals"></div>
|
||||||
<div id="calendar-grid" class="academic-calendar-grid"></div>
|
<div id="calendar-grid" class="academic-calendar-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-subjects" data-calendar-tab-panel="subjects" role="tabpanel" hidden>
|
||||||
|
<div class="card create-card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Дисциплины графика</h2>
|
||||||
|
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-refresh">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-subject-calendar">График</label>
|
||||||
|
<select id="calendar-subject-calendar">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-subject-semester">Семестр</label>
|
||||||
|
<select id="calendar-subject-semester">
|
||||||
|
<option value="">Выберите график</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-subject-id">Дисциплина</label>
|
||||||
|
<select id="calendar-subject-id">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-add">Добавить</button>
|
||||||
|
<button type="button" class="btn btn-md btn-primary" id="calendar-subject-save">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="calendar-subject-alert" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Привязки по семестрам</h2>
|
||||||
|
<span id="calendar-subject-count" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<div id="calendar-subject-summary" class="calendar-subject-summary">
|
||||||
|
<div class="loading-row">Выберите календарный график</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="modal-overlay" id="calendar-day-dialog">
|
<div class="modal-overlay" id="calendar-day-dialog">
|
||||||
<div class="modal-content card calendar-day-dialog-card">
|
<div class="modal-content card calendar-day-dialog-card">
|
||||||
|
|||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
<!-- Модальное окно назначения Календарного графика -->
|
<!-- Модальное окно назначения Календарного графика -->
|
||||||
<div class="modal-overlay" id="modal-manage-calendar">
|
<div class="modal-overlay" id="modal-manage-calendar">
|
||||||
<div class="modal-content card" style="max-width: 800px; width: 90%;">
|
<div class="modal-content card" style="max-width: 960px; width: 90%;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
<div>
|
<div>
|
||||||
<h2>Календарные графики группы</h2>
|
<h2>Календарные графики группы</h2>
|
||||||
@@ -232,12 +232,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Учебный год</th>
|
<th>Учебный год</th>
|
||||||
<th>График</th>
|
<th>График</th>
|
||||||
|
<th>Дисциплины</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="manage-calendar-tbody">
|
<tbody id="manage-calendar-tbody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user