diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherChangeRequestController.java b/backend/src/main/java/com/magistr/app/controller/TeacherChangeRequestController.java new file mode 100644 index 0000000..2bd84d9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TeacherChangeRequestController.java @@ -0,0 +1,64 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.TeacherChangeCandidatesDto; +import com.magistr.app.dto.TeacherChangeRequestDto; +import com.magistr.app.dto.TeacherChangeRequestPayload; +import com.magistr.app.dto.TeacherChangeReviewRequest; +import com.magistr.app.model.Role; +import com.magistr.app.service.TeacherChangeRequestService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/teacher-change-requests") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER}) +public class TeacherChangeRequestController { + + private final TeacherChangeRequestService requestService; + + public TeacherChangeRequestController(TeacherChangeRequestService requestService) { + this.requestService = requestService; + } + + @GetMapping + public List getAll(@RequestParam(required = false) String status) { + return requestService.getAll(status); + } + + @GetMapping("/candidates") + public TeacherChangeCandidatesDto getCandidates( + @RequestParam Long baseRuleSlotId, + @RequestParam LocalDate lessonDate, + @RequestParam(required = false) LocalDate targetDate + ) { + return requestService.getCandidates(baseRuleSlotId, lessonDate, targetDate); + } + + @PostMapping + public TeacherChangeRequestDto create(@RequestBody TeacherChangeRequestPayload request) { + return requestService.create(request); + } + + @PostMapping("/{id}/review") + public TeacherChangeRequestDto review( + @PathVariable Long id, + @RequestBody TeacherChangeReviewRequest request + ) { + return requestService.review(id, request); + } + + @DeleteMapping("/{id}") + public TeacherChangeRequestDto cancel(@PathVariable Long id) { + return requestService.cancel(id); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherPreferenceController.java b/backend/src/main/java/com/magistr/app/controller/TeacherPreferenceController.java new file mode 100644 index 0000000..d6ff121 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TeacherPreferenceController.java @@ -0,0 +1,62 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.TeacherPreferenceDto; +import com.magistr.app.dto.TeacherPreferenceMetaDto; +import com.magistr.app.dto.TeacherPreferenceRequest; +import com.magistr.app.dto.TeacherPreferenceReviewRequest; +import com.magistr.app.model.Role; +import com.magistr.app.service.TeacherPreferenceService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/teacher-preferences") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER}) +public class TeacherPreferenceController { + + private final TeacherPreferenceService preferenceService; + + public TeacherPreferenceController(TeacherPreferenceService preferenceService) { + this.preferenceService = preferenceService; + } + + @GetMapping("/meta") + public TeacherPreferenceMetaDto getMeta() { + return preferenceService.getMeta(); + } + + @GetMapping + public List getAll( + @RequestParam(required = false) Long semesterId, + @RequestParam(required = false) String status + ) { + return preferenceService.getAll(semesterId, status); + } + + @PostMapping + public TeacherPreferenceDto create(@RequestBody TeacherPreferenceRequest request) { + return preferenceService.create(request); + } + + @PostMapping("/{id}/review") + public TeacherPreferenceDto review( + @PathVariable Long id, + @RequestBody TeacherPreferenceReviewRequest request + ) { + return preferenceService.review(id, request); + } + + @DeleteMapping("/{id}") + public TeacherPreferenceDto cancel(@PathVariable Long id) { + return preferenceService.cancel(id); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherChangeCandidatesDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherChangeCandidatesDto.java new file mode 100644 index 0000000..8b3170e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherChangeCandidatesDto.java @@ -0,0 +1,23 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; +import java.util.List; + +public record TeacherChangeCandidatesDto( + RenderedLessonDto lesson, + Long semesterId, + LocalDate semesterStartDate, + LocalDate semesterEndDate, + List availableDates, + LocalDate checkedDate, + List timeSlots, + List classrooms +) { + public record CandidateOption( + Long id, + String label, + boolean available, + String conflictMessage + ) { + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherChangeRequestDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherChangeRequestDto.java new file mode 100644 index 0000000..bd846b9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherChangeRequestDto.java @@ -0,0 +1,50 @@ +package com.magistr.app.dto; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public record TeacherChangeRequestDto( + Long id, + Long teacherId, + String teacherName, + Long departmentId, + Long baseRuleSlotId, + LocalDate lessonDate, + String subjectName, + String lessonTypeName, + List groupNames, + Long sourceTimeSlotId, + Integer sourceTimeSlotOrder, + LocalTime sourceStartTime, + LocalTime sourceEndTime, + Long sourceClassroomId, + String sourceClassroomName, + String requestType, + LocalDate targetLessonDate, + Long requestedTimeSlotId, + Integer requestedTimeSlotOrder, + LocalTime requestedStartTime, + LocalTime requestedEndTime, + Long requestedClassroomId, + String requestedClassroomName, + String reason, + String status, + Long reviewedBy, + String reviewComment, + Instant reviewedAt, + Long appliedOverrideId, + Instant createdAt, + Instant updatedAt, + List history +) { + public record HistoryItem( + Long id, + String status, + String comment, + Long actorId, + Instant createdAt + ) { + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherChangeRequestPayload.java b/backend/src/main/java/com/magistr/app/dto/TeacherChangeRequestPayload.java new file mode 100644 index 0000000..6f15752 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherChangeRequestPayload.java @@ -0,0 +1,14 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record TeacherChangeRequestPayload( + Long baseRuleSlotId, + LocalDate lessonDate, + String requestType, + LocalDate targetLessonDate, + Long requestedTimeSlotId, + Long requestedClassroomId, + String reason +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherChangeReviewRequest.java b/backend/src/main/java/com/magistr/app/dto/TeacherChangeReviewRequest.java new file mode 100644 index 0000000..e8f67e1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherChangeReviewRequest.java @@ -0,0 +1,4 @@ +package com.magistr.app.dto; + +public record TeacherChangeReviewRequest(Boolean approved, String comment) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceDto.java new file mode 100644 index 0000000..3a9dd64 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceDto.java @@ -0,0 +1,34 @@ +package com.magistr.app.dto; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; + +public record TeacherPreferenceDto( + Long id, + Long teacherId, + String teacherName, + Long departmentId, + Long semesterId, + String academicYearTitle, + String semesterType, + LocalDate semesterStartDate, + LocalDate semesterEndDate, + String preferenceType, + Integer dayOfWeek, + String dayName, + Long timeSlotId, + Integer timeSlotOrder, + LocalTime startTime, + LocalTime endTime, + LocalDate preferenceDate, + String comment, + String status, + Long requestedBy, + Long reviewedBy, + String reviewComment, + Instant reviewedAt, + Instant createdAt, + Instant updatedAt +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceMetaDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceMetaDto.java new file mode 100644 index 0000000..1d27ecd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceMetaDto.java @@ -0,0 +1,9 @@ +package com.magistr.app.dto; + +import java.util.List; + +public record TeacherPreferenceMetaDto( + List semesters, + List timeSlots +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceRequest.java b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceRequest.java new file mode 100644 index 0000000..eab4343 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceRequest.java @@ -0,0 +1,14 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record TeacherPreferenceRequest( + Long teacherId, + Long semesterId, + String preferenceType, + Integer dayOfWeek, + Long timeSlotId, + LocalDate preferenceDate, + String comment +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceReviewRequest.java b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceReviewRequest.java new file mode 100644 index 0000000..89f3cf3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherPreferenceReviewRequest.java @@ -0,0 +1,4 @@ +package com.magistr.app.dto; + +public record TeacherPreferenceReviewRequest(Boolean approved, String comment) { +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherChangeRequest.java b/backend/src/main/java/com/magistr/app/model/TeacherChangeRequest.java new file mode 100644 index 0000000..4c3076d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherChangeRequest.java @@ -0,0 +1,104 @@ +package com.magistr.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "teacher_change_requests") +public class TeacherChangeRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @ManyToOne(optional = false) + @JoinColumn(name = "base_rule_slot_id", nullable = false) + private ScheduleRuleSlot baseRuleSlot; + + @Column(name = "lesson_date", nullable = false) + private LocalDate lessonDate; + + @Column(name = "request_type", nullable = false, length = 30) + private String requestType; + + @Column(name = "target_lesson_date") + private LocalDate targetLessonDate; + + @ManyToOne + @JoinColumn(name = "requested_time_slot_id") + private TimeSlot requestedTimeSlot; + + @ManyToOne + @JoinColumn(name = "requested_classroom_id") + private Classroom requestedClassroom; + + @Column(nullable = false, columnDefinition = "TEXT") + private String reason; + + @Column(nullable = false, length = 20) + private String status; + + @Column(name = "reviewed_by") + private Long reviewedBy; + + @Column(name = "review_comment", columnDefinition = "TEXT") + private String reviewComment; + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + @ManyToOne + @JoinColumn(name = "applied_override_id") + private ScheduleOverride appliedOverride; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + public Long getId() { return id; } + public User getTeacher() { return teacher; } + public void setTeacher(User teacher) { this.teacher = teacher; } + public ScheduleRuleSlot getBaseRuleSlot() { return baseRuleSlot; } + public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) { this.baseRuleSlot = baseRuleSlot; } + public LocalDate getLessonDate() { return lessonDate; } + public void setLessonDate(LocalDate lessonDate) { this.lessonDate = lessonDate; } + public String getRequestType() { return requestType; } + public void setRequestType(String requestType) { this.requestType = requestType; } + public LocalDate getTargetLessonDate() { return targetLessonDate; } + public void setTargetLessonDate(LocalDate targetLessonDate) { this.targetLessonDate = targetLessonDate; } + public TimeSlot getRequestedTimeSlot() { return requestedTimeSlot; } + public void setRequestedTimeSlot(TimeSlot requestedTimeSlot) { this.requestedTimeSlot = requestedTimeSlot; } + public Classroom getRequestedClassroom() { return requestedClassroom; } + public void setRequestedClassroom(Classroom requestedClassroom) { this.requestedClassroom = requestedClassroom; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Long getReviewedBy() { return reviewedBy; } + public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public String getReviewComment() { return reviewComment; } + public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } + public Instant getReviewedAt() { return reviewedAt; } + public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; } + public ScheduleOverride getAppliedOverride() { return appliedOverride; } + public void setAppliedOverride(ScheduleOverride appliedOverride) { this.appliedOverride = appliedOverride; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherChangeRequestHistory.java b/backend/src/main/java/com/magistr/app/model/TeacherChangeRequestHistory.java new file mode 100644 index 0000000..6e351ba --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherChangeRequestHistory.java @@ -0,0 +1,49 @@ +package com.magistr.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "teacher_change_request_history") +public class TeacherChangeRequestHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "request_id", nullable = false) + private TeacherChangeRequest request; + + @Column(nullable = false, length = 20) + private String status; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(name = "actor_id") + private Long actorId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + public Long getId() { return id; } + public TeacherChangeRequest getRequest() { return request; } + public void setRequest(TeacherChangeRequest request) { this.request = request; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getComment() { return comment; } + public void setComment(String comment) { this.comment = comment; } + public Long getActorId() { return actorId; } + public void setActorId(Long actorId) { this.actorId = actorId; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherPreference.java b/backend/src/main/java/com/magistr/app/model/TeacherPreference.java new file mode 100644 index 0000000..7061ef1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherPreference.java @@ -0,0 +1,97 @@ +package com.magistr.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "teacher_preferences") +public class TeacherPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @ManyToOne(optional = false) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @Column(name = "preference_type", nullable = false, length = 30) + private String preferenceType; + + @Column(name = "day_of_week") + private Integer dayOfWeek; + + @ManyToOne + @JoinColumn(name = "time_slot_id") + private TimeSlot timeSlot; + + @Column(name = "preference_date") + private LocalDate preferenceDate; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(nullable = false, length = 20) + private String status; + + @Column(name = "requested_by") + private Long requestedBy; + + @Column(name = "reviewed_by") + private Long reviewedBy; + + @Column(name = "review_comment", columnDefinition = "TEXT") + private String reviewComment; + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + public Long getId() { return id; } + public User getTeacher() { return teacher; } + public void setTeacher(User teacher) { this.teacher = teacher; } + public Semester getSemester() { return semester; } + public void setSemester(Semester semester) { this.semester = semester; } + public String getPreferenceType() { return preferenceType; } + public void setPreferenceType(String preferenceType) { this.preferenceType = preferenceType; } + public Integer getDayOfWeek() { return dayOfWeek; } + public void setDayOfWeek(Integer dayOfWeek) { this.dayOfWeek = dayOfWeek; } + public TimeSlot getTimeSlot() { return timeSlot; } + public void setTimeSlot(TimeSlot timeSlot) { this.timeSlot = timeSlot; } + public LocalDate getPreferenceDate() { return preferenceDate; } + public void setPreferenceDate(LocalDate preferenceDate) { this.preferenceDate = preferenceDate; } + public String getComment() { return comment; } + public void setComment(String comment) { this.comment = comment; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Long getRequestedBy() { return requestedBy; } + public void setRequestedBy(Long requestedBy) { this.requestedBy = requestedBy; } + public Long getReviewedBy() { return reviewedBy; } + public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public String getReviewComment() { return reviewComment; } + public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } + public Instant getReviewedAt() { return reviewedAt; } + public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherChangeRequestHistoryRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherChangeRequestHistoryRepository.java new file mode 100644 index 0000000..74d420e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherChangeRequestHistoryRepository.java @@ -0,0 +1,10 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherChangeRequestHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TeacherChangeRequestHistoryRepository extends JpaRepository { + List findByRequestIdOrderByCreatedAtAscIdAsc(Long requestId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherChangeRequestRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherChangeRequestRepository.java new file mode 100644 index 0000000..410db69 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherChangeRequestRepository.java @@ -0,0 +1,54 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherChangeRequest; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface TeacherChangeRequestRepository extends JpaRepository { + + @Query(""" + select request + from TeacherChangeRequest request + join fetch request.teacher + join fetch request.baseRuleSlot slot + join fetch slot.scheduleRule rule + join fetch rule.subject + join fetch slot.timeSlot + join fetch slot.classroom + left join fetch request.requestedTimeSlot + left join fetch request.requestedClassroom + left join fetch request.appliedOverride + order by request.createdAt desc, request.id desc + """) + List findAllWithDetails(); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select request + from TeacherChangeRequest request + join fetch request.teacher + join fetch request.baseRuleSlot slot + join fetch slot.scheduleRule rule + join fetch rule.subject + join fetch slot.timeSlot + join fetch slot.classroom + left join fetch request.requestedTimeSlot + left join fetch request.requestedClassroom + left join fetch request.appliedOverride + where request.id = :id + """) + Optional findByIdForUpdate(@Param("id") Long id); + + boolean existsByBaseRuleSlotIdAndLessonDateAndStatus( + Long baseRuleSlotId, + LocalDate lessonDate, + String status + ); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherPreferenceRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherPreferenceRepository.java new file mode 100644 index 0000000..8efd0d2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherPreferenceRepository.java @@ -0,0 +1,90 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherPreference; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface TeacherPreferenceRepository extends JpaRepository { + + @Query(""" + select preference + from TeacherPreference preference + join fetch preference.teacher + join fetch preference.semester semester + join fetch semester.academicYear + left join fetch preference.timeSlot slot + left join fetch slot.timeSlotScope + order by preference.createdAt desc, preference.id desc + """) + List findAllWithDetails(); + + @Query(""" + select preference + from TeacherPreference preference + join fetch preference.teacher + join fetch preference.semester semester + join fetch semester.academicYear + left join fetch preference.timeSlot slot + left join fetch slot.timeSlotScope + where preference.id = :id + """) + Optional findByIdWithDetails(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select preference + from TeacherPreference preference + join fetch preference.teacher + join fetch preference.semester semester + join fetch semester.academicYear + left join fetch preference.timeSlot slot + left join fetch slot.timeSlotScope + where preference.id = :id + """) + Optional findByIdForUpdate(@Param("id") Long id); + + @Query(""" + select preference + from TeacherPreference preference + join fetch preference.teacher + join fetch preference.semester semester + left join fetch preference.timeSlot slot + where preference.teacher.id = :teacherId + and preference.semester.id = :semesterId + and preference.status = 'APPROVED' + order by preference.preferenceType, preference.preferenceDate, preference.dayOfWeek, slot.orderNumber + """) + List findApprovedForTeacherAndSemester( + @Param("teacherId") Long teacherId, + @Param("semesterId") Long semesterId + ); + + @Query(""" + select count(preference) > 0 + from TeacherPreference preference + where preference.teacher.id = :teacherId + and preference.semester.id = :semesterId + and preference.preferenceType = :preferenceType + and preference.status in :statuses + and ((preference.dayOfWeek is null and :dayOfWeek is null) or preference.dayOfWeek = :dayOfWeek) + and ((preference.timeSlot is null and :timeSlotId is null) or preference.timeSlot.id = :timeSlotId) + and ((preference.preferenceDate is null and :preferenceDate is null) or preference.preferenceDate = :preferenceDate) + """) + boolean existsDuplicate( + @Param("teacherId") Long teacherId, + @Param("semesterId") Long semesterId, + @Param("preferenceType") String preferenceType, + @Param("dayOfWeek") Integer dayOfWeek, + @Param("timeSlotId") Long timeSlotId, + @Param("preferenceDate") LocalDate preferenceDate, + @Param("statuses") Collection statuses + ); +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java index e2aff88..0678a94 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java @@ -56,6 +56,7 @@ public class ScheduleOverrideService { private final PostgreSqlAdvisoryLock advisoryLock; private final AcademicDateService academicDateService; private final EffectiveTimeSlotService effectiveTimeSlotService; + private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService; @Autowired public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository, @@ -106,6 +107,11 @@ public class ScheduleOverrideService { ); } + @Autowired(required = false) + void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) { + this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService; + } + @Transactional(readOnly = true) public List getAll(LocalDate startDate, LocalDate endDate) { if ((startDate == null) != (endDate == null)) { @@ -331,6 +337,7 @@ public class ScheduleOverrideService { .map(EffectiveSourceTuple::from) .collect(Collectors.toSet()); validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences); + validateTeacherAvailability(candidate, candidateOccurrences); if (sourceTuples.equals(candidateTuples)) { throw new IllegalArgumentException( "Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары" @@ -345,6 +352,27 @@ public class ScheduleOverrideService { validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup); } + private void validateTeacherAvailability( + ScheduleOverride candidate, + List candidateOccurrences + ) { + if (teacherAvailabilityPolicyService == null + || candidate.getBaseRuleSlot() == null + || candidate.getBaseRuleSlot().getScheduleRule() == null + || candidate.getBaseRuleSlot().getScheduleRule().getSemester() == null) { + return; + } + Long semesterId = candidate.getBaseRuleSlot().getScheduleRule().getSemester().getId(); + for (RenderedLessonDto occurrence : candidateOccurrences) { + teacherAvailabilityPolicyService.assertLessonAvailable( + occurrence.teacherId(), + semesterId, + occurrence.date(), + occurrence.timeSlotId() + ); + } + } + private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) { return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()) && (Objects.equals(lesson.originalLessonDate(), command.lessonDate()) diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java index 2334fdb..965a09f 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java @@ -25,6 +25,7 @@ import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubgroupRepository; import com.magistr.app.repository.TimeSlotRepository; import com.magistr.app.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -70,6 +71,7 @@ public class ScheduleRuleService { private final LessonTypesRepository lessonTypesRepository; private final SubgroupRepository subgroupRepository; private final ScheduleGeneratorService scheduleGeneratorService; + private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService; public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository, SubjectRepository subjectRepository, @@ -93,6 +95,11 @@ public class ScheduleRuleService { this.scheduleGeneratorService = scheduleGeneratorService; } + @Autowired(required = false) + void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) { + this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService; + } + @Transactional(readOnly = true) public List getAll(Long semesterId, Long groupId) { return scheduleRuleRepository.findAllWithDetails().stream() @@ -424,6 +431,21 @@ public class ScheduleRuleService { List slots = new ArrayList<>(candidate.getSlots()); validateExactDuplicates(slots); validateInternalConflicts(slots); + validateTeacherAvailability(candidate, slots); + } + + private void validateTeacherAvailability(ScheduleRule candidate, List slots) { + if (teacherAvailabilityPolicyService == null) return; + Map> activeWeeks = activeWeeksBySlot(slots); + for (ScheduleRuleSlot slot : slots) { + teacherAvailabilityPolicyService.assertRuleSlotAvailable( + teacherId(slot), + candidate.getSemester(), + slot.getDayOfWeek(), + timeSlotId(slot), + activeWeeks.get(slot) + ); + } } private void validateExactDuplicates(List slots) { diff --git a/backend/src/main/java/com/magistr/app/service/TeacherAvailabilityPolicyService.java b/backend/src/main/java/com/magistr/app/service/TeacherAvailabilityPolicyService.java new file mode 100644 index 0000000..f2e1a35 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TeacherAvailabilityPolicyService.java @@ -0,0 +1,106 @@ +package com.magistr.app.service; + +import com.magistr.app.model.Semester; +import com.magistr.app.model.TeacherPreference; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.repository.TeacherPreferenceRepository; +import com.magistr.app.repository.TimeSlotRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Service +public class TeacherAvailabilityPolicyService { + + private final TeacherPreferenceRepository preferenceRepository; + private final TimeSlotRepository timeSlotRepository; + + public TeacherAvailabilityPolicyService( + TeacherPreferenceRepository preferenceRepository, + TimeSlotRepository timeSlotRepository + ) { + this.preferenceRepository = preferenceRepository; + this.timeSlotRepository = timeSlotRepository; + } + + @Transactional(readOnly = true) + public void assertLessonAvailable(Long teacherId, Long semesterId, LocalDate date, Long timeSlotId) { + if (teacherId == null || semesterId == null || date == null || timeSlotId == null) return; + TimeSlot candidateSlot = timeSlotRepository.findById(timeSlotId) + .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); + List constraints = preferenceRepository + .findApprovedForTeacherAndSemester(teacherId, semesterId); + TeacherPreference conflict = constraints.stream() + .filter(this::isHard) + .filter(preference -> conflicts(preference, date, candidateSlot)) + .findFirst() + .orElse(null); + if (conflict != null) { + throw new ScheduleConflictException(conflict.getPreferenceDate() == null + ? "Преподаватель отметил строгую недоступность в выбранный день и время" + : "Преподаватель отметил полную недоступность на выбранную дату"); + } + } + + @Transactional(readOnly = true) + public void assertRuleSlotAvailable( + Long teacherId, + Semester semester, + Integer dayOfWeek, + Long timeSlotId, + Set activeWeeks + ) { + if (teacherId == null || semester == null || dayOfWeek == null || timeSlotId == null) return; + TimeSlot candidateSlot = timeSlotRepository.findById(timeSlotId) + .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); + List constraints = preferenceRepository + .findApprovedForTeacherAndSemester(teacherId, semester.getId()); + for (TeacherPreference preference : constraints) { + if (!isHard(preference)) continue; + if (preference.getPreferenceDate() == null) { + if (Objects.equals(preference.getDayOfWeek(), dayOfWeek) + && sameOrder(preference.getTimeSlot(), candidateSlot)) { + throw unavailableRuleConflict(); + } + continue; + } + LocalDate date = preference.getPreferenceDate(); + if (date.getDayOfWeek().getValue() != dayOfWeek) continue; + int week = Math.toIntExact(ChronoUnit.DAYS.between(semester.getStartDate(), date) / 7 + 1); + if (activeWeeks == null || activeWeeks.contains(week)) { + throw unavailableRuleConflict(); + } + } + } + + private ScheduleRuleConflictException unavailableRuleConflict() { + return new ScheduleRuleConflictException( + "Невозможно сохранить правило: преподаватель строго недоступен в одном из выбранных интервалов", + null, + List.of("teacherAvailability"), + List.of("Строгая недоступность преподавателя") + ); + } + + private boolean conflicts(TeacherPreference preference, LocalDate date, TimeSlot candidateSlot) { + if (preference.getPreferenceDate() != null) { + return Objects.equals(preference.getPreferenceDate(), date); + } + return Objects.equals(preference.getDayOfWeek(), date.getDayOfWeek().getValue()) + && sameOrder(preference.getTimeSlot(), candidateSlot); + } + + private boolean sameOrder(TimeSlot first, TimeSlot second) { + return first != null && second != null && Objects.equals(first.getOrderNumber(), second.getOrderNumber()); + } + + private boolean isHard(TeacherPreference preference) { + return "APPROVED".equals(preference.getStatus()) + && "HARD_UNAVAILABLE".equals(preference.getPreferenceType()); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TeacherChangeRequestService.java b/backend/src/main/java/com/magistr/app/service/TeacherChangeRequestService.java new file mode 100644 index 0000000..a5235a6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TeacherChangeRequestService.java @@ -0,0 +1,493 @@ +package com.magistr.app.service; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.dto.ScheduleOverrideDto; +import com.magistr.app.dto.TeacherChangeCandidatesDto; +import com.magistr.app.dto.TeacherChangeRequestDto; +import com.magistr.app.dto.TeacherChangeRequestPayload; +import com.magistr.app.dto.TeacherChangeReviewRequest; +import com.magistr.app.model.Classroom; +import com.magistr.app.model.Role; +import com.magistr.app.model.ScheduleOverride; +import com.magistr.app.model.ScheduleRuleSlot; +import com.magistr.app.model.TeacherChangeRequest; +import com.magistr.app.model.TeacherChangeRequestHistory; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.repository.ClassroomRepository; +import com.magistr.app.repository.ScheduleOverrideRepository; +import com.magistr.app.repository.ScheduleRuleSlotRepository; +import com.magistr.app.repository.TeacherChangeRequestHistoryRepository; +import com.magistr.app.repository.TeacherChangeRequestRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; + +@Service +public class TeacherChangeRequestService { + + private static final Logger log = LoggerFactory.getLogger(TeacherChangeRequestService.class); + private static final Set TYPES = Set.of("MOVE", "CHANGE_CLASSROOM", "CANCEL"); + private static final Set STATUSES = Set.of("PENDING", "APPROVED", "REJECTED", "CANCELLED"); + + private final TeacherChangeRequestRepository requestRepository; + private final TeacherChangeRequestHistoryRepository historyRepository; + private final ScheduleRuleSlotRepository ruleSlotRepository; + private final ScheduleOverrideRepository overrideRepository; + private final ClassroomRepository classroomRepository; + private final TeacherDepartmentAssignmentRepository assignmentRepository; + private final ScheduleQueryService scheduleQueryService; + private final ScheduleOverrideService scheduleOverrideService; + private final EffectiveTimeSlotService effectiveTimeSlotService; + private final BusinessTimeService businessTime; + + public TeacherChangeRequestService( + TeacherChangeRequestRepository requestRepository, + TeacherChangeRequestHistoryRepository historyRepository, + ScheduleRuleSlotRepository ruleSlotRepository, + ScheduleOverrideRepository overrideRepository, + ClassroomRepository classroomRepository, + TeacherDepartmentAssignmentRepository assignmentRepository, + ScheduleQueryService scheduleQueryService, + ScheduleOverrideService scheduleOverrideService, + EffectiveTimeSlotService effectiveTimeSlotService, + BusinessTimeService businessTime + ) { + this.requestRepository = requestRepository; + this.historyRepository = historyRepository; + this.ruleSlotRepository = ruleSlotRepository; + this.overrideRepository = overrideRepository; + this.classroomRepository = classroomRepository; + this.assignmentRepository = assignmentRepository; + this.scheduleQueryService = scheduleQueryService; + this.scheduleOverrideService = scheduleOverrideService; + this.effectiveTimeSlotService = effectiveTimeSlotService; + this.businessTime = businessTime; + } + + @Transactional(readOnly = true) + public List getAll(String status) { + AuthenticatedUser current = currentUser(); + String normalizedStatus = cleanUpper(status); + if (normalizedStatus != null && !STATUSES.contains(normalizedStatus)) { + throw new IllegalArgumentException("Неизвестный статус заявки на изменение расписания"); + } + return requestRepository.findAllWithDetails().stream() + .filter(request -> normalizedStatus == null || normalizedStatus.equals(request.getStatus())) + .filter(request -> canView(request, current)) + .map(this::toDto) + .toList(); + } + + @Transactional(readOnly = true) + public TeacherChangeCandidatesDto getCandidates( + Long baseRuleSlotId, + LocalDate lessonDate, + LocalDate targetDate + ) { + AuthenticatedUser current = currentUser(); + RenderedLessonDto lesson = sourceLesson(baseRuleSlotId, lessonDate); + ensureCanUseLesson(lesson, current); + var availability = scheduleOverrideService.getAvailability(baseRuleSlotId, lessonDate); + LocalDate checkedDate = targetDate == null ? lessonDate : targetDate; + if (!availability.availableDates().contains(checkedDate)) { + throw new IllegalArgumentException("Целевая дата недоступна для этого правила расписания"); + } + + List timeSlots = effectiveTimeSlotService.findEffective(checkedDate) + .stream() + .sorted(Comparator.comparing(TimeSlot::getOrderNumber).thenComparing(TimeSlot::getStartTime)) + .map(slot -> candidateOption( + slot.getId(), + slot.getOrderNumber() + " пара · " + shortTime(slot), + buildOverride( + baseRuleSlotId, + lessonDate, + checkedDate, + "MOVE", + slot.getId(), + null, + "Предварительная проверка заявки преподавателя" + ) + )) + .toList(); + List classrooms = classroomRepository.findByStatusNot("ARCHIVED") + .stream() + .filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable())) + .sorted(Comparator.comparing(Classroom::getName)) + .map(classroom -> candidateOption( + classroom.getId(), + classroom.getName(), + buildOverride( + baseRuleSlotId, + lessonDate, + lessonDate, + "REPLACE", + null, + classroom.getId(), + "Предварительная проверка заявки преподавателя" + ) + )) + .toList(); + return new TeacherChangeCandidatesDto( + lesson, + availability.semesterId(), + availability.semesterStartDate(), + availability.semesterEndDate(), + availability.availableDates(), + checkedDate, + timeSlots, + classrooms + ); + } + + @Transactional + public TeacherChangeRequestDto create(TeacherChangeRequestPayload payload) { + AuthenticatedUser current = currentUser(); + if (current.role() != Role.TEACHER) { + throw forbidden("Заявку на изменение занятия отправляет преподаватель"); + } + if (payload == null || payload.baseRuleSlotId() == null || payload.lessonDate() == null) { + throw new IllegalArgumentException("Выберите занятие для заявки"); + } + String type = cleanUpper(payload.requestType()); + if (type == null || !TYPES.contains(type)) { + throw new IllegalArgumentException("Неизвестный тип изменения занятия"); + } + String reason = clean(payload.reason()); + if (reason == null) { + throw new IllegalArgumentException("Обоснование заявки обязательно"); + } + if (reason.length() > 2000) { + throw new IllegalArgumentException("Обоснование не может превышать 2000 символов"); + } + RenderedLessonDto lesson = sourceLesson(payload.baseRuleSlotId(), payload.lessonDate()); + ensureCanUseLesson(lesson, current); + if (requestRepository.existsByBaseRuleSlotIdAndLessonDateAndStatus( + payload.baseRuleSlotId(), payload.lessonDate(), "PENDING")) { + throw new ScheduleConflictException("По выбранному занятию уже есть заявка на рассмотрении"); + } + + ScheduleRuleSlot baseSlot = ruleSlotRepository.findById(payload.baseRuleSlotId()) + .orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден")); + ScheduleOverrideDto candidate = validatePayload(payload, type, lesson); + scheduleOverrideService.validateCandidate(candidate); + + var now = businessTime.now(); + TeacherChangeRequest request = new TeacherChangeRequest(); + request.setTeacher(baseSlot.getTeacher()); + request.setBaseRuleSlot(baseSlot); + request.setLessonDate(payload.lessonDate()); + request.setRequestType(type); + request.setTargetLessonDate("MOVE".equals(type) ? candidate.targetLessonDate() : null); + request.setRequestedTimeSlot(candidate.newTimeSlotId() == null + ? null + : effectiveTimeSlotService.findEffective( + candidate.targetLessonDate() == null ? payload.lessonDate() : candidate.targetLessonDate()) + .stream() + .filter(slot -> Objects.equals(slot.getId(), candidate.newTimeSlotId())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Выбранный временной слот недоступен на целевую дату"))); + request.setRequestedClassroom(candidate.newClassroomId() == null + ? null + : classroomRepository.findById(candidate.newClassroomId()) + .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"))); + request.setReason(reason); + request.setStatus("PENDING"); + request.setCreatedAt(now); + request.setUpdatedAt(now); + TeacherChangeRequest saved = requestRepository.saveAndFlush(request); + addHistory(saved, "PENDING", reason, current.id(), now); + log.info("Преподаватель {} отправил заявку {} по занятию {} на {}", + current.id(), saved.getId(), payload.baseRuleSlotId(), payload.lessonDate()); + return toDto(saved); + } + + @Transactional + public TeacherChangeRequestDto review(Long id, TeacherChangeReviewRequest review) { + AuthenticatedUser current = currentUser(); + if (current.role() != Role.ADMIN && current.role() != Role.EDUCATION_OFFICE) { + throw forbidden("Применять заявки на изменение расписания может только учебный отдел"); + } + if (review == null || review.approved() == null) { + throw new IllegalArgumentException("Укажите решение по заявке"); + } + TeacherChangeRequest request = requestRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Заявка на изменение расписания не найдена")); + if (!"PENDING".equals(request.getStatus())) { + throw new IllegalArgumentException("Рассмотреть можно только ожидающую заявку"); + } + String comment = clean(review.comment()); + if (!Boolean.TRUE.equals(review.approved()) && comment == null) { + throw new IllegalArgumentException("При отклонении укажите комментарий"); + } + var now = businessTime.now(); + if (Boolean.TRUE.equals(review.approved())) { + ScheduleOverrideDto applied = scheduleOverrideService.create(toOverride(request, comment)); + ScheduleOverride override = overrideRepository.findById(applied.id()) + .orElseThrow(() -> new IllegalStateException("Применённое изменение расписания не найдено")); + request.setAppliedOverride(override); + request.setStatus("APPROVED"); + } else { + request.setStatus("REJECTED"); + } + request.setReviewedBy(current.id()); + request.setReviewComment(comment); + request.setReviewedAt(now); + request.setUpdatedAt(now); + TeacherChangeRequest saved = requestRepository.saveAndFlush(request); + addHistory(saved, saved.getStatus(), comment, current.id(), now); + log.info("Заявка преподавателя {} переведена в статус {}", id, saved.getStatus()); + return toDto(saved); + } + + @Transactional + public TeacherChangeRequestDto cancel(Long id) { + AuthenticatedUser current = currentUser(); + TeacherChangeRequest request = requestRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Заявка на изменение расписания не найдена")); + if (current.role() != Role.TEACHER || !Objects.equals(current.id(), request.getTeacher().getId())) { + throw forbidden("Отменить заявку может только отправивший её преподаватель"); + } + if (!"PENDING".equals(request.getStatus())) { + throw new IllegalArgumentException("Отменить можно только ожидающую заявку"); + } + var now = businessTime.now(); + request.setStatus("CANCELLED"); + request.setUpdatedAt(now); + TeacherChangeRequest saved = requestRepository.saveAndFlush(request); + addHistory(saved, "CANCELLED", "Заявка отозвана преподавателем", current.id(), now); + return toDto(saved); + } + + private ScheduleOverrideDto validatePayload( + TeacherChangeRequestPayload payload, + String type, + RenderedLessonDto lesson + ) { + if ("MOVE".equals(type)) { + if (payload.requestedTimeSlotId() == null) { + throw new IllegalArgumentException("Для переноса выберите новую пару"); + } + if (payload.requestedClassroomId() != null) { + throw new IllegalArgumentException("Смену аудитории оформите отдельным типом заявки"); + } + LocalDate targetDate = payload.targetLessonDate() == null ? payload.lessonDate() : payload.targetLessonDate(); + return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), targetDate, "MOVE", + payload.requestedTimeSlotId(), null, payload.reason()); + } + if ("CHANGE_CLASSROOM".equals(type)) { + if (payload.requestedClassroomId() == null) { + throw new IllegalArgumentException("Выберите новую аудиторию"); + } + if (payload.targetLessonDate() != null || payload.requestedTimeSlotId() != null) { + throw new IllegalArgumentException("Для смены аудитории дата и время не изменяются"); + } + if (Objects.equals(payload.requestedClassroomId(), lesson.classroomId())) { + throw new IllegalArgumentException("Новая аудитория должна отличаться от текущей"); + } + return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), payload.lessonDate(), "REPLACE", + null, payload.requestedClassroomId(), payload.reason()); + } + if (payload.targetLessonDate() != null + || payload.requestedTimeSlotId() != null + || payload.requestedClassroomId() != null) { + throw new IllegalArgumentException("Для отмены занятия новые параметры не указываются"); + } + return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), payload.lessonDate(), "CANCEL", + null, null, payload.reason()); + } + + private TeacherChangeCandidatesDto.CandidateOption candidateOption( + Long id, + String label, + ScheduleOverrideDto candidate + ) { + try { + scheduleOverrideService.validateCandidate(candidate); + return new TeacherChangeCandidatesDto.CandidateOption(id, label, true, null); + } catch (RuntimeException exception) { + return new TeacherChangeCandidatesDto.CandidateOption(id, label, false, exception.getMessage()); + } + } + + private ScheduleOverrideDto buildOverride( + Long baseRuleSlotId, + LocalDate lessonDate, + LocalDate targetDate, + String action, + Long timeSlotId, + Long classroomId, + String comment + ) { + LocalDate storedTargetDate = Objects.equals(lessonDate, targetDate) ? null : targetDate; + return new ScheduleOverrideDto( + null, + baseRuleSlotId, + lessonDate, + storedTargetDate, + action, + timeSlotId, + classroomId, + null, + null, + comment, + null, + null + ); + } + + private ScheduleOverrideDto toOverride(TeacherChangeRequest request, String reviewComment) { + String comment = "Одобрена заявка преподавателя №" + request.getId() + ": " + request.getReason(); + if (reviewComment != null) comment += ". Решение: " + reviewComment; + String action = switch (request.getRequestType()) { + case "MOVE" -> "MOVE"; + case "CHANGE_CLASSROOM" -> "REPLACE"; + case "CANCEL" -> "CANCEL"; + default -> throw new IllegalStateException("Неизвестный тип заявки"); + }; + return buildOverride( + request.getBaseRuleSlot().getId(), + request.getLessonDate(), + request.getTargetLessonDate() == null ? request.getLessonDate() : request.getTargetLessonDate(), + action, + request.getRequestedTimeSlot() == null ? null : request.getRequestedTimeSlot().getId(), + request.getRequestedClassroom() == null ? null : request.getRequestedClassroom().getId(), + comment + ); + } + + private RenderedLessonDto sourceLesson(Long baseRuleSlotId, LocalDate lessonDate) { + if (baseRuleSlotId == null || lessonDate == null) { + throw new IllegalArgumentException("Базовый слот и дата занятия обязательны"); + } + if (overrideRepository.findByBaseRuleSlotIdAndLessonDate(baseRuleSlotId, lessonDate).isPresent()) { + throw new ScheduleConflictException("Для занятия уже существует точечное изменение расписания"); + } + return scheduleQueryService.buildBaseDayForAllGroups(lessonDate).stream() + .filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), baseRuleSlotId)) + .filter(lesson -> Objects.equals(lesson.date(), lessonDate)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Выбранное занятие не найдено в базовом расписании")); + } + + private void ensureCanUseLesson(RenderedLessonDto lesson, AuthenticatedUser current) { + if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return; + if (current.role() == Role.TEACHER && Objects.equals(current.id(), lesson.teacherId())) return; + if (current.role() == Role.DEPARTMENT + && current.departmentId() != null + && assignmentRepository.existsActiveAssignment( + lesson.teacherId(), current.departmentId(), lesson.date())) return; + throw forbidden("Выбранное занятие находится вне доступной области"); + } + + private boolean canView(TeacherChangeRequest request, AuthenticatedUser current) { + if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return true; + if (current.role() == Role.TEACHER) { + return Objects.equals(current.id(), request.getTeacher().getId()); + } + return current.role() == Role.DEPARTMENT + && current.departmentId() != null + && assignmentRepository.existsActiveAssignment( + request.getTeacher().getId(), current.departmentId(), request.getLessonDate()); + } + + private void addHistory( + TeacherChangeRequest request, + String status, + String comment, + Long actorId, + java.time.Instant now + ) { + TeacherChangeRequestHistory history = new TeacherChangeRequestHistory(); + history.setRequest(request); + history.setStatus(status); + history.setComment(comment); + history.setActorId(actorId); + history.setCreatedAt(now); + historyRepository.save(history); + } + + private TeacherChangeRequestDto toDto(TeacherChangeRequest request) { + ScheduleRuleSlot source = request.getBaseRuleSlot(); + TimeSlot sourceSlot = source.getTimeSlot(); + TimeSlot targetSlot = request.getRequestedTimeSlot(); + Classroom sourceClassroom = source.getClassroom(); + Classroom targetClassroom = request.getRequestedClassroom(); + List history = historyRepository + .findByRequestIdOrderByCreatedAtAscIdAsc(request.getId()).stream() + .map(item -> new TeacherChangeRequestDto.HistoryItem( + item.getId(), item.getStatus(), item.getComment(), item.getActorId(), item.getCreatedAt())) + .toList(); + return new TeacherChangeRequestDto( + request.getId(), + request.getTeacher().getId(), + ScheduleGeneratorService.displayUserName(request.getTeacher()), + request.getTeacher().getDepartmentId(), + source.getId(), + request.getLessonDate(), + source.getScheduleRule().getSubject().getName(), + source.getLessonType().getLessonType(), + source.getScheduleRule().getGroups().stream().map(group -> group.getName()).sorted().toList(), + sourceSlot.getId(), + sourceSlot.getOrderNumber(), + sourceSlot.getStartTime(), + sourceSlot.getEndTime(), + sourceClassroom.getId(), + sourceClassroom.getName(), + request.getRequestType(), + request.getTargetLessonDate(), + targetSlot == null ? null : targetSlot.getId(), + targetSlot == null ? null : targetSlot.getOrderNumber(), + targetSlot == null ? null : targetSlot.getStartTime(), + targetSlot == null ? null : targetSlot.getEndTime(), + targetClassroom == null ? null : targetClassroom.getId(), + targetClassroom == null ? null : targetClassroom.getName(), + request.getReason(), + request.getStatus(), + request.getReviewedBy(), + request.getReviewComment(), + request.getReviewedAt(), + request.getAppliedOverride() == null ? null : request.getAppliedOverride().getId(), + request.getCreatedAt(), + request.getUpdatedAt(), + history + ); + } + + private String shortTime(TimeSlot slot) { + return slot.getStartTime().toString() + "–" + slot.getEndTime(); + } + + private AuthenticatedUser currentUser() { + AuthenticatedUser current = AuthContext.getCurrentUser(); + if (current == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Требуется авторизация"); + return current; + } + + private ResponseStatusException forbidden(String message) { + return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + } + + private String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private String cleanUpper(String value) { + String cleaned = clean(value); + return cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TeacherPreferenceService.java b/backend/src/main/java/com/magistr/app/service/TeacherPreferenceService.java new file mode 100644 index 0000000..e6936df --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TeacherPreferenceService.java @@ -0,0 +1,368 @@ +package com.magistr.app.service; + +import com.magistr.app.config.auth.AuthContext; +import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.dto.SemesterDto; +import com.magistr.app.dto.TeacherPreferenceDto; +import com.magistr.app.dto.TeacherPreferenceMetaDto; +import com.magistr.app.dto.TeacherPreferenceRequest; +import com.magistr.app.dto.TeacherPreferenceReviewRequest; +import com.magistr.app.dto.TimeSlotDto; +import com.magistr.app.model.Role; +import com.magistr.app.model.Semester; +import com.magistr.app.model.TeacherPreference; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.model.User; +import com.magistr.app.repository.SemesterRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; +import com.magistr.app.repository.TeacherPreferenceRepository; +import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.TextStyle; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; + +@Service +public class TeacherPreferenceService { + + private static final Logger log = LoggerFactory.getLogger(TeacherPreferenceService.class); + private static final Set TYPES = Set.of( + "HARD_UNAVAILABLE", "SOFT_PREFERRED", "SOFT_UNWANTED", "CONSECUTIVE", "NO_GAPS" + ); + private static final Set STATUSES = Set.of("PENDING", "APPROVED", "REJECTED", "CANCELLED"); + private static final Set ACTIVE_STATUSES = Set.of("PENDING", "APPROVED"); + + private final TeacherPreferenceRepository preferenceRepository; + private final UserRepository userRepository; + private final SemesterRepository semesterRepository; + private final TimeSlotRepository timeSlotRepository; + private final TeacherDepartmentAssignmentRepository assignmentRepository; + private final BusinessTimeService businessTime; + + public TeacherPreferenceService( + TeacherPreferenceRepository preferenceRepository, + UserRepository userRepository, + SemesterRepository semesterRepository, + TimeSlotRepository timeSlotRepository, + TeacherDepartmentAssignmentRepository assignmentRepository, + BusinessTimeService businessTime + ) { + this.preferenceRepository = preferenceRepository; + this.userRepository = userRepository; + this.semesterRepository = semesterRepository; + this.timeSlotRepository = timeSlotRepository; + this.assignmentRepository = assignmentRepository; + this.businessTime = businessTime; + } + + @Transactional(readOnly = true) + public TeacherPreferenceMetaDto getMeta() { + List semesters = semesterRepository.findAll().stream() + .sorted(Comparator.comparing(Semester::getStartDate).reversed()) + .map(semester -> new SemesterDto( + semester.getId(), + semester.getAcademicYear().getId(), + semester.getAcademicYear().getTitle(), + semester.getSemesterType(), + semester.getStartDate(), + semester.getEndDate() + )) + .toList(); + List timeSlots = timeSlotRepository.findAllForSchedule().stream() + .filter(slot -> slot.getTimeSlotScope() != null) + .filter(slot -> "DEFAULT".equals(slot.getTimeSlotScope().getApplyMode())) + .map(this::toTimeSlotDto) + .toList(); + return new TeacherPreferenceMetaDto(semesters, timeSlots); + } + + @Transactional(readOnly = true) + public List getAll(Long semesterId, String status) { + AuthenticatedUser current = currentUser(); + String normalizedStatus = cleanUpper(status); + if (normalizedStatus != null && !STATUSES.contains(normalizedStatus)) { + throw new IllegalArgumentException("Неизвестный статус пожелания преподавателя"); + } + return preferenceRepository.findAllWithDetails().stream() + .filter(thisPreference -> semesterId == null + || Objects.equals(thisPreference.getSemester().getId(), semesterId)) + .filter(thisPreference -> normalizedStatus == null + || normalizedStatus.equals(thisPreference.getStatus())) + .filter(thisPreference -> canView(thisPreference, current)) + .map(this::toDto) + .toList(); + } + + @Transactional + public TeacherPreferenceDto create(TeacherPreferenceRequest request) { + AuthenticatedUser current = currentUser(); + if (request == null) { + throw new IllegalArgumentException("Передайте данные пожелания преподавателя"); + } + String type = cleanUpper(request.preferenceType()); + if (type == null || !TYPES.contains(type)) { + throw new IllegalArgumentException("Неизвестный тип пожелания преподавателя"); + } + if (request.semesterId() == null) { + throw new IllegalArgumentException("Выберите семестр"); + } + Semester semester = semesterRepository.findById(request.semesterId()) + .orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); + Long teacherId = current.role() == Role.TEACHER ? current.id() : request.teacherId(); + if (teacherId == null) { + throw new IllegalArgumentException("Выберите преподавателя"); + } + if (current.role() == Role.TEACHER + && request.teacherId() != null + && !Objects.equals(request.teacherId(), current.id())) { + throw forbidden("Преподаватель может создавать пожелания только для себя"); + } + User teacher = userRepository.findById(teacherId) + .filter(user -> user.getRole() == Role.TEACHER) + .filter(User::isActiveRecord) + .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); + ensureManageScope(teacher, semester, current); + + PreferencePayload payload = validatePayload(type, semester, request); + String comment = clean(request.comment()); + if (comment != null && comment.length() > 2000) { + throw new IllegalArgumentException("Комментарий не может превышать 2000 символов"); + } + if (preferenceRepository.existsDuplicate( + teacherId, + semester.getId(), + type, + payload.dayOfWeek(), + payload.timeSlot() == null ? null : payload.timeSlot().getId(), + payload.preferenceDate(), + ACTIVE_STATUSES + )) { + throw new ScheduleConflictException("Такое пожелание уже ожидает решения или согласовано"); + } + + var now = businessTime.now(); + TeacherPreference preference = new TeacherPreference(); + preference.setTeacher(teacher); + preference.setSemester(semester); + preference.setPreferenceType(type); + preference.setDayOfWeek(payload.dayOfWeek()); + preference.setTimeSlot(payload.timeSlot()); + preference.setPreferenceDate(payload.preferenceDate()); + preference.setComment(comment); + preference.setRequestedBy(current.id()); + preference.setCreatedAt(now); + preference.setUpdatedAt(now); + if (current.role() == Role.TEACHER) { + preference.setStatus("PENDING"); + } else { + preference.setStatus("APPROVED"); + preference.setReviewedBy(current.id()); + preference.setReviewedAt(now); + preference.setReviewComment("Добавлено ответственным сотрудником"); + } + TeacherPreference saved = preferenceRepository.saveAndFlush(preference); + log.info("Создано пожелание преподавателя {} на семестр {}, тип {}", teacherId, semester.getId(), type); + return toDto(saved); + } + + @Transactional + public TeacherPreferenceDto review(Long id, TeacherPreferenceReviewRequest request) { + AuthenticatedUser current = currentUser(); + if (current.role() == Role.TEACHER) { + throw forbidden("Преподаватель не может согласовывать пожелания"); + } + if (request == null || request.approved() == null) { + throw new IllegalArgumentException("Укажите решение по пожеланию преподавателя"); + } + TeacherPreference preference = getForUpdate(id); + ensureManageScope(preference.getTeacher(), preference.getSemester(), current); + if (!"PENDING".equals(preference.getStatus())) { + throw new IllegalArgumentException("Рассмотреть можно только ожидающее пожелание"); + } + String comment = clean(request.comment()); + if (!Boolean.TRUE.equals(request.approved()) && comment == null) { + throw new IllegalArgumentException("При отклонении укажите комментарий"); + } + var now = businessTime.now(); + preference.setStatus(Boolean.TRUE.equals(request.approved()) ? "APPROVED" : "REJECTED"); + preference.setReviewedBy(current.id()); + preference.setReviewComment(comment); + preference.setReviewedAt(now); + preference.setUpdatedAt(now); + TeacherPreference saved = preferenceRepository.saveAndFlush(preference); + log.info("Пожелание преподавателя {} переведено в статус {}", id, saved.getStatus()); + return toDto(saved); + } + + @Transactional + public TeacherPreferenceDto cancel(Long id) { + AuthenticatedUser current = currentUser(); + TeacherPreference preference = getForUpdate(id); + if (current.role() == Role.TEACHER) { + if (!Objects.equals(current.id(), preference.getTeacher().getId())) { + throw forbidden("Нельзя отменить пожелание другого преподавателя"); + } + if (!"PENDING".equals(preference.getStatus())) { + throw new IllegalArgumentException("Преподаватель может отменить только ожидающее пожелание"); + } + } else { + ensureManageScope(preference.getTeacher(), preference.getSemester(), current); + if (!Set.of("PENDING", "APPROVED").contains(preference.getStatus())) { + throw new IllegalArgumentException("Это пожелание уже нельзя отменить"); + } + } + preference.setStatus("CANCELLED"); + preference.setUpdatedAt(businessTime.now()); + return toDto(preferenceRepository.saveAndFlush(preference)); + } + + private PreferencePayload validatePayload(String type, Semester semester, TeacherPreferenceRequest request) { + boolean compact = Set.of("CONSECUTIVE", "NO_GAPS").contains(type); + if (compact) { + if (request.dayOfWeek() != null || request.timeSlotId() != null || request.preferenceDate() != null) { + throw new IllegalArgumentException("Для пожелания к компактности день, дата и пара не указываются"); + } + return new PreferencePayload(null, null, null); + } + if (request.preferenceDate() != null) { + if (!"HARD_UNAVAILABLE".equals(type)) { + throw new IllegalArgumentException("Полную недоступность по дате можно отметить только как строгую"); + } + if (request.dayOfWeek() != null || request.timeSlotId() != null) { + throw new IllegalArgumentException("Для полной недоступности по дате день недели и пара не указываются"); + } + if (request.preferenceDate().isBefore(semester.getStartDate()) + || request.preferenceDate().isAfter(semester.getEndDate())) { + throw new IllegalArgumentException("Дата недоступности должна находиться внутри семестра"); + } + return new PreferencePayload(null, null, request.preferenceDate()); + } + if (request.dayOfWeek() == null || request.dayOfWeek() < 1 || request.dayOfWeek() > 7) { + throw new IllegalArgumentException("Выберите день недели"); + } + if (request.timeSlotId() == null) { + throw new IllegalArgumentException("Выберите временной слот"); + } + TimeSlot slot = timeSlotRepository.findById(request.timeSlotId()) + .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); + if (slot.getTimeSlotScope() == null || !"DEFAULT".equals(slot.getTimeSlotScope().getApplyMode())) { + throw new IllegalArgumentException("Для пожеланий используйте базовую сетку времени"); + } + return new PreferencePayload(request.dayOfWeek(), slot, null); + } + + private TeacherPreference getForUpdate(Long id) { + return preferenceRepository.findByIdForUpdate(id) + .orElseThrow(() -> new NoSuchElementException("Пожелание преподавателя не найдено")); + } + + private boolean canView(TeacherPreference preference, AuthenticatedUser current) { + if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return true; + if (current.role() == Role.TEACHER) { + return Objects.equals(current.id(), preference.getTeacher().getId()); + } + return current.role() == Role.DEPARTMENT + && belongsToDepartment(preference.getTeacher(), preference.getSemester(), current.departmentId()); + } + + private void ensureManageScope(User teacher, Semester semester, AuthenticatedUser current) { + if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return; + if (current.role() == Role.TEACHER && Objects.equals(current.id(), teacher.getId())) return; + if (current.role() == Role.DEPARTMENT + && belongsToDepartment(teacher, semester, current.departmentId())) return; + throw forbidden("Пожелание преподавателя находится вне доступной кафедры"); + } + + private boolean belongsToDepartment(User teacher, Semester semester, Long departmentId) { + if (departmentId == null) return false; + return assignmentRepository.findForTeachersBetween( + Set.of(teacher.getId()), + semester.getStartDate(), + semester.getEndDate() + ).stream() + .anyMatch(assignment -> assignment.getDepartment() != null + && Objects.equals(assignment.getDepartment().getId(), departmentId)); + } + + private TeacherPreferenceDto toDto(TeacherPreference preference) { + User teacher = preference.getTeacher(); + Semester semester = preference.getSemester(); + TimeSlot slot = preference.getTimeSlot(); + return new TeacherPreferenceDto( + preference.getId(), + teacher.getId(), + ScheduleGeneratorService.displayUserName(teacher), + teacher.getDepartmentId(), + semester.getId(), + semester.getAcademicYear().getTitle(), + semester.getSemesterType().name(), + semester.getStartDate(), + semester.getEndDate(), + preference.getPreferenceType(), + preference.getDayOfWeek(), + dayName(preference.getDayOfWeek()), + slot == null ? null : slot.getId(), + slot == null ? null : slot.getOrderNumber(), + slot == null ? null : slot.getStartTime(), + slot == null ? null : slot.getEndTime(), + preference.getPreferenceDate(), + preference.getComment(), + preference.getStatus(), + preference.getRequestedBy(), + preference.getReviewedBy(), + preference.getReviewComment(), + preference.getReviewedAt(), + preference.getCreatedAt(), + preference.getUpdatedAt() + ); + } + + private TimeSlotDto toTimeSlotDto(TimeSlot slot) { + var scope = slot.getTimeSlotScope(); + return new TimeSlotDto( + slot.getId(), slot.getOrderNumber(), scope.getId(), scope.getName(), scope.getApplyMode(), + scope.getDayOfWeek(), slot.getStartTime(), slot.getEndTime(), slot.getDurationMinutes() + ); + } + + private String dayName(Integer dayOfWeek) { + if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) return null; + String value = DayOfWeek.of(dayOfWeek).getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru")); + return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru")) + value.substring(1); + } + + private AuthenticatedUser currentUser() { + AuthenticatedUser current = AuthContext.getCurrentUser(); + if (current == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Требуется авторизация"); + return current; + } + + private ResponseStatusException forbidden(String message) { + return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + } + + private String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private String cleanUpper(String value) { + String cleaned = clean(value); + return cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT); + } + + private record PreferencePayload(Integer dayOfWeek, TimeSlot timeSlot, LocalDate preferenceDate) { + } +} diff --git a/backend/src/main/resources/db/migration/V3__teacher_preferences_and_change_requests.sql b/backend/src/main/resources/db/migration/V3__teacher_preferences_and_change_requests.sql new file mode 100644 index 0000000..7e48e2f --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__teacher_preferences_and_change_requests.sql @@ -0,0 +1,151 @@ +-- Пожелания преподавателей на семестр и журнал заявок на изменение опубликованных занятий. + +CREATE TABLE teacher_preferences ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id), + semester_id BIGINT NOT NULL REFERENCES semesters(id), + preference_type VARCHAR(30) NOT NULL, + day_of_week SMALLINT, + time_slot_id BIGINT REFERENCES time_slots(id), + preference_date DATE, + comment TEXT, + status VARCHAR(20) NOT NULL, + requested_by BIGINT REFERENCES users(id), + reviewed_by BIGINT REFERENCES users(id), + review_comment TEXT, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_teacher_preferences_type CHECK ( + preference_type IN ( + 'HARD_UNAVAILABLE', + 'SOFT_PREFERRED', + 'SOFT_UNWANTED', + 'CONSECUTIVE', + 'NO_GAPS' + ) + ), + CONSTRAINT chk_teacher_preferences_status CHECK ( + status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') + ), + CONSTRAINT chk_teacher_preferences_day CHECK ( + day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7 + ), + CONSTRAINT chk_teacher_preferences_payload CHECK ( + ( + preference_type = 'HARD_UNAVAILABLE' + AND ( + (preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL) + OR + (preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL) + ) + ) + OR + ( + preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED') + AND preference_date IS NULL + AND day_of_week IS NOT NULL + AND time_slot_id IS NOT NULL + ) + OR + ( + preference_type IN ('CONSECUTIVE', 'NO_GAPS') + AND preference_date IS NULL + AND day_of_week IS NULL + AND time_slot_id IS NULL + ) + ), + CONSTRAINT chk_teacher_preferences_comment CHECK ( + comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000 + ) +); + +CREATE INDEX idx_teacher_preferences_teacher_semester + ON teacher_preferences(teacher_id, semester_id, status); + +CREATE INDEX idx_teacher_preferences_slot + ON teacher_preferences(semester_id, day_of_week, time_slot_id) + WHERE status = 'APPROVED'; + +CREATE INDEX idx_teacher_preferences_date + ON teacher_preferences(teacher_id, preference_date) + WHERE status = 'APPROVED' AND preference_date IS NOT NULL; + +CREATE TABLE teacher_change_requests ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id), + base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id), + lesson_date DATE NOT NULL, + request_type VARCHAR(30) NOT NULL, + target_lesson_date DATE, + requested_time_slot_id BIGINT REFERENCES time_slots(id), + requested_classroom_id BIGINT REFERENCES classrooms(id), + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + reviewed_by BIGINT REFERENCES users(id), + review_comment TEXT, + reviewed_at TIMESTAMPTZ, + applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_teacher_change_requests_type CHECK ( + request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL') + ), + CONSTRAINT chk_teacher_change_requests_status CHECK ( + status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') + ), + CONSTRAINT chk_teacher_change_requests_reason CHECK ( + length(trim(reason)) BETWEEN 1 AND 2000 + ), + CONSTRAINT chk_teacher_change_requests_payload CHECK ( + ( + request_type = 'MOVE' + AND requested_time_slot_id IS NOT NULL + AND requested_classroom_id IS NULL + ) + OR + ( + request_type = 'CHANGE_CLASSROOM' + AND target_lesson_date IS NULL + AND requested_time_slot_id IS NULL + AND requested_classroom_id IS NOT NULL + ) + OR + ( + request_type = 'CANCEL' + AND target_lesson_date IS NULL + AND requested_time_slot_id IS NULL + AND requested_classroom_id IS NULL + ) + ) +); + +CREATE INDEX idx_teacher_change_requests_teacher + ON teacher_change_requests(teacher_id, created_at DESC); + +CREATE INDEX idx_teacher_change_requests_status + ON teacher_change_requests(status, created_at); + +CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson + ON teacher_change_requests(base_rule_slot_id, lesson_date) + WHERE status = 'PENDING'; + +CREATE TABLE teacher_change_request_history ( + id BIGSERIAL PRIMARY KEY, + request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL, + comment TEXT, + actor_id BIGINT REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_teacher_change_request_history_status CHECK ( + status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') + ) +); + +CREATE INDEX idx_teacher_change_request_history_request + ON teacher_change_request_history(request_id, created_at, id); + +COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр'; +COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания'; +COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия'; +COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя'; diff --git a/backend/src/test/java/com/magistr/app/controller/TeacherChangeRequestControllerTest.java b/backend/src/test/java/com/magistr/app/controller/TeacherChangeRequestControllerTest.java new file mode 100644 index 0000000..cc0a480 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/TeacherChangeRequestControllerTest.java @@ -0,0 +1,90 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.TeacherChangeRequestDto; +import com.magistr.app.service.TeacherChangeRequestService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class TeacherChangeRequestControllerTest { + + private TeacherChangeRequestService service; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + service = mock(TeacherChangeRequestService.class); + mockMvc = standaloneSetup(new TeacherChangeRequestController(service)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void createsMoveRequestWithRussianReason() throws Exception { + when(service.create(any())).thenReturn(request("PENDING")); + + mockMvc.perform(post("/api/teacher-change-requests") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "baseRuleSlotId": 40, + "lessonDate": "2026-09-14", + "requestType": "MOVE", + "targetLessonDate": "2026-09-16", + "requestedTimeSlotId": 4, + "reason": "Участие в конференции" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.reason").value("Участие в конференции")); + } + + @Test + void approvesRequestAndReturnsAppliedOverride() throws Exception { + when(service.review(eq(12L), any())).thenReturn(request("APPROVED")); + + mockMvc.perform(post("/api/teacher-change-requests/12/review") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"approved": true, "comment": "Свободный слот подтверждён"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("APPROVED")) + .andExpect(jsonPath("$.appliedOverrideId").value(77)); + + verify(service).review(eq(12L), any()); + } + + private TeacherChangeRequestDto request(String status) { + Instant now = Instant.parse("2026-08-11T10:00:00Z"); + return new TeacherChangeRequestDto( + 12L, 11L, "Иванов Иван Иванович", 7L, 40L, + LocalDate.of(2026, 9, 14), "Базы данных", "Лекция", List.of("ИВТ-21"), + 2L, 1, LocalTime.of(9, 0), LocalTime.of(10, 30), 8L, "101", + "MOVE", LocalDate.of(2026, 9, 16), 4L, 2, + LocalTime.of(10, 40), LocalTime.of(12, 10), null, null, + "Участие в конференции", status, status.equals("APPROVED") ? 1L : null, + status.equals("APPROVED") ? "Свободный слот подтверждён" : null, + status.equals("APPROVED") ? now : null, status.equals("APPROVED") ? 77L : null, + now, now, + List.of(new TeacherChangeRequestDto.HistoryItem(1L, status, "Комментарий", 1L, now)) + ); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/TeacherPreferenceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/TeacherPreferenceControllerTest.java new file mode 100644 index 0000000..da13d74 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/TeacherPreferenceControllerTest.java @@ -0,0 +1,77 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.TeacherPreferenceDto; +import com.magistr.app.service.TeacherPreferenceService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class TeacherPreferenceControllerTest { + + private TeacherPreferenceService service; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + service = mock(TeacherPreferenceService.class); + mockMvc = standaloneSetup(new TeacherPreferenceController(service)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void returnsApprovedPreferencesForSemester() throws Exception { + when(service.getAll(5L, "APPROVED")).thenReturn(List.of(preference("APPROVED"))); + + mockMvc.perform(get("/api/teacher-preferences") + .param("semesterId", "5") + .param("status", "APPROVED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].preferenceType").value("SOFT_PREFERRED")) + .andExpect(jsonPath("$[0].teacherName").value("Иванов Иван Иванович")); + } + + @Test + void reviewsPendingPreference() throws Exception { + when(service.review(eq(9L), any())).thenReturn(preference("APPROVED")); + + mockMvc.perform(post("/api/teacher-preferences/9/review") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"approved": true, "comment": "Учтено при составлении расписания"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("APPROVED")); + + verify(service).review(eq(9L), any()); + } + + private TeacherPreferenceDto preference(String status) { + Instant now = Instant.parse("2026-08-11T10:00:00Z"); + return new TeacherPreferenceDto( + 9L, 11L, "Иванов Иван Иванович", 7L, 5L, "2026/2027", "AUTUMN", + LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31), + "SOFT_PREFERRED", 1, "Понедельник", 3L, 2, + LocalTime.of(10, 40), LocalTime.of(12, 10), null, null, status, + 11L, status.equals("APPROVED") ? 1L : null, null, + status.equals("APPROVED") ? now : null, now, now + ); + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java new file mode 100644 index 0000000..ead5bfe --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java @@ -0,0 +1,83 @@ +package com.magistr.app.migration; + +import com.magistr.app.testing.TestContainerImages; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Testcontainers +@DisplayName("Миграция пожеланий и заявок преподавателей") +class TeacherPreferencesMigrationIntegrationTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>(TestContainerImages.POSTGRES); + + @Test + @DisplayName("V3 создаёт три таблицы и отклоняет противоречивое пожелание") + void migrationCreatesWorkflowTablesAndConstraints() throws SQLException { + Flyway flyway = Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("3")) + .cleanDisabled(false) + .load(); + flyway.clean(); + flyway.migrate(); + + assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("3"); + try (Connection connection = POSTGRES.createConnection("")) { + assertThat(queryLong(connection, """ + SELECT count(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'teacher_preferences', + 'teacher_change_requests', + 'teacher_change_request_history' + ) + """)).isEqualTo(3L); + + long teacherId = queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1"); + long semesterId = queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1"); + long timeSlotId = queryLong(connection, "SELECT id FROM time_slots ORDER BY id LIMIT 1"); + + Throwable failure = catchThrowable(() -> { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO teacher_preferences ( + teacher_id, semester_id, preference_type, + day_of_week, time_slot_id, preference_date, status + ) VALUES (?, ?, 'SOFT_PREFERRED', 1, ?, CURRENT_DATE, 'PENDING') + """)) { + statement.setLong(1, teacherId); + statement.setLong(2, semesterId); + statement.setLong(3, timeSlotId); + statement.executeUpdate(); + } + }); + + assertThat(failure).isInstanceOf(SQLException.class); + assertThat(((SQLException) failure).getSQLState()).isEqualTo("23514"); + assertThat(failure.getMessage()).contains("chk_teacher_preferences_payload"); + } + } + + private long queryLong(Connection connection, String sql) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getLong(1); + } + } +} diff --git a/backend/src/test/java/com/magistr/app/service/TeacherAvailabilityPolicyServiceTest.java b/backend/src/test/java/com/magistr/app/service/TeacherAvailabilityPolicyServiceTest.java new file mode 100644 index 0000000..2f6db37 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/TeacherAvailabilityPolicyServiceTest.java @@ -0,0 +1,92 @@ +package com.magistr.app.service; + +import com.magistr.app.model.TeacherPreference; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.repository.TeacherPreferenceRepository; +import com.magistr.app.repository.TimeSlotRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TeacherAvailabilityPolicyServiceTest { + + @Mock + private TeacherPreferenceRepository preferenceRepository; + @Mock + private TimeSlotRepository timeSlotRepository; + + private TeacherAvailabilityPolicyService service; + private TimeSlot candidateSlot; + + @BeforeEach + void setUp() { + service = new TeacherAvailabilityPolicyService(preferenceRepository, timeSlotRepository); + candidateSlot = new TimeSlot(); + candidateSlot.setId(3L); + candidateSlot.setOrderNumber(2); + when(timeSlotRepository.findById(3L)).thenReturn(Optional.of(candidateSlot)); + } + + @Test + void approvedHardDateBlocksCandidate() { + TeacherPreference preference = preference("HARD_UNAVAILABLE"); + preference.setPreferenceDate(LocalDate.of(2026, 9, 14)); + when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L)) + .thenReturn(List.of(preference)); + + assertThatThrownBy(() -> service.assertLessonAvailable( + 11L, 5L, LocalDate.of(2026, 9, 14), 3L + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessageContaining("полную недоступность"); + } + + @Test + void weeklyHardIntervalMatchesSlotOrderAcrossTimeGrids() { + TeacherPreference preference = preference("HARD_UNAVAILABLE"); + preference.setDayOfWeek(1); + TimeSlot baseSlot = new TimeSlot(); + baseSlot.setId(8L); + baseSlot.setOrderNumber(2); + preference.setTimeSlot(baseSlot); + when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L)) + .thenReturn(List.of(preference)); + + assertThatThrownBy(() -> service.assertLessonAvailable( + 11L, 5L, LocalDate.of(2026, 9, 14), 3L + )) + .isInstanceOf(ScheduleConflictException.class) + .hasMessageContaining("день и время"); + } + + @Test + void softPreferenceDoesNotBlockCandidate() { + TeacherPreference preference = preference("SOFT_UNWANTED"); + preference.setDayOfWeek(1); + preference.setTimeSlot(candidateSlot); + when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L)) + .thenReturn(List.of(preference)); + + assertThatCode(() -> service.assertLessonAvailable( + 11L, 5L, LocalDate.of(2026, 9, 14), 3L + )).doesNotThrowAnyException(); + } + + private TeacherPreference preference(String type) { + TeacherPreference preference = new TeacherPreference(); + preference.setPreferenceType(type); + preference.setStatus("APPROVED"); + return preference; + } +} diff --git a/docs/API.md b/docs/API.md index 9f3358e..3ee8436 100644 --- a/docs/API.md +++ b/docs/API.md @@ -912,6 +912,84 @@ API возвращает `409 Conflict`; соседние интервалы и выбранного действия возвращает `409` и откатывает остальные. Незаполненные занятия не меняются. Когда обработаны все оставшиеся занятия, отсутствие получает статус `RESOLVED`. +### Пожелания преподавателей на семестр + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/teacher-preferences/meta` | Семестры и базовая сетка времени для календаря доступности | +| `GET` | `/api/teacher-preferences?semesterId=&status=` | Доступные текущей роли пожелания с фильтрами по семестру и статусу | +| `POST` | `/api/teacher-preferences` | Создать ограничение или мягкое пожелание | +| `POST` | `/api/teacher-preferences/{id}/review` | Согласовать или отклонить ожидающее пожелание | +| `DELETE` | `/api/teacher-preferences/{id}` | Отозвать ожидающее либо отменить действующее пожелание | + +Типы пожеланий: `HARD_UNAVAILABLE` — строгая недоступность по паре недели или целой дате, +`SOFT_PREFERRED` — предпочтительный интервал, `SOFT_UNWANTED` — нежелательный интервал, +`CONSECUTIVE` — пары подряд, `NO_GAPS` — расписание без окон. Интервальные пожелания +используют `dayOfWeek` от 1 до 7 и `timeSlotId` базовой сетки; полная недоступность +использует только `preferenceDate` внутри выбранного семестра. + +```json +{ + "semesterId": 4, + "preferenceType": "HARD_UNAVAILABLE", + "dayOfWeek": 5, + "timeSlotId": 3, + "comment": "Методический день" +} +``` + +Преподаватель создаёт и видит только собственные пожелания; новая запись получает статус +`PENDING`. `ADMIN` и `EDUCATION_OFFICE` работают со всеми преподавателями, а `DEPARTMENT` — +только с преподавателями своей кафедры по истории назначений на период семестра. Запись, +добавленная ответственным сотрудником, сразу получает статус `APPROVED`. При отклонении +поле `comment` в запросе согласования обязательно: + +```json +{ + "approved": false, + "comment": "Уточните день полной недоступности" +} +``` + +Согласованная строгая недоступность блокирует сохранение конфликтующего правила и разовой +правки. Мягкие пожелания и требования компактности показываются в конструкторе правил как +подсказки и не изменяют опубликованное расписание автоматически. + +### Заявки преподавателей на изменение занятия + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/teacher-change-requests?status=` | Доступный текущей роли журнал заявок и история решений | +| `GET` | `/api/teacher-change-requests/candidates?baseRuleSlotId=&lessonDate=&targetDate=` | Предварительно проверенные даты, пары и аудитории | +| `POST` | `/api/teacher-change-requests` | Отправить заявку по собственному занятию | +| `POST` | `/api/teacher-change-requests/{id}/review` | Применить или отклонить заявку | +| `DELETE` | `/api/teacher-change-requests/{id}` | Отозвать собственную ожидающую заявку | + +Преподаватель может выбрать только фактически существующее собственное занятие без уже +созданного override. Поддерживаются `MOVE`, `CHANGE_CLASSROOM` и `CANCEL`; обоснование +обязательно. Для `MOVE` передаются новая пара и, при необходимости, новая дата, для +`CHANGE_CLASSROOM` — только новая аудитория. Кандидаты заранее проходят тот же валидатор +ресурсных конфликтов, календаря, эффективной сетки времени, отсутствий и строгой +недоступности, который используется при применении разовой правки. + +```json +{ + "baseRuleSlotId": 31, + "lessonDate": "2026-09-10", + "requestType": "MOVE", + "targetLessonDate": "2026-09-11", + "requestedTimeSlotId": 4, + "reason": "Участие в конференции" +} +``` + +`ADMIN` и `EDUCATION_OFFICE` могут принять заявку; backend повторно валидирует её и +транзакционно создаёт обычный `schedule_override`, записывает `appliedOverrideId` и событие +`APPROVED` в историю. Кафедра видит заявки своих преподавателей, но не применяет изменения +расписания. При отклонении комментарий обязателен. Статусы: `PENDING`, `APPROVED`, +`REJECTED`, `CANCELLED`; по одному занятию одновременно допускается только одна ожидающая +заявка. + ## Загруженность | Метод | URL | Назначение | diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 52f3d93..36daed6 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -343,6 +343,49 @@ constraint. При чтении API разворачивает периоды о --- +### Пожелания преподавателей на семестр + +Преподаватель формирует набор пожеланий отдельно для каждого семестра. Интервальные записи +привязаны к дню недели и паре базовой сетки времени, полная строгая недоступность — к +конкретной дате семестра. Поддерживаются: + +- `HARD_UNAVAILABLE` — строго запрещённый интервал либо полностью недоступная дата; +- `SOFT_PREFERRED` и `SOFT_UNWANTED` — предпочтительный и нежелательный интервалы; +- `CONSECUTIVE` и `NO_GAPS` — пожелания к компактности расписания. + +Запись преподавателя сначала имеет статус `PENDING`. Кафедра может рассматривать только +пожелания преподавателей, относившихся к ней в период семестра; учебный отдел и +администратор работают со всеми записями. Ответственный сотрудник также может сразу создать +согласованную запись. Отклонение требует комментария, а преподаватель может отозвать только +собственную ожидающую запись. + +Только согласованные строгие ограничения влияют на валидацию. `ScheduleRuleService` +проверяет каждую активную неделю нового или изменённого правила, а `ScheduleOverrideService` +— фактическую дату результата разовой правки. Поэтому строгая недоступность одинаково +учитывается конструктором правил, мастером замены и заявками преподавателей. Мягкие +пожелания и компактность подсвечиваются в конструкторе, но не меняют опубликованное +расписание автоматически. + +### Заявки преподавателей на изменение занятия + +Заявку можно создать только по собственному фактическому занятию, для которого ещё нет +разовой правки. Поддерживаются перенос даты/времени (`MOVE`), смена аудитории +(`CHANGE_CLASSROOM`) и отмена (`CANCEL`). Одновременно по одной паре допускается только одна +заявка `PENDING`. + +До отправки интерфейс получает список ближайших учебных дат, временных слотов и аудиторий. +Каждый вариант проходит `ScheduleOverrideService`: проверяются принадлежность семестру, +календарный график групп, эффективная сетка времени, жизненный цикл ресурсов, пересечения +преподавателя, аудитории, групп и подгрупп, подтверждённые отсутствия и строгая +недоступность преподавателя. При создании заявки проверка выполняется повторно. + +Кафедра видит заявки своих преподавателей, но применять изменение вправе только +`ADMIN` или `EDUCATION_OFFICE`. При одобрении в одной транзакции повторно проверяется и +создаётся обычный `schedule_override`, его ID сохраняется в заявке, а в неизменяемую +историю добавляется статус `APPROVED`. Отклонение требует комментария; преподаватель может +отозвать только собственную ожидающую заявку. История содержит автора, время, статус и +комментарий каждого перехода. + ## Привязка преподаватель ↔ дисциплина Связь Many-to-Many через таблицу `teacher_subjects`: diff --git a/docs/DATABASE.md b/docs/DATABASE.md index b06978c..ab5c894 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -362,6 +362,50 @@ erDiagram TIMESTAMPTZ decided_at } + teacher_preferences { + BIGSERIAL id PK + BIGINT teacher_id FK + BIGINT semester_id FK + VARCHAR preference_type + INT day_of_week + BIGINT time_slot_id FK + DATE preference_date + TEXT comment + VARCHAR status + BIGINT requested_by FK + BIGINT reviewed_by FK + TEXT review_comment + TIMESTAMPTZ reviewed_at + TIMESTAMPTZ created_at + TIMESTAMPTZ updated_at + } + + teacher_change_requests { + BIGSERIAL id PK + BIGINT teacher_id FK + BIGINT base_rule_slot_id FK + DATE lesson_date + VARCHAR request_type + DATE target_lesson_date + BIGINT requested_time_slot_id FK + BIGINT requested_classroom_id FK + TEXT reason + VARCHAR status + BIGINT reviewed_by FK + BIGINT applied_override_id FK + TIMESTAMPTZ created_at + TIMESTAMPTZ updated_at + } + + teacher_change_request_history { + BIGSERIAL id PK + BIGINT request_id FK + VARCHAR status + TEXT comment + BIGINT actor_id FK + TIMESTAMPTZ created_at + } + schedule_rule_slot_subgroups { BIGINT schedule_rule_slot_id FK,PK BIGINT subgroup_id FK,PK @@ -418,6 +462,16 @@ erDiagram schedule_rule_slots ||--o{ teacher_absence_decisions : "base_rule_slot_id" schedule_overrides ||--o{ teacher_absence_decisions : "schedule_override_id" users ||--o{ teacher_absence_decisions : "decided_by" + users ||--o{ teacher_preferences : "teacher_id/requested_by/reviewed_by" + semesters ||--o{ teacher_preferences : "semester_id" + time_slots ||--o{ teacher_preferences : "time_slot_id" + users ||--o{ teacher_change_requests : "teacher_id/reviewed_by" + schedule_rule_slots ||--o{ teacher_change_requests : "base_rule_slot_id" + time_slots ||--o{ teacher_change_requests : "requested_time_slot_id" + classrooms ||--o{ teacher_change_requests : "requested_classroom_id" + schedule_overrides ||--o{ teacher_change_requests : "applied_override_id" + teacher_change_requests ||--o{ teacher_change_request_history : "request_id" + users ||--o{ teacher_change_request_history : "actor_id" time_slot_scopes ||--o{ time_slots : "time_slot_scope_id" time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id" time_slots ||--o{ schedule_rule_slots : "time_slot_id" @@ -976,6 +1030,69 @@ CHECK запрещает обратный период, пустую или сл одно занятие одного инцидента. При удалении override ссылка обнуляется, но аудиторская запись сохраняется. +#### `teacher_preferences` — Пожелания преподавателей на семестр + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID пожелания | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `semester_id` | BIGINT FK → semesters | Семестр действия | +| `preference_type` | VARCHAR(30) | `HARD_UNAVAILABLE`, `SOFT_PREFERRED`, `SOFT_UNWANTED`, `CONSECUTIVE` или `NO_GAPS` | +| `day_of_week` | SMALLINT NULL | День недели 1–7 для интервального пожелания | +| `time_slot_id` | BIGINT FK → time_slots NULL | Пара базовой сетки времени | +| `preference_date` | DATE NULL | Полностью недоступная дата при `HARD_UNAVAILABLE` | +| `comment` | TEXT | Комментарий преподавателя | +| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED` | +| `requested_by` | BIGINT FK → users | Автор записи | +| `reviewed_by` | BIGINT FK → users | Автор решения | +| `review_comment` | TEXT | Комментарий согласования | +| `reviewed_at` | TIMESTAMPTZ | Время решения | +| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла | + +CHECK-ограничение разделяет три допустимые формы записи: строгая недоступность по целой +дате, строгое или мягкое пожелание по паре недели, либо пожелание компактности без даты и +пары. Частичные индексы ускоряют проверку согласованной недоступности по интервалу и дате. +Семантическая уникальность активных пожеланий проверяется сервисом, поскольку PostgreSQL +считает значения `NULL` различными. + +#### `teacher_change_requests` — Заявки на изменение занятия + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID заявки | +| `teacher_id` | BIGINT FK → users | Преподаватель — автор заявки | +| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот занятия | +| `lesson_date` | DATE | Исходная дата занятия | +| `request_type` | VARCHAR(30) | `MOVE`, `CHANGE_CLASSROOM` или `CANCEL` | +| `target_lesson_date` | DATE NULL | Целевая дата переноса | +| `requested_time_slot_id` | BIGINT FK → time_slots NULL | Запрошенная пара при переносе | +| `requested_classroom_id` | BIGINT FK → classrooms NULL | Запрошенная аудитория | +| `reason` | TEXT | Обязательное обоснование длиной 1–2000 символов | +| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED` | +| `reviewed_by` | BIGINT FK → users | Сотрудник учебного отдела | +| `review_comment` | TEXT | Комментарий решения | +| `reviewed_at` | TIMESTAMPTZ | Время решения | +| `applied_override_id` | BIGINT FK → schedule_overrides NULL | Разовая правка одобренной заявки | +| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла | + +CHECK фиксирует допустимую форму каждого типа заявки. Частичный уникальный индекс по +`(base_rule_slot_id, lesson_date)` запрещает две одновременно ожидающие заявки на одну пару. +При удалении применённого override ссылка обнуляется, но заявка и история сохраняются. + +#### `teacher_change_request_history` — История решений по заявке + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID события | +| `request_id` | BIGINT FK → teacher_change_requests (CASCADE) | Заявка | +| `status` | VARCHAR(20) | Зафиксированный статус заявки | +| `comment` | TEXT | Обоснование или комментарий решения | +| `actor_id` | BIGINT FK → users | Автор перехода | +| `created_at` | TIMESTAMPTZ | Время события | + +Записи добавляются при создании, принятии, отклонении и отзыве заявки. Индекс +`(request_id, created_at, id)` обеспечивает стабильный хронологический вывод журнала. + --- ## Flyway миграции @@ -996,12 +1113,14 @@ CHECK запрещает обратный период, пустую или сл |------|----------| | `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | | `V2__teacher_absences_and_replacement_wizard.sql` | Реестр отсутствий преподавателей, статусы согласования и журнал применённых/отклонённых решений со ссылками на обычные `schedule_overrides` | +| `V3__teacher_preferences_and_change_requests.sql` | Пожелания преподавателей на семестр, строгая и мягкая доступность, заявки на изменение занятия и неизменяемая история решений | ### Этап разработки Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были -объединены в baseline `V1`. После фиксации baseline нумерация начата заново: текущая `V2` -добавляет отсутствия и мастер замены, не изменяя контрольную сумму `V1`. +объединены в baseline `V1`. После фиксации baseline нумерация начата заново: `V2` +добавляет отсутствия и мастер замены, а `V3` — пожелания преподавателей и заявки на +изменение занятий, не изменяя контрольную сумму `V1`. Интервальное хранение активностей и правильная нумерация недель календарного графика входят непосредственно в V1. Перед применением этой редакции требуется полностью пустая tenant-схема. diff --git a/docs/FEATURE_IDEAS.md b/docs/FEATURE_IDEAS.md index 24aaf0f..857a5e5 100644 --- a/docs/FEATURE_IDEAS.md +++ b/docs/FEATURE_IDEAS.md @@ -162,6 +162,11 @@ ## 3. Пожелания преподавателей и заявки на перенос +**Статус: реализовано в MVP.** Добавлены согласуемый календарь доступности, строгие и мягкие +пожелания, требования «пары подряд» и «без окон», заявки на перенос, смену аудитории и отмену +с предварительной проверкой конфликтов, историей решений и применением через +`schedule_overrides`. + ### Проблема и пользователи Кабинет преподавателя сейчас предназначен только для просмотра недельного расписания. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index cd86d9b..d163cd6 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -35,6 +35,7 @@ frontend/ │ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки │ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра │ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям +│ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора │ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile ├── index.html # 🔐 Страница авторизации (общая) ├── script.js # Логика авторизации @@ -51,7 +52,7 @@ frontend/ │ │ ├── modals.css # Модальные окна │ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности │ │ ├── departments-data.css # Стили создания кафедры/специальности -│ │ └── teacher-absences.css # Реестр инцидентов и полноэкранный мастер замены +│ │ └── teacher-absences.css # Отсутствия, пожелания и очередь заявок преподавателей │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям @@ -70,8 +71,8 @@ frontend/ │ │ ├── department-workspace.js # Кабинет кафедры в общей панели │ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки │ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений -│ │ ├── teacher-absences.js # Реестр, согласование и групповой мастер замены -│ │ ├── schedule.js # Конструктор правил расписания +│ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение +│ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей │ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки │ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года │ │ ├── academic-calendar.js # Календарные учебные графики @@ -105,9 +106,9 @@ frontend/ │ └── time-slots.html # Базовая, субботняя и ручные сетки времени │ ├── teacher/ # 👩‍🏫 Интерфейс преподавателя -│ ├── index.html # CSP-совместимая HTML-оболочка -│ ├── app.js # Недельный просмотр и общий auth-session -│ └── style.css # Стили кабинета без inline-блока +│ ├── index.html # Расписание, календарь доступности и журнал заявок +│ ├── app.js # Пожелания, переносы и недельный просмотр через общий auth-session +│ └── style.css # Адаптивный кабинет в стиле диспетчерского журнала ├── department/ # 🏛 Кабинет кафедры │ └── index.html # Redirect в `/admin/#department-workspace` ├── edu-office/ # 🗓 Кабинет учебного отдела @@ -170,8 +171,8 @@ frontend/ | `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` | | `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` | -| `teacher-absences` | Реестр отсутствий, подтверждение преподавательских заявок, проверенные варианты замен/переносов и журнал решений | `/api/teacher-absences`, `/api/users/teachers` | -| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | +| `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers` | +| `schedule` | Конструктор правил динамического расписания с подсказками согласованных пожеланий и выезжающей визуальной матрицей групп | `/api/admin/schedule-rules`, `/api/teacher-preferences`, `/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/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/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` | @@ -190,14 +191,19 @@ frontend/ командный блок показывает очередь инцидентов, форма регистрирует преподавателя, период и причину, а реестр разделяет статусы согласования. Полноэкранный мастер выводит каждое затронутое занятие отдельной строкой и предлагает только кандидатов, уже проверенных - backend. Пустая строка не отправляется; выбранные решения применяются одним пакетом. + backend. Пустая строка не отправляется; выбранные решения применяются одним пакетом. Ниже + расположены реестр пожеланий на семестр и очередь заявок на изменение опубликованных пар. + Пожелание можно принять или отклонить с комментарием. Заявку применяют только + `ADMIN` и `EDUCATION_OFFICE`; кафедра видит записи своих преподавателей без управляющих + действий. Каждая карточка показывает исходное и запрошенное состояние, результат + предварительной проверки и хронологию решения. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. - Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. -- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. +- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. REST-контракт остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. @@ -327,14 +333,23 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) { ### Преподаватель (`/teacher/`) -Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из -восстановленного в памяти профиля сессии. +Страница объединяет недельную сетку занятий, семестровый календарь доступности и журнал +заявок на изменение пар. ID преподавателя берётся из восстановленного в памяти профиля +сессии. Основные элементы: - навигация по неделям: предыдущая, текущая, следующая; - выбор даты через `input[type="date"]`; - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; -- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила. +- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила; +- вкладка «Мои пожелания» с выбором семестра, режимом отметки строгих, предпочтительных и + нежелательных интервалов, полностью недоступных дат, пар подряд и расписания без окон; +- цветовые статусы `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED`; ожидающее пожелание можно + отозвать кликом по отмеченной ячейке или кнопкой в списке дат; +- кнопка «Заявка на изменение» в карточке фактического занятия и модальное окно переноса, + смены аудитории или отмены. Недоступные кандидаты отфильтрованы после проверки backend; +- вкладка журнала заявок с причиной, решением, ссылкой на применённый override и историей + статусов; ожидающую заявку можно отозвать; - форма собственной заявки на отсутствие через `POST /api/teacher-absences`; - список статусов заявок и отмена ещё не подтверждённой записи через `DELETE /api/teacher-absences/{id}`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index ce5f333..55e75da 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -759,6 +759,37 @@ margin-bottom: 0; } +.schedule-preference-hint { + display: flex; + grid-column: 1 / -1; + flex-wrap: wrap; + gap: .4rem; + align-items: center; + min-height: 25px; + margin-top: -.15rem; +} + +.schedule-preference-caption { + margin-right: .25rem; + color: var(--text-secondary); + font-size: .69rem; + font-weight: 700; +} + +.schedule-preference-pill { + padding: .24rem .5rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: .67rem; + font-weight: 800; +} + +.schedule-preference-pill.hard { color: #fb7185; background: color-mix(in srgb, #fb7185 9%, transparent); } +.schedule-preference-pill.preferred { color: #34d399; background: color-mix(in srgb, #34d399 9%, transparent); } +.schedule-preference-pill.unwanted { color: #f59e0b; background: color-mix(in srgb, #f59e0b 9%, transparent); } +.schedule-preference-pill.dates { color: #fda4af; } +.schedule-preference-pill.compact { color: #60a5fa; } + .schedule-slot-row.has-subgroup { grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; } diff --git a/frontend/admin/css/teacher-absences.css b/frontend/admin/css/teacher-absences.css index 5d569d8..5b33159 100644 --- a/frontend/admin/css/teacher-absences.css +++ b/frontend/admin/css/teacher-absences.css @@ -470,6 +470,167 @@ body.absence-wizard-open { gap: 0.65rem; } +.teacher-dialogue-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.teacher-dialogue-card { + min-width: 0; + border-radius: var(--radius-lg); +} + +.teacher-dialogue-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.teacher-dialogue-head h2 { + margin: .25rem 0; + font-size: 1.25rem; +} + +.teacher-dialogue-head p { + max-width: 560px; + margin: 0; + color: var(--text-secondary); + font-size: .8rem; + line-height: 1.5; +} + +.teacher-dialogue-counter { + display: grid; + min-width: 78px; + min-height: 70px; + place-items: center; + padding: .55rem; + border: 1px solid color-mix(in srgb, #f59e0b 28%, var(--bg-card-border)); + border-radius: 14px; + background: color-mix(in srgb, #f59e0b 8%, var(--bg-input)); +} + +.teacher-dialogue-counter strong { + font-size: 1.5rem; + line-height: 1; +} + +.teacher-dialogue-counter span { + color: var(--text-secondary); + font-size: .67rem; +} + +.teacher-dialogue-toolbar { + display: flex; + gap: .55rem; + margin: 1rem 0; +} + +.teacher-dialogue-toolbar select { + min-width: 0; + flex: 1; +} + +.teacher-dialogue-list { + display: grid; + gap: .65rem; + max-height: 680px; + overflow-y: auto; +} + +.teacher-dialogue-item { + display: grid; + grid-template-columns: 7px minmax(0, 1fr) minmax(180px, .42fr); + gap: .85rem; + padding: .85rem; + border: 1px solid var(--bg-card-border); + border-radius: 13px; + background: color-mix(in srgb, var(--bg-input) 68%, transparent); +} + +.teacher-dialogue-mark { + border-radius: 99px; + background: #94a3b8; +} + +.teacher-dialogue-mark.preference-hard_unavailable { background: #fb7185; } +.teacher-dialogue-mark.preference-soft_preferred { background: #34d399; } +.teacher-dialogue-mark.preference-soft_unwanted { background: #f59e0b; } +.teacher-dialogue-mark.preference-consecutive, +.teacher-dialogue-mark.preference-no_gaps { background: #60a5fa; } +.teacher-dialogue-mark.change-move { background: #60a5fa; } +.teacher-dialogue-mark.change-change_classroom { background: #a78bfa; } +.teacher-dialogue-mark.change-cancel { background: #fb7185; } + +.teacher-dialogue-copy small { + color: var(--text-secondary); + font-size: .68rem; +} + +.teacher-dialogue-copy h3 { + margin: .22rem 0; + font-size: .92rem; +} + +.teacher-dialogue-copy p { + margin: 0; + color: var(--text-secondary); + font-size: .77rem; +} + +.teacher-dialogue-copy blockquote { + margin: .6rem 0 0; + padding: .5rem .65rem; + border-left: 2px solid #f59e0b; + background: color-mix(in srgb, #f59e0b 6%, transparent); + color: var(--text-primary); + font-size: .76rem; +} + +.teacher-dialogue-review { + display: block; + margin-top: .55rem; + color: #34d399; + font-size: .72rem; +} + +.teacher-dialogue-copy details { + margin-top: .55rem; + color: var(--text-secondary); + font-size: .7rem; +} + +.teacher-dialogue-copy summary { cursor: pointer; } +.teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; } + +.teacher-dialogue-action { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: .55rem; +} + +.teacher-dialogue-action > div { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: .35rem; +} + +.teacher-dialogue-comment { + width: 100%; + min-height: 36px; + padding: 0 .65rem; + border: 1px solid var(--bg-card-border); + border-radius: 8px; + background: var(--bg-input); + color: var(--text-primary); +} + @media (max-width: 1100px) { .absence-command-card, .absence-form-grid { @@ -494,6 +655,10 @@ body.absence-wizard-open { justify-content: flex-start; max-width: none; } + + .teacher-dialogue-grid { + grid-template-columns: 1fr; + } } @media (max-width: 760px) { @@ -553,4 +718,19 @@ body.absence-wizard-open { min-height: 100vh; border-radius: 0; } + + .teacher-dialogue-toolbar, + .teacher-dialogue-head { + align-items: stretch; + flex-direction: column; + } + + .teacher-dialogue-item { + grid-template-columns: 7px minmax(0, 1fr); + } + + .teacher-dialogue-action { + grid-column: 2; + align-items: flex-start; + } } diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 74f05df..6596c90 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -121,7 +121,7 @@ - Отсутствия и замены + Запросы преподавателей diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 3e29351..d5f8733 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -118,7 +118,7 @@ const ROUTES = { } }, 'teacher-absences': { - title: 'Отсутствия и мастер замены', + title: 'Запросы преподавателей', file: '/admin/views/teacher-absences.html', init: async () => { const module = await import('./views/teacher-absences.js'); diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 129ab2c..78d0165 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -124,6 +124,7 @@ export async function initSchedule() { let timeSlots = []; let academicYears = []; let semesters = []; + let approvedTeacherPreferences = []; let selectedVisualYearId = ''; let selectedVisualSemesterId = ''; let selectedVisualGroupIds = new Set(); @@ -137,7 +138,7 @@ export async function initSchedule() { bindEvents(); try { - await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]); + await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]); renderRuleGroups([]); renderRuleSlots([{}]); await loadRules(); @@ -185,6 +186,7 @@ export async function initSchedule() { renderVisualSchedule(); }); ruleSemesterSelect?.addEventListener('change', () => { + updatePreferenceHints(); if (visualPeriodTouched) return; selectDefaultVisualPeriod(); renderVisualSchedule(); @@ -252,6 +254,7 @@ export async function initSchedule() { if (event.target.closest('.slot-subgroup-checkboxes')) { updateSlotSubgroupText(event.target.closest('.schedule-slot-row')); } + updatePreferenceHints(); }); ruleForm.addEventListener('submit', saveRule); rulesTbody.addEventListener('click', handleRuleTableClick); @@ -276,6 +279,11 @@ export async function initSchedule() { selectDefaultVisualPeriod(); } + async function loadApprovedTeacherPreferences() { + approvedTeacherPreferences = await api.get('/api/teacher-preferences?status=APPROVED'); + updatePreferenceHints(); + } + async function loadRules() { rulesTbody.innerHTML = 'Загрузка...'; try { @@ -1383,8 +1391,55 @@ export async function initSchedule() { ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
+
`).join(''); + updatePreferenceHints(); + } + + function updatePreferenceHints() { + const semesterId = Number(ruleSemesterSelect?.value); + Array.from(ruleSlotsContainer?.querySelectorAll('.schedule-slot-row') || []).forEach(row => { + const hint = row.querySelector('[data-preference-hint]'); + if (!hint) return; + const teacherId = Number(row.querySelector('.slot-teacher')?.value); + const dayOfWeek = Number(row.querySelector('.slot-day')?.value); + const timeSlotId = Number(row.querySelector('.slot-time')?.value); + if (!semesterId || !teacherId) { + hint.innerHTML = ''; + return; + } + const teacherItems = approvedTeacherPreferences.filter(item => + Number(item.semesterId) === semesterId && Number(item.teacherId) === teacherId + ); + const exact = teacherItems.filter(item => + Number(item.dayOfWeek) === dayOfWeek && Number(item.timeSlotId) === timeSlotId + ); + const dateCount = teacherItems.filter(item => item.preferenceType === 'HARD_UNAVAILABLE' && item.preferenceDate).length; + const flags = teacherItems.filter(item => ['CONSECUTIVE', 'NO_GAPS'].includes(item.preferenceType)); + const pills = [ + ...exact.map(item => ({ + text: preferenceHintLabel(item.preferenceType), + kind: item.preferenceType === 'HARD_UNAVAILABLE' ? 'hard' + : item.preferenceType === 'SOFT_PREFERRED' ? 'preferred' : 'unwanted' + })), + ...(dateCount ? [{ text: `Недоступных дат: ${dateCount}`, kind: 'dates' }] : []), + ...flags.map(item => ({ text: preferenceHintLabel(item.preferenceType), kind: 'compact' })) + ]; + hint.innerHTML = pills.length + ? `Пожелания преподавателя${pills.map(pill => `${escapeHtml(pill.text)}`).join('')}` + : 'Согласованных пожеланий для слота нет'; + }); + } + + function preferenceHintLabel(type) { + return { + HARD_UNAVAILABLE: 'Строгая недоступность', + SOFT_PREFERRED: 'Предпочтительно', + SOFT_UNWANTED: 'Нежелательно', + CONSECUTIVE: 'Пары подряд', + NO_GAPS: 'Без окон' + }[type] || type; } function readRuleSlots(validate) { diff --git a/frontend/admin/js/views/teacher-absences.js b/frontend/admin/js/views/teacher-absences.js index e121d39..ada911c 100644 --- a/frontend/admin/js/views/teacher-absences.js +++ b/frontend/admin/js/views/teacher-absences.js @@ -17,8 +17,15 @@ export async function initTeacherAbsences() { const registry = document.getElementById('teacher-absence-registry'); const overlay = document.getElementById('absence-wizard-overlay'); const wizardLessons = document.getElementById('absence-wizard-lessons'); + const preferenceSemesterFilter = document.getElementById('teacher-preference-semester-filter'); + const preferenceStatusFilter = document.getElementById('teacher-preference-status-filter'); + const preferenceReviewList = document.getElementById('teacher-preference-review-list'); + const changeStatusFilter = document.getElementById('teacher-change-status-filter'); + const changeReviewList = document.getElementById('teacher-change-review-list'); let absences = []; let currentWizard = null; + let preferenceItems = []; + let changeItems = []; setDefaultPeriod(startInput, endInput); form?.addEventListener('submit', createAbsence); @@ -31,8 +38,15 @@ export async function initTeacherAbsences() { if (event.target === overlay) closeWizard(); }); registry?.addEventListener('click', handleRegistryAction); + preferenceSemesterFilter?.addEventListener('change', loadPreferenceReviews); + preferenceStatusFilter?.addEventListener('change', loadPreferenceReviews); + document.getElementById('teacher-preference-refresh')?.addEventListener('click', loadPreferenceReviews); + preferenceReviewList?.addEventListener('click', handlePreferenceReview); + changeStatusFilter?.addEventListener('change', loadChangeReviews); + document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews); + changeReviewList?.addEventListener('click', handleChangeReview); - await Promise.all([loadTeachers(), loadAbsences()]); + await Promise.all([loadTeachers(), loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]); async function loadTeachers() { try { @@ -249,6 +263,152 @@ export async function initTeacherAbsences() { button.disabled = false; } } + + async function loadPreferenceMeta() { + preferenceReviewList.innerHTML = '
Загрузка семестров...
'; + try { + const meta = await api.get('/api/teacher-preferences/meta'); + preferenceSemesterFilter.innerHTML = '' + (meta.semesters || []).map(item => ` + + `).join(''); + const today = isoDate(new Date()); + const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today); + if (current) preferenceSemesterFilter.value = String(current.id); + await loadPreferenceReviews(); + } catch (error) { + preferenceReviewList.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить семестры')}
`; + } + } + + async function loadPreferenceReviews() { + preferenceReviewList.innerHTML = '
Загрузка пожеланий...
'; + const params = new URLSearchParams(); + if (preferenceSemesterFilter.value) params.set('semesterId', preferenceSemesterFilter.value); + if (preferenceStatusFilter.value) params.set('status', preferenceStatusFilter.value); + try { + preferenceItems = await api.get(`/api/teacher-preferences${params.size ? `?${params}` : ''}`); + document.getElementById('teacher-preference-pending-count').textContent = String( + preferenceItems.filter(item => item.status === 'PENDING').length + ); + renderPreferenceReviews(); + } catch (error) { + preferenceReviewList.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить пожелания')}
`; + } + } + + function renderPreferenceReviews() { + preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => ` +
+ +
+ ${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')} +

${escapeHtml(item.teacherName)}

+

${escapeHtml(preferenceDescription(item))}

+ ${item.comment ? `
${escapeHtml(item.comment)}
` : ''} + ${item.reviewComment ? `Решение: ${escapeHtml(item.reviewComment)}` : ''} +
+
+ ${escapeHtml(genericStatusLabel(item.status))} + ${item.status === 'PENDING' ? ` + +
+ + +
+ ` : ''} +
+
+ `).join('') : '
Пожеланий не найденоИзмените фильтры или дождитесь новых отметок преподавателей.
'; + } + + async function handlePreferenceReview(event) { + const button = event.target.closest('[data-preference-review][data-id]'); + if (!button) return; + const card = button.closest('.teacher-dialogue-item'); + const approved = button.dataset.preferenceReview === 'approve'; + const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || ''; + if (!approved && !comment) { + window.alert('При отклонении укажите комментарий'); + return; + } + button.disabled = true; + try { + await api.post(`/api/teacher-preferences/${button.dataset.id}/review`, { + approved, + comment: comment || 'Согласовано ответственным сотрудником' + }); + await loadPreferenceReviews(); + } catch (error) { + window.alert(error.message || 'Не удалось рассмотреть пожелание'); + button.disabled = false; + } + } + + async function loadChangeReviews() { + changeReviewList.innerHTML = '
Загрузка заявок...
'; + const params = changeStatusFilter.value ? `?status=${encodeURIComponent(changeStatusFilter.value)}` : ''; + try { + changeItems = await api.get(`/api/teacher-change-requests${params}`); + document.getElementById('teacher-change-pending-count').textContent = String( + changeItems.filter(item => item.status === 'PENDING').length + ); + renderChangeReviews(); + } catch (error) { + changeReviewList.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить заявки')}
`; + } + } + + function renderChangeReviews() { + const canApply = role === 'ADMIN' || role === 'EDUCATION_OFFICE'; + changeReviewList.innerHTML = changeItems.length ? changeItems.map(item => { + const target = item.requestType === 'MOVE' + ? `${formatDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}` + : item.requestType === 'CHANGE_CLASSROOM' ? item.requestedClassroomName : 'Отмена занятия'; + return `
+ +
+ ${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)} +

${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}

+

${escapeHtml(changeTypeLabel(item.requestType))} → ${escapeHtml(target)}

+
${escapeHtml(item.reason)}
+ ${item.reviewComment ? `Решение: ${escapeHtml(item.reviewComment)}` : ''} +
История · ${(item.history || []).length} +
    ${(item.history || []).map(entry => `
  1. ${genericStatusLabel(entry.status)} · ${escapeHtml(entry.comment || 'Без комментария')}
  2. `).join('')}
+
+
+
+ ${escapeHtml(genericStatusLabel(item.status))} + ${item.status === 'PENDING' && canApply ? ` + +
+ + +
+ ` : item.status === 'PENDING' ? 'Применение доступно учебному отделу' : ''} +
+
`; + }).join('') : '
Заявок не найденоОчередь изменений сейчас пуста.
'; + } + + async function handleChangeReview(event) { + const button = event.target.closest('[data-change-review][data-id]'); + if (!button) return; + const card = button.closest('.teacher-dialogue-item'); + const approved = button.dataset.changeReview === 'approve'; + const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || ''; + if (!approved && !comment) { + window.alert('При отклонении укажите комментарий'); + return; + } + button.disabled = true; + try { + await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment }); + await Promise.all([loadChangeReviews(), loadAbsences()]); + } catch (error) { + window.alert(error.message || 'Не удалось применить решение'); + button.disabled = false; + } + } } export function decisionOptions(lesson) { @@ -343,6 +503,41 @@ function statusClass(status) { return `is-${String(status || '').toLowerCase()}`; } +function genericStatusLabel(status) { + return { + PENDING: 'На рассмотрении', + APPROVED: 'Согласовано', + REJECTED: 'Отклонено', + CANCELLED: 'Отменено' + }[status] || status || '—'; +} + +function semesterLabel(semester) { + const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType; + return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}–${formatDate(semester.endDate)}`; +} + +function preferenceDescription(item) { + const type = { + HARD_UNAVAILABLE: 'Строгая недоступность', + SOFT_PREFERRED: 'Предпочтительное время', + SOFT_UNWANTED: 'Нежелательное время', + CONSECUTIVE: 'Пожелание «пары подряд»', + NO_GAPS: 'Пожелание «без окон»' + }[item.preferenceType] || item.preferenceType; + if (item.preferenceDate) return `${type}: ${formatDate(item.preferenceDate)}`; + if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`; + return type; +} + +function changeTypeLabel(type) { + return { + MOVE: 'Перенос даты или времени', + CHANGE_CLASSROOM: 'Смена аудитории', + CANCEL: 'Отмена занятия' + }[type] || type || 'Изменение'; +} + function resolutionLabel(resolution) { return { REPLACE_TEACHER: 'Замена преподавателя', diff --git a/frontend/admin/views/teacher-absences.html b/frontend/admin/views/teacher-absences.html index 91e8bb8..a698972 100644 --- a/frontend/admin/views/teacher-absences.html +++ b/frontend/admin/views/teacher-absences.html @@ -69,6 +69,50 @@ +
+
+
+
+ 03 +

Пожелания на семестр

+

Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.

+
+
0ожидают
+
+
+ + + +
+
+
+ +
+
+
+ 04 +

Заявки на изменение

+

Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.

+
+
0в очереди
+
+
+ + +
+
+
+
+ + ${lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId ? ` + + ` : 'Занятие уже изменено учебным отделом'} `; } + function lessonKey(lesson) { + return `${lesson.scheduleRuleSlotId}:${lesson.date}`; + } + function collectSlots(lessons) { const slots = new Map(); lessons.forEach(lesson => { @@ -335,6 +736,59 @@ import { return `is-${String(status || '').toLowerCase()}`; } + function selectedSemester() { + return (preferenceMeta.semesters || []).find(item => String(item.id) === String(preferenceSemester.value)); + } + + function semesterLabel(semester) { + const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType; + return `${semester.academicYearTitle} · ${type} · ${formatIsoDate(semester.startDate)}–${formatIsoDate(semester.endDate)}`; + } + + function preferenceTypeLabel(type) { + return { + HARD_UNAVAILABLE: 'Строго недоступен', + SOFT_PREFERRED: 'Предпочтительно', + SOFT_UNWANTED: 'Нежелательно', + CONSECUTIVE: 'Пары подряд', + NO_GAPS: 'Без окон' + }[type] || type || '—'; + } + + function preferenceTypeClass(type) { + return { + HARD_UNAVAILABLE: 'is-hard', + SOFT_PREFERRED: 'is-preferred', + SOFT_UNWANTED: 'is-unwanted' + }[type] || ''; + } + + function preferenceTypeSymbol(type) { + return { HARD_UNAVAILABLE: '×', SOFT_PREFERRED: '✓', SOFT_UNWANTED: '!' }[type] || '•'; + } + + function preferenceStatusLabel(status) { + return { + PENDING: 'На рассмотрении', + APPROVED: 'Согласовано', + REJECTED: 'Отклонено', + CANCELLED: 'Отменено' + }[status] || status || '—'; + } + + function changeTypeLabel(type) { + return { + MOVE: 'Перенос', + CHANGE_CLASSROOM: 'Смена аудитории', + CANCEL: 'Отмена' + }[type] || type || '—'; + } + + function formatInstant(value) { + if (!value) return '—'; + return new Date(value).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }); + } + function dayName(date) { const value = date.toLocaleDateString('ru-RU', { weekday: 'long' }); return value.charAt(0).toUpperCase() + value.slice(1); diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index 8f8d2ca..000118e 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -3,14 +3,14 @@ - Расписание преподавателя + Кабинет преподавателя
-

Расписание преподавателя

+

Кабинет преподавателя

Загрузка периода...
@@ -23,6 +23,15 @@
+ + +
+
+ + + +
+ + diff --git a/frontend/teacher/style.css b/frontend/teacher/style.css index adebf31..15b2cde 100644 --- a/frontend/teacher/style.css +++ b/frontend/teacher/style.css @@ -185,6 +185,8 @@ --green: #047857; --orange: #b45309; --shadow: 0 12px 30px rgba(23, 32, 51, 0.08); + --card: var(--panel); + --border: var(--panel-border); } [data-theme="dark"] { @@ -419,6 +421,456 @@ body { background: var(--panel); } +.btn-sm { + min-height: 32px; + padding: 0 10px; + font-size: 12px; + font-weight: 700; +} + +.workspace-nav { + display: inline-flex; + gap: 4px; + margin: 0 0 18px; + padding: 5px; + border: 1px solid var(--panel-border); + border-radius: 12px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.workspace-tab { + min-height: 38px; + padding: 0 15px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.workspace-tab span { + display: inline-grid; + min-width: 20px; + min-height: 20px; + margin-left: 5px; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + font-size: 11px; +} + +.workspace-tab.active { + background: var(--text); + color: var(--panel); +} + +.workspace-view[hidden], +.request-dialog-overlay[hidden] { + display: none; +} + +.workspace-view.active { + animation: workspace-in 0.28s ease both; +} + +@keyframes workspace-in { + from { opacity: 0; transform: translateY(7px); } + to { opacity: 1; transform: translateY(0); } +} + +.preference-hero, +.request-ledger-head { + position: relative; + display: flex; + align-items: end; + justify-content: space-between; + gap: 30px; + margin-bottom: 14px; + padding: 26px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--panel-border)); + border-radius: 18px; + background: + linear-gradient(120deg, color-mix(in srgb, var(--accent) 13%, transparent), transparent 58%), + var(--panel); + box-shadow: var(--shadow); +} + +.preference-hero::after { + position: absolute; + top: -90px; + right: 18%; + width: 180px; + height: 180px; + border: 1px solid color-mix(in srgb, var(--accent) 23%, transparent); + border-radius: 50%; + content: ''; +} + +.preference-kicker, +.request-ledger-head > div > span { + color: var(--accent-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.preference-hero h2, +.request-ledger-head h2 { + max-width: 720px; + margin: 7px 0; + font-size: clamp(24px, 4vw, 42px); + line-height: 1.02; + letter-spacing: -0.035em; +} + +.preference-hero p, +.request-ledger-head p { + max-width: 760px; + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.preference-semester-field, +.preference-board-head label, +.request-dialog label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.preference-semester-field { + position: relative; + z-index: 1; + min-width: 280px; +} + +.preference-semester-field select, +.preference-board-head select, +.preference-date-form input, +.request-dialog select, +.request-dialog textarea { + min-height: 42px; + padding: 0 12px; + border: 1px solid var(--panel-border); + border-radius: 9px; + background: var(--panel); + color: var(--text); + font: inherit; +} + +.preference-board, +.preference-card { + border: 1px solid var(--panel-border); + border-radius: 16px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.preference-board { + padding: 20px; +} + +.preference-board-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; +} + +.preference-board-head > div > span, +.preference-card-number { + color: var(--accent-strong); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.12em; +} + +.preference-board-head h3, +.preference-card h3 { + margin: 5px 0 0; + font-size: 19px; +} + +.preference-legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0 10px; +} + +.preference-legend span { + padding: 4px 9px; + border: 1px solid var(--panel-border); + border-radius: 999px; + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.preference-legend .is-preferred { border-color: #10b981; color: #047857; } +.preference-legend .is-unwanted { border-color: #f59e0b; color: #b45309; } +.preference-legend .is-hard { border-color: #f43f5e; color: #be123c; } +.preference-legend .is-pending { border-style: dashed; } + +.preference-grid-wrap { + overflow-x: auto; + padding-bottom: 4px; +} + +.preference-grid { + display: grid; + min-width: 780px; + grid-template-columns: 110px repeat(7, minmax(78px, 1fr)); + gap: 6px; +} + +.preference-grid-corner, +.preference-grid-day, +.preference-grid-time, +.preference-cell { + min-height: 48px; + border: 1px solid var(--panel-border); + border-radius: 9px; +} + +.preference-grid-corner, +.preference-grid-day { + display: grid; + place-items: center; + background: color-mix(in srgb, var(--panel-border) 22%, transparent); + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.preference-grid-time { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; +} + +.preference-grid-time strong { + display: grid; + width: 25px; + height: 25px; + place-items: center; + border-radius: 7px; + background: var(--text); + color: var(--panel); + font-size: 12px; +} + +.preference-grid-time span { + color: var(--muted); + font-size: 11px; +} + +.preference-cell { + position: relative; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, background .16s ease; +} + +.preference-cell:hover { transform: translateY(-2px); border-color: var(--accent); } +.preference-cell span { font-size: 19px; font-weight: 900; } +.preference-cell.is-preferred { border-color: #10b981; background: color-mix(in srgb, #10b981 15%, transparent); color: #047857; } +.preference-cell.is-unwanted { border-color: #f59e0b; background: color-mix(in srgb, #f59e0b 15%, transparent); color: #b45309; } +.preference-cell.is-hard { border-color: #f43f5e; background: color-mix(in srgb, #f43f5e 14%, transparent); color: #be123c; } +.preference-cell.is-pending::after { + position: absolute; + inset: 3px; + border: 1px dashed currentColor; + border-radius: 6px; + content: ''; + opacity: .7; +} + +.preference-message, +.request-dialog-message { + min-height: 20px; + margin-top: 10px; + font-size: 13px; +} + +.preference-message.success { color: #059669; } +.preference-message.error, +.request-dialog-message.error { color: #dc2626; } + +.preference-lower-grid { + display: grid; + grid-template-columns: 1.35fr .65fr; + gap: 14px; + margin-top: 14px; +} + +.preference-card { + padding: 20px; +} + +.preference-card p { + margin: 7px 0 16px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.preference-date-form { + display: grid; + grid-template-columns: 170px 1fr auto; + gap: 8px; +} + +.preference-date-list { + display: grid; + gap: 7px; + margin-top: 12px; +} + +.preference-date-list article { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 9px; + align-items: center; + padding: 9px; + border: 1px solid var(--panel-border); + border-radius: 10px; +} + +.preference-date-list small { display: block; margin-top: 2px; color: var(--muted); } +.preference-date-mark { width: 7px; height: 34px; border-radius: 9px; background: #f59e0b; } +.preference-date-mark.approved { background: #f43f5e; } +.preference-status { color: var(--muted); font-size: 11px; font-weight: 800; } +.preference-empty { padding: 14px; color: var(--muted); text-align: center; font-size: 13px; } + +.preference-toggle-card { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + align-items: start; + margin-top: 9px; + padding: 12px; + border: 1px solid var(--panel-border); + border-radius: 11px; + cursor: pointer; +} + +.preference-toggle-card.approved { border-color: #10b981; background: color-mix(in srgb, #10b981 8%, transparent); } +.preference-toggle-card input { margin-top: 3px; accent-color: var(--accent); } +.preference-toggle-card strong, +.preference-toggle-card small { display: block; } +.preference-toggle-card small { margin-top: 3px; color: var(--muted); line-height: 1.4; } + +.lesson-request-button { + width: 100%; + margin-top: 9px; + padding: 6px 8px; + border: 1px solid color-mix(in srgb, var(--green) 35%, var(--panel-border)); + border-radius: 7px; + background: var(--panel); + color: var(--green); + font: inherit; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} + +.lesson-change-note { display: block; margin-top: 8px; color: var(--muted); font-size: 10px; } + +.request-ledger-head h2 { font-size: clamp(24px, 3vw, 34px); } +.request-ledger { display: grid; gap: 10px; } +.request-empty { padding: 28px; border: 1px dashed var(--panel-border); border-radius: 14px; color: var(--muted); text-align: center; } + +.request-ledger-item { + display: grid; + grid-template-columns: 7px minmax(0, 1fr) auto; + gap: 17px; + padding: 17px; + border: 1px solid var(--panel-border); + border-radius: 15px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.request-ledger-stripe { border-radius: 99px; background: #94a3b8; } +.request-ledger-stripe.pending { background: #f59e0b; } +.request-ledger-stripe.approved { background: #10b981; } +.request-ledger-stripe.rejected { background: #f43f5e; } +.request-ledger-main > span { color: var(--muted); font-size: 12px; } +.request-ledger-main h3 { margin: 4px 0; } +.request-ledger-main p { margin: 3px 0; color: var(--muted); font-size: 13px; } +.request-change-line { display: flex; align-items: center; gap: 9px; margin: 12px 0; font-size: 13px; } +.request-change-line b { color: var(--accent-strong); } +.request-change-line i { color: var(--muted); font-style: normal; } +.request-ledger-main .request-reason { color: var(--text); } +.request-ledger-main .request-review-comment { color: #047857; } +.request-ledger-main details { margin-top: 10px; } +.request-ledger-main summary { color: var(--muted); font-size: 12px; cursor: pointer; } +.request-ledger-main ol { display: grid; gap: 5px; padding-left: 20px; } +.request-ledger-main li { padding-left: 5px; font-size: 12px; } +.request-ledger-main li time, +.request-ledger-main li strong, +.request-ledger-main li span { margin-right: 8px; } +.request-ledger-main li time { color: var(--muted); } +.request-ledger-side { display: flex; flex-direction: column; align-items: end; gap: 9px; } +.request-status { padding: 5px 9px; border: 1px solid currentColor; border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 800; } +.request-status.pending { color: #b45309; } +.request-status.approved { color: #047857; } +.request-status.rejected { color: #be123c; } + +.request-dialog-overlay { + position: fixed; + z-index: 1000; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(7, 12, 22, .68); + backdrop-filter: blur(7px); +} + +.dialog-open { overflow: hidden; } +.request-dialog { + width: min(760px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + border: 1px solid var(--panel-border); + border-radius: 18px; + background: var(--panel); + box-shadow: 0 35px 90px rgba(0, 0, 0, .35); +} + +.request-dialog header { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 20px; + border-bottom: 1px solid var(--panel-border); + background: linear-gradient(115deg, color-mix(in srgb, var(--accent) 12%, transparent), transparent); +} + +.request-dialog header span { color: var(--accent-strong); font-size: 11px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; } +.request-dialog header h2 { margin: 5px 0; } +.request-dialog header p { margin: 0; color: var(--muted); font-size: 13px; } +.request-dialog form { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; padding: 20px; } +.request-dialog [hidden] { display: none; } +.request-dialog textarea { min-height: 100px; padding-top: 10px; resize: vertical; } +.request-reason-field, +.request-dialog-message, +.request-dialog footer { grid-column: 1 / -1; } +.request-dialog footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 5px; } + .empty { color: var(--muted); font-size: 13px; @@ -471,5 +923,26 @@ body { .week-nav .btn { flex: 1; } + + .workspace-nav, + .preference-hero, + .preference-board-head, + .request-ledger-head { + display: grid; + width: 100%; + } + + .workspace-tab { text-align: left; } + .preference-semester-field { min-width: 0; } + .preference-lower-grid, + .preference-date-form, + .request-dialog form { grid-template-columns: 1fr; } + .preference-date-form .btn, + .request-reason-field, + .request-dialog-message, + .request-dialog footer { grid-column: auto; } + .request-ledger-item { grid-template-columns: 6px minmax(0, 1fr); } + .request-ledger-side { grid-column: 2; align-items: start; } + .request-dialog footer { flex-direction: column-reverse; } } diff --git a/frontend/tests/teacher-preferences.test.mjs b/frontend/tests/teacher-preferences.test.mjs new file mode 100644 index 0000000..fe5269b --- /dev/null +++ b/frontend/tests/teacher-preferences.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +test('кабинет преподавателя содержит календарь пожеланий и журнал заявок', async () => { + const [index, app] = await Promise.all([ + readFile(new URL('../teacher/index.html', import.meta.url), 'utf8'), + readFile(new URL('../teacher/app.js', import.meta.url), 'utf8') + ]); + + assert.match(index, /data-workspace-tab="preferences"/); + assert.match(index, /data-workspace-tab="requests"/); + assert.match(index, /id="preference-grid"/); + assert.match(index, /id="change-request-form"/); + assert.match(app, /\/api\/teacher-preferences\/meta/); + assert.match(app, /\/api\/teacher-change-requests\/candidates/); + assert.match(app, /MOVE:[\s\S]*CHANGE_CLASSROOM:[\s\S]*CANCEL:/); +}); + +test('ответственные роли видят согласование, а применять изменение может учебный отдел', async () => { + const [view, script] = await Promise.all([ + readFile(new URL('../admin/views/teacher-absences.html', import.meta.url), 'utf8'), + readFile(new URL('../admin/js/views/teacher-absences.js', import.meta.url), 'utf8') + ]); + + assert.match(view, /id="teacher-preference-review-list"/); + assert.match(view, /id="teacher-change-review-list"/); + assert.match(script, /\/api\/teacher-preferences/); + assert.match(script, /\/api\/teacher-change-requests/); + assert.match(script, /role === 'ADMIN' \|\| role === 'EDUCATION_OFFICE'/); + assert.match(script, /Применение доступно учебному отделу/); +}); + +test('конструктор правил показывает только согласованные пожелания преподавателя', async () => { + const [script, styles] = await Promise.all([ + readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/css/components.css', import.meta.url), 'utf8') + ]); + + assert.match(script, /\/api\/teacher-preferences\?status=APPROVED/); + assert.match(script, /data-preference-hint/); + assert.match(script, /Строгая недоступность/); + assert.match(styles, /\.schedule-preference-hint/); +});