3 Задача
This commit is contained in:
@@ -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<TeacherChangeRequestDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TeacherPreferenceDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LocalDate> availableDates,
|
||||||
|
LocalDate checkedDate,
|
||||||
|
List<CandidateOption> timeSlots,
|
||||||
|
List<CandidateOption> classrooms
|
||||||
|
) {
|
||||||
|
public record CandidateOption(
|
||||||
|
Long id,
|
||||||
|
String label,
|
||||||
|
boolean available,
|
||||||
|
String conflictMessage
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<HistoryItem> history
|
||||||
|
) {
|
||||||
|
public record HistoryItem(
|
||||||
|
Long id,
|
||||||
|
String status,
|
||||||
|
String comment,
|
||||||
|
Long actorId,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public record TeacherChangeReviewRequest(Boolean approved, String comment) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record TeacherPreferenceMetaDto(
|
||||||
|
List<SemesterDto> semesters,
|
||||||
|
List<TimeSlotDto> timeSlots
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public record TeacherPreferenceReviewRequest(Boolean approved, String comment) {
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<TeacherChangeRequestHistory, Long> {
|
||||||
|
List<TeacherChangeRequestHistory> findByRequestIdOrderByCreatedAtAscIdAsc(Long requestId);
|
||||||
|
}
|
||||||
@@ -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<TeacherChangeRequest, Long> {
|
||||||
|
|
||||||
|
@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<TeacherChangeRequest> 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<TeacherChangeRequest> findByIdForUpdate(@Param("id") Long id);
|
||||||
|
|
||||||
|
boolean existsByBaseRuleSlotIdAndLessonDateAndStatus(
|
||||||
|
Long baseRuleSlotId,
|
||||||
|
LocalDate lessonDate,
|
||||||
|
String status
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<TeacherPreference, Long> {
|
||||||
|
|
||||||
|
@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<TeacherPreference> 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<TeacherPreference> 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<TeacherPreference> 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<TeacherPreference> 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<String> statuses
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ public class ScheduleOverrideService {
|
|||||||
private final PostgreSqlAdvisoryLock advisoryLock;
|
private final PostgreSqlAdvisoryLock advisoryLock;
|
||||||
private final AcademicDateService academicDateService;
|
private final AcademicDateService academicDateService;
|
||||||
private final EffectiveTimeSlotService effectiveTimeSlotService;
|
private final EffectiveTimeSlotService effectiveTimeSlotService;
|
||||||
|
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
|
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
|
||||||
@@ -106,6 +107,11 @@ public class ScheduleOverrideService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
|
||||||
|
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
|
public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
|
||||||
if ((startDate == null) != (endDate == null)) {
|
if ((startDate == null) != (endDate == null)) {
|
||||||
@@ -331,6 +337,7 @@ public class ScheduleOverrideService {
|
|||||||
.map(EffectiveSourceTuple::from)
|
.map(EffectiveSourceTuple::from)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
|
validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
|
||||||
|
validateTeacherAvailability(candidate, candidateOccurrences);
|
||||||
if (sourceTuples.equals(candidateTuples)) {
|
if (sourceTuples.equals(candidateTuples)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
|
"Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
|
||||||
@@ -345,6 +352,27 @@ public class ScheduleOverrideService {
|
|||||||
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
|
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateTeacherAvailability(
|
||||||
|
ScheduleOverride candidate,
|
||||||
|
List<RenderedLessonDto> 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) {
|
private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) {
|
||||||
return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
|
return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
|
||||||
&& (Objects.equals(lesson.originalLessonDate(), command.lessonDate())
|
&& (Objects.equals(lesson.originalLessonDate(), command.lessonDate())
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import com.magistr.app.repository.SubjectRepository;
|
|||||||
import com.magistr.app.repository.SubgroupRepository;
|
import com.magistr.app.repository.SubgroupRepository;
|
||||||
import com.magistr.app.repository.TimeSlotRepository;
|
import com.magistr.app.repository.TimeSlotRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ public class ScheduleRuleService {
|
|||||||
private final LessonTypesRepository lessonTypesRepository;
|
private final LessonTypesRepository lessonTypesRepository;
|
||||||
private final SubgroupRepository subgroupRepository;
|
private final SubgroupRepository subgroupRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
|
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
||||||
|
|
||||||
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
|
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
|
||||||
SubjectRepository subjectRepository,
|
SubjectRepository subjectRepository,
|
||||||
@@ -93,6 +95,11 @@ public class ScheduleRuleService {
|
|||||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
|
||||||
|
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||||
@@ -424,6 +431,21 @@ public class ScheduleRuleService {
|
|||||||
List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
|
List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
|
||||||
validateExactDuplicates(slots);
|
validateExactDuplicates(slots);
|
||||||
validateInternalConflicts(slots);
|
validateInternalConflicts(slots);
|
||||||
|
validateTeacherAvailability(candidate, slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateTeacherAvailability(ScheduleRule candidate, List<ScheduleRuleSlot> slots) {
|
||||||
|
if (teacherAvailabilityPolicyService == null) return;
|
||||||
|
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = activeWeeksBySlot(slots);
|
||||||
|
for (ScheduleRuleSlot slot : slots) {
|
||||||
|
teacherAvailabilityPolicyService.assertRuleSlotAvailable(
|
||||||
|
teacherId(slot),
|
||||||
|
candidate.getSemester(),
|
||||||
|
slot.getDayOfWeek(),
|
||||||
|
timeSlotId(slot),
|
||||||
|
activeWeeks.get(slot)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {
|
private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {
|
||||||
|
|||||||
@@ -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<TeacherPreference> 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<Integer> activeWeeks
|
||||||
|
) {
|
||||||
|
if (teacherId == null || semester == null || dayOfWeek == null || timeSlotId == null) return;
|
||||||
|
TimeSlot candidateSlot = timeSlotRepository.findById(timeSlotId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||||
|
List<TeacherPreference> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> TYPES = Set.of("MOVE", "CHANGE_CLASSROOM", "CANCEL");
|
||||||
|
private static final Set<String> 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<TeacherChangeRequestDto> 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<TeacherChangeCandidatesDto.CandidateOption> 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<TeacherChangeCandidatesDto.CandidateOption> 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<TeacherChangeRequestDto.HistoryItem> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> TYPES = Set.of(
|
||||||
|
"HARD_UNAVAILABLE", "SOFT_PREFERRED", "SOFT_UNWANTED", "CONSECUTIVE", "NO_GAPS"
|
||||||
|
);
|
||||||
|
private static final Set<String> STATUSES = Set.of("PENDING", "APPROVED", "REJECTED", "CANCELLED");
|
||||||
|
private static final Set<String> 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<SemesterDto> 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<TimeSlotDto> 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<TeacherPreferenceDto> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 'Неизменяемая история статусов и комментариев по заявке преподавателя';
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
docs/API.md
78
docs/API.md
@@ -912,6 +912,84 @@ API возвращает `409 Conflict`; соседние интервалы и
|
|||||||
выбранного действия возвращает `409` и откатывает остальные. Незаполненные занятия не
|
выбранного действия возвращает `409` и откатывает остальные. Незаполненные занятия не
|
||||||
меняются. Когда обработаны все оставшиеся занятия, отсутствие получает статус `RESOLVED`.
|
меняются. Когда обработаны все оставшиеся занятия, отсутствие получает статус `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 | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|
|||||||
@@ -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`:
|
Связь Many-to-Many через таблицу `teacher_subjects`:
|
||||||
|
|||||||
123
docs/DATABASE.md
123
docs/DATABASE.md
@@ -362,6 +362,50 @@ erDiagram
|
|||||||
TIMESTAMPTZ decided_at
|
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 {
|
schedule_rule_slot_subgroups {
|
||||||
BIGINT schedule_rule_slot_id FK,PK
|
BIGINT schedule_rule_slot_id FK,PK
|
||||||
BIGINT subgroup_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_rule_slots ||--o{ teacher_absence_decisions : "base_rule_slot_id"
|
||||||
schedule_overrides ||--o{ teacher_absence_decisions : "schedule_override_id"
|
schedule_overrides ||--o{ teacher_absence_decisions : "schedule_override_id"
|
||||||
users ||--o{ teacher_absence_decisions : "decided_by"
|
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_slots : "time_slot_scope_id"
|
||||||
time_slot_scopes ||--o{ time_slot_date_assignments : "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"
|
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
||||||
@@ -976,6 +1030,69 @@ CHECK запрещает обратный период, пустую или сл
|
|||||||
одно занятие одного инцидента. При удалении override ссылка обнуляется, но аудиторская запись
|
одно занятие одного инцидента. При удалении 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 миграции
|
## Flyway миграции
|
||||||
@@ -996,12 +1113,14 @@ CHECK запрещает обратный период, пустую или сл
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||||
| `V2__teacher_absences_and_replacement_wizard.sql` | Реестр отсутствий преподавателей, статусы согласования и журнал применённых/отклонённых решений со ссылками на обычные `schedule_overrides` |
|
| `V2__teacher_absences_and_replacement_wizard.sql` | Реестр отсутствий преподавателей, статусы согласования и журнал применённых/отклонённых решений со ссылками на обычные `schedule_overrides` |
|
||||||
|
| `V3__teacher_preferences_and_change_requests.sql` | Пожелания преподавателей на семестр, строгая и мягкая доступность, заявки на изменение занятия и неизменяемая история решений |
|
||||||
|
|
||||||
### Этап разработки
|
### Этап разработки
|
||||||
|
|
||||||
Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были
|
Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были
|
||||||
объединены в baseline `V1`. После фиксации baseline нумерация начата заново: текущая `V2`
|
объединены в baseline `V1`. После фиксации baseline нумерация начата заново: `V2`
|
||||||
добавляет отсутствия и мастер замены, не изменяя контрольную сумму `V1`.
|
добавляет отсутствия и мастер замены, а `V3` — пожелания преподавателей и заявки на
|
||||||
|
изменение занятий, не изменяя контрольную сумму `V1`.
|
||||||
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
||||||
непосредственно в V1.
|
непосредственно в V1.
|
||||||
Перед применением этой редакции требуется полностью пустая tenant-схема.
|
Перед применением этой редакции требуется полностью пустая tenant-схема.
|
||||||
|
|||||||
@@ -162,6 +162,11 @@
|
|||||||
|
|
||||||
## 3. Пожелания преподавателей и заявки на перенос
|
## 3. Пожелания преподавателей и заявки на перенос
|
||||||
|
|
||||||
|
**Статус: реализовано в MVP.** Добавлены согласуемый календарь доступности, строгие и мягкие
|
||||||
|
пожелания, требования «пары подряд» и «без окон», заявки на перенос, смену аудитории и отмену
|
||||||
|
с предварительной проверкой конфликтов, историей решений и применением через
|
||||||
|
`schedule_overrides`.
|
||||||
|
|
||||||
### Проблема и пользователи
|
### Проблема и пользователи
|
||||||
|
|
||||||
Кабинет преподавателя сейчас предназначен только для просмотра недельного расписания.
|
Кабинет преподавателя сейчас предназначен только для просмотра недельного расписания.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ frontend/
|
|||||||
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
||||||
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
||||||
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
|
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
|
||||||
|
│ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора
|
||||||
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
||||||
├── index.html # 🔐 Страница авторизации (общая)
|
├── index.html # 🔐 Страница авторизации (общая)
|
||||||
├── script.js # Логика авторизации
|
├── script.js # Логика авторизации
|
||||||
@@ -51,7 +52,7 @@ frontend/
|
|||||||
│ │ ├── modals.css # Модальные окна
|
│ │ ├── modals.css # Модальные окна
|
||||||
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
|
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
|
||||||
│ │ ├── departments-data.css # Стили создания кафедры/специальности
|
│ │ ├── departments-data.css # Стили создания кафедры/специальности
|
||||||
│ │ └── teacher-absences.css # Реестр инцидентов и полноэкранный мастер замены
|
│ │ └── teacher-absences.css # Отсутствия, пожелания и очередь заявок преподавателей
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
||||||
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
||||||
@@ -70,8 +71,8 @@ frontend/
|
|||||||
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
|
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
|
||||||
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
||||||
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
||||||
│ │ ├── teacher-absences.js # Реестр, согласование и групповой мастер замены
|
│ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение
|
||||||
│ │ ├── schedule.js # Конструктор правил расписания
|
│ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей
|
||||||
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
|
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
|
||||||
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
|
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
|
||||||
│ │ ├── academic-calendar.js # Календарные учебные графики
|
│ │ ├── academic-calendar.js # Календарные учебные графики
|
||||||
@@ -105,9 +106,9 @@ frontend/
|
|||||||
│ └── time-slots.html # Базовая, субботняя и ручные сетки времени
|
│ └── time-slots.html # Базовая, субботняя и ручные сетки времени
|
||||||
│
|
│
|
||||||
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
||||||
│ ├── index.html # CSP-совместимая HTML-оболочка
|
│ ├── index.html # Расписание, календарь доступности и журнал заявок
|
||||||
│ ├── app.js # Недельный просмотр и общий auth-session
|
│ ├── app.js # Пожелания, переносы и недельный просмотр через общий auth-session
|
||||||
│ └── style.css # Стили кабинета без inline-блока
|
│ └── style.css # Адаптивный кабинет в стиле диспетчерского журнала
|
||||||
├── department/ # 🏛 Кабинет кафедры
|
├── department/ # 🏛 Кабинет кафедры
|
||||||
│ └── index.html # Redirect в `/admin/#department-workspace`
|
│ └── index.html # Redirect в `/admin/#department-workspace`
|
||||||
├── edu-office/ # 🗓 Кабинет учебного отдела
|
├── edu-office/ # 🗓 Кабинет учебного отдела
|
||||||
@@ -170,8 +171,8 @@ frontend/
|
|||||||
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
|
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
|
||||||
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
|
| `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` |
|
| `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` |
|
| `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers` |
|
||||||
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
|
| `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` |
|
| `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` |
|
| `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`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
||||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||||
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
|
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
|
||||||
- Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются.
|
- Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются.
|
||||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||||
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
- Вкладка `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 `ГГГГ-ММ-ДД`.
|
- Вкладка `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`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
|
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
|
||||||
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||||
@@ -327,14 +333,23 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
|
|||||||
|
|
||||||
### Преподаватель (`/teacher/`)
|
### Преподаватель (`/teacher/`)
|
||||||
|
|
||||||
Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из
|
Страница объединяет недельную сетку занятий, семестровый календарь доступности и журнал
|
||||||
восстановленного в памяти профиля сессии.
|
заявок на изменение пар. ID преподавателя берётся из восстановленного в памяти профиля
|
||||||
|
сессии.
|
||||||
|
|
||||||
Основные элементы:
|
Основные элементы:
|
||||||
- навигация по неделям: предыдущая, текущая, следующая;
|
- навигация по неделям: предыдущая, текущая, следующая;
|
||||||
- выбор даты через `input[type="date"]`;
|
- выбор даты через `input[type="date"]`;
|
||||||
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||||
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
|
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила;
|
||||||
|
- вкладка «Мои пожелания» с выбором семестра, режимом отметки строгих, предпочтительных и
|
||||||
|
нежелательных интервалов, полностью недоступных дат, пар подряд и расписания без окон;
|
||||||
|
- цветовые статусы `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED`; ожидающее пожелание можно
|
||||||
|
отозвать кликом по отмеченной ячейке или кнопкой в списке дат;
|
||||||
|
- кнопка «Заявка на изменение» в карточке фактического занятия и модальное окно переноса,
|
||||||
|
смены аудитории или отмены. Недоступные кандидаты отфильтрованы после проверки backend;
|
||||||
|
- вкладка журнала заявок с причиной, решением, ссылкой на применённый override и историей
|
||||||
|
статусов; ожидающую заявку можно отозвать;
|
||||||
- форма собственной заявки на отсутствие через `POST /api/teacher-absences`;
|
- форма собственной заявки на отсутствие через `POST /api/teacher-absences`;
|
||||||
- список статусов заявок и отмена ещё не подтверждённой записи через
|
- список статусов заявок и отмена ещё не подтверждённой записи через
|
||||||
`DELETE /api/teacher-absences/{id}`.
|
`DELETE /api/teacher-absences/{id}`.
|
||||||
|
|||||||
@@ -759,6 +759,37 @@
|
|||||||
margin-bottom: 0;
|
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 {
|
.schedule-slot-row.has-subgroup {
|
||||||
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,6 +470,167 @@ body.absence-wizard-open {
|
|||||||
gap: 0.65rem;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
.absence-command-card,
|
.absence-command-card,
|
||||||
.absence-form-grid {
|
.absence-form-grid {
|
||||||
@@ -494,6 +655,10 @@ body.absence-wizard-open {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-dialogue-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
@@ -553,4 +718,19 @@ body.absence-wizard-open {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
border-radius: 0;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
<path d="M18 8l3 3" />
|
<path d="M18 8l3 3" />
|
||||||
<path d="M21 8l-3 3" />
|
<path d="M21 8l-3 3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Отсутствия и замены</span>
|
<span>Запросы преподавателей</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Раздел: Субъекты и ресурсы -->
|
<!-- Раздел: Субъекты и ресурсы -->
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const ROUTES = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'teacher-absences': {
|
'teacher-absences': {
|
||||||
title: 'Отсутствия и мастер замены',
|
title: 'Запросы преподавателей',
|
||||||
file: '/admin/views/teacher-absences.html',
|
file: '/admin/views/teacher-absences.html',
|
||||||
init: async () => {
|
init: async () => {
|
||||||
const module = await import('./views/teacher-absences.js');
|
const module = await import('./views/teacher-absences.js');
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export async function initSchedule() {
|
|||||||
let timeSlots = [];
|
let timeSlots = [];
|
||||||
let academicYears = [];
|
let academicYears = [];
|
||||||
let semesters = [];
|
let semesters = [];
|
||||||
|
let approvedTeacherPreferences = [];
|
||||||
let selectedVisualYearId = '';
|
let selectedVisualYearId = '';
|
||||||
let selectedVisualSemesterId = '';
|
let selectedVisualSemesterId = '';
|
||||||
let selectedVisualGroupIds = new Set();
|
let selectedVisualGroupIds = new Set();
|
||||||
@@ -137,7 +138,7 @@ export async function initSchedule() {
|
|||||||
bindEvents();
|
bindEvents();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
|
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]);
|
||||||
renderRuleGroups([]);
|
renderRuleGroups([]);
|
||||||
renderRuleSlots([{}]);
|
renderRuleSlots([{}]);
|
||||||
await loadRules();
|
await loadRules();
|
||||||
@@ -185,6 +186,7 @@ export async function initSchedule() {
|
|||||||
renderVisualSchedule();
|
renderVisualSchedule();
|
||||||
});
|
});
|
||||||
ruleSemesterSelect?.addEventListener('change', () => {
|
ruleSemesterSelect?.addEventListener('change', () => {
|
||||||
|
updatePreferenceHints();
|
||||||
if (visualPeriodTouched) return;
|
if (visualPeriodTouched) return;
|
||||||
selectDefaultVisualPeriod();
|
selectDefaultVisualPeriod();
|
||||||
renderVisualSchedule();
|
renderVisualSchedule();
|
||||||
@@ -252,6 +254,7 @@ export async function initSchedule() {
|
|||||||
if (event.target.closest('.slot-subgroup-checkboxes')) {
|
if (event.target.closest('.slot-subgroup-checkboxes')) {
|
||||||
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
|
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
|
||||||
}
|
}
|
||||||
|
updatePreferenceHints();
|
||||||
});
|
});
|
||||||
ruleForm.addEventListener('submit', saveRule);
|
ruleForm.addEventListener('submit', saveRule);
|
||||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||||
@@ -276,6 +279,11 @@ export async function initSchedule() {
|
|||||||
selectDefaultVisualPeriod();
|
selectDefaultVisualPeriod();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadApprovedTeacherPreferences() {
|
||||||
|
approvedTeacherPreferences = await api.get('/api/teacher-preferences?status=APPROVED');
|
||||||
|
updatePreferenceHints();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||||
try {
|
try {
|
||||||
@@ -1383,8 +1391,55 @@ export async function initSchedule() {
|
|||||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||||
<button type="button" class="btn btn-md ${actionClass} schedule-slot-remove" data-index="${index}">${actionLabel}</button>
|
<button type="button" class="btn btn-md ${actionClass} schedule-slot-remove" data-index="${index}">${actionLabel}</button>
|
||||||
|
<div class="schedule-preference-hint" data-preference-hint></div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).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
|
||||||
|
? `<span class="schedule-preference-caption">Пожелания преподавателя</span>${pills.map(pill => `<span class="schedule-preference-pill ${pill.kind}">${escapeHtml(pill.text)}</span>`).join('')}`
|
||||||
|
: '<span class="schedule-preference-caption muted">Согласованных пожеланий для слота нет</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferenceHintLabel(type) {
|
||||||
|
return {
|
||||||
|
HARD_UNAVAILABLE: 'Строгая недоступность',
|
||||||
|
SOFT_PREFERRED: 'Предпочтительно',
|
||||||
|
SOFT_UNWANTED: 'Нежелательно',
|
||||||
|
CONSECUTIVE: 'Пары подряд',
|
||||||
|
NO_GAPS: 'Без окон'
|
||||||
|
}[type] || type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRuleSlots(validate) {
|
function readRuleSlots(validate) {
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ export async function initTeacherAbsences() {
|
|||||||
const registry = document.getElementById('teacher-absence-registry');
|
const registry = document.getElementById('teacher-absence-registry');
|
||||||
const overlay = document.getElementById('absence-wizard-overlay');
|
const overlay = document.getElementById('absence-wizard-overlay');
|
||||||
const wizardLessons = document.getElementById('absence-wizard-lessons');
|
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 absences = [];
|
||||||
let currentWizard = null;
|
let currentWizard = null;
|
||||||
|
let preferenceItems = [];
|
||||||
|
let changeItems = [];
|
||||||
|
|
||||||
setDefaultPeriod(startInput, endInput);
|
setDefaultPeriod(startInput, endInput);
|
||||||
form?.addEventListener('submit', createAbsence);
|
form?.addEventListener('submit', createAbsence);
|
||||||
@@ -31,8 +38,15 @@ export async function initTeacherAbsences() {
|
|||||||
if (event.target === overlay) closeWizard();
|
if (event.target === overlay) closeWizard();
|
||||||
});
|
});
|
||||||
registry?.addEventListener('click', handleRegistryAction);
|
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() {
|
async function loadTeachers() {
|
||||||
try {
|
try {
|
||||||
@@ -249,6 +263,152 @@ export async function initTeacherAbsences() {
|
|||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPreferenceMeta() {
|
||||||
|
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка семестров...</div>';
|
||||||
|
try {
|
||||||
|
const meta = await api.get('/api/teacher-preferences/meta');
|
||||||
|
preferenceSemesterFilter.innerHTML = '<option value="">Все семестры</option>' + (meta.semesters || []).map(item => `
|
||||||
|
<option value="${item.id}">${escapeHtml(semesterLabel(item))}</option>
|
||||||
|
`).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 = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить семестры')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreferenceReviews() {
|
||||||
|
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка пожеланий...</div>';
|
||||||
|
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 = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить пожелания')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreferenceReviews() {
|
||||||
|
preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => `
|
||||||
|
<article class="teacher-dialogue-item">
|
||||||
|
<span class="teacher-dialogue-mark preference-${escapeHtml(item.preferenceType.toLowerCase())}"></span>
|
||||||
|
<div class="teacher-dialogue-copy">
|
||||||
|
<small>${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')}</small>
|
||||||
|
<h3>${escapeHtml(item.teacherName)}</h3>
|
||||||
|
<p>${escapeHtml(preferenceDescription(item))}</p>
|
||||||
|
${item.comment ? `<blockquote>${escapeHtml(item.comment)}</blockquote>` : ''}
|
||||||
|
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="teacher-dialogue-action">
|
||||||
|
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
|
||||||
|
${item.status === 'PENDING' ? `
|
||||||
|
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-primary" type="button" data-preference-review="approve" data-id="${item.id}">Согласовать</button>
|
||||||
|
<button class="btn btn-sm btn-danger-subtle" type="button" data-preference-review="reject" data-id="${item.id}">Отклонить</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`).join('') : '<div class="absence-empty"><strong>Пожеланий не найдено</strong><span>Измените фильтры или дождитесь новых отметок преподавателей.</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="loading-row">Загрузка заявок...</div>';
|
||||||
|
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 = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить заявки')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<article class="teacher-dialogue-item change-item">
|
||||||
|
<span class="teacher-dialogue-mark change-${escapeHtml(item.requestType.toLowerCase())}"></span>
|
||||||
|
<div class="teacher-dialogue-copy">
|
||||||
|
<small>${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)}</small>
|
||||||
|
<h3>${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}</h3>
|
||||||
|
<p>${escapeHtml(changeTypeLabel(item.requestType))} → <strong>${escapeHtml(target)}</strong></p>
|
||||||
|
<blockquote>${escapeHtml(item.reason)}</blockquote>
|
||||||
|
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
|
||||||
|
<details><summary>История · ${(item.history || []).length}</summary>
|
||||||
|
<ol>${(item.history || []).map(entry => `<li><b>${genericStatusLabel(entry.status)}</b> · ${escapeHtml(entry.comment || 'Без комментария')}</li>`).join('')}</ol>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="teacher-dialogue-action">
|
||||||
|
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
|
||||||
|
${item.status === 'PENDING' && canApply ? `
|
||||||
|
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-primary" type="button" data-change-review="approve" data-id="${item.id}">Применить</button>
|
||||||
|
<button class="btn btn-sm btn-danger-subtle" type="button" data-change-review="reject" data-id="${item.id}">Отклонить</button>
|
||||||
|
</div>
|
||||||
|
` : item.status === 'PENDING' ? '<small>Применение доступно учебному отделу</small>' : ''}
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}).join('') : '<div class="absence-empty"><strong>Заявок не найдено</strong><span>Очередь изменений сейчас пуста.</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
export function decisionOptions(lesson) {
|
||||||
@@ -343,6 +503,41 @@ function statusClass(status) {
|
|||||||
return `is-${String(status || '').toLowerCase()}`;
|
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) {
|
function resolutionLabel(resolution) {
|
||||||
return {
|
return {
|
||||||
REPLACE_TEACHER: 'Замена преподавателя',
|
REPLACE_TEACHER: 'Замена преподавателя',
|
||||||
|
|||||||
@@ -69,6 +69,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="teacher-dialogue-grid">
|
||||||
|
<article class="card teacher-dialogue-card">
|
||||||
|
<header class="teacher-dialogue-head">
|
||||||
|
<div>
|
||||||
|
<span class="absence-section-number">03</span>
|
||||||
|
<h2>Пожелания на семестр</h2>
|
||||||
|
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
|
||||||
|
</div>
|
||||||
|
<div class="teacher-dialogue-counter"><strong id="teacher-preference-pending-count">0</strong><span>ожидают</span></div>
|
||||||
|
</header>
|
||||||
|
<div class="teacher-dialogue-toolbar">
|
||||||
|
<select id="teacher-preference-semester-filter" aria-label="Семестр пожеланий"></select>
|
||||||
|
<select id="teacher-preference-status-filter" aria-label="Статус пожеланий">
|
||||||
|
<option value="PENDING">На рассмотрении</option>
|
||||||
|
<option value="APPROVED">Согласованные</option>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-md btn-secondary" id="teacher-preference-refresh" type="button">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div class="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite"></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card teacher-dialogue-card change-review-card">
|
||||||
|
<header class="teacher-dialogue-head">
|
||||||
|
<div>
|
||||||
|
<span class="absence-section-number">04</span>
|
||||||
|
<h2>Заявки на изменение</h2>
|
||||||
|
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
|
||||||
|
</div>
|
||||||
|
<div class="teacher-dialogue-counter"><strong id="teacher-change-pending-count">0</strong><span>в очереди</span></div>
|
||||||
|
</header>
|
||||||
|
<div class="teacher-dialogue-toolbar">
|
||||||
|
<select id="teacher-change-status-filter" aria-label="Статус заявок">
|
||||||
|
<option value="PENDING">Ожидают решения</option>
|
||||||
|
<option value="APPROVED">Одобренные</option>
|
||||||
|
<option value="REJECTED">Отклонённые</option>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-md btn-secondary" id="teacher-change-refresh" type="button">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div class="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite"></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="absence-wizard-overlay" id="absence-wizard-overlay" hidden>
|
<div class="absence-wizard-overlay" id="absence-wizard-overlay" hidden>
|
||||||
<section class="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
|
<section class="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
|
||||||
<header class="absence-wizard-header">
|
<header class="absence-wizard-header">
|
||||||
|
|||||||
@@ -22,6 +22,29 @@ import {
|
|||||||
const absenceStart = document.getElementById('teacher-absence-start');
|
const absenceStart = document.getElementById('teacher-absence-start');
|
||||||
const absenceEnd = document.getElementById('teacher-absence-end');
|
const absenceEnd = document.getElementById('teacher-absence-end');
|
||||||
const absenceReason = document.getElementById('teacher-absence-reason');
|
const absenceReason = document.getElementById('teacher-absence-reason');
|
||||||
|
const preferenceSemester = document.getElementById('preference-semester');
|
||||||
|
const preferenceGrid = document.getElementById('preference-grid');
|
||||||
|
const preferenceMessage = document.getElementById('preference-message');
|
||||||
|
const preferenceDateForm = document.getElementById('preference-date-form');
|
||||||
|
const preferenceDate = document.getElementById('preference-date');
|
||||||
|
const preferenceDateComment = document.getElementById('preference-date-comment');
|
||||||
|
const preferenceDateList = document.getElementById('preference-date-list');
|
||||||
|
const preferenceConsecutive = document.getElementById('preference-consecutive');
|
||||||
|
const preferenceNoGaps = document.getElementById('preference-no-gaps');
|
||||||
|
const requestList = document.getElementById('change-request-list');
|
||||||
|
const requestOverlay = document.getElementById('request-dialog-overlay');
|
||||||
|
const requestForm = document.getElementById('change-request-form');
|
||||||
|
const requestType = document.getElementById('change-request-type');
|
||||||
|
const requestDate = document.getElementById('change-request-date');
|
||||||
|
const requestTime = document.getElementById('change-request-time');
|
||||||
|
const requestClassroom = document.getElementById('change-request-classroom');
|
||||||
|
const requestReason = document.getElementById('change-request-reason');
|
||||||
|
const requestMessage = document.getElementById('request-dialog-message');
|
||||||
|
let preferenceMeta = { semesters: [], timeSlots: [] };
|
||||||
|
let preferences = [];
|
||||||
|
let scheduleLessons = new Map();
|
||||||
|
let activeRequestLesson = null;
|
||||||
|
let activeCandidates = null;
|
||||||
let weekStart = startOfWeek(new Date());
|
let weekStart = startOfWeek(new Date());
|
||||||
|
|
||||||
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
||||||
@@ -58,6 +81,29 @@ import {
|
|||||||
});
|
});
|
||||||
absenceForm.addEventListener('submit', submitAbsence);
|
absenceForm.addEventListener('submit', submitAbsence);
|
||||||
absenceList.addEventListener('click', cancelAbsence);
|
absenceList.addEventListener('click', cancelAbsence);
|
||||||
|
document.querySelectorAll('[data-workspace-tab]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => switchWorkspace(button.dataset.workspaceTab));
|
||||||
|
});
|
||||||
|
preferenceSemester.addEventListener('change', loadPreferences);
|
||||||
|
preferenceGrid.addEventListener('click', handlePreferenceCell);
|
||||||
|
preferenceDateForm.addEventListener('submit', submitUnavailableDate);
|
||||||
|
preferenceDateList.addEventListener('click', cancelPreference);
|
||||||
|
preferenceConsecutive.addEventListener('change', () => toggleCompactPreference('CONSECUTIVE', preferenceConsecutive));
|
||||||
|
preferenceNoGaps.addEventListener('change', () => toggleCompactPreference('NO_GAPS', preferenceNoGaps));
|
||||||
|
grid.addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-request-lesson]');
|
||||||
|
if (button) openRequestDialog(button.dataset.requestLesson);
|
||||||
|
});
|
||||||
|
document.getElementById('change-request-refresh').addEventListener('click', loadChangeRequests);
|
||||||
|
requestList.addEventListener('click', cancelChangeRequest);
|
||||||
|
document.getElementById('request-dialog-close').addEventListener('click', closeRequestDialog);
|
||||||
|
document.getElementById('request-dialog-cancel').addEventListener('click', closeRequestDialog);
|
||||||
|
requestOverlay.addEventListener('click', event => {
|
||||||
|
if (event.target === requestOverlay) closeRequestDialog();
|
||||||
|
});
|
||||||
|
requestType.addEventListener('change', updateRequestFields);
|
||||||
|
requestDate.addEventListener('change', reloadRequestCandidates);
|
||||||
|
requestForm.addEventListener('submit', submitChangeRequest);
|
||||||
|
|
||||||
const todayIso = toIsoDate(new Date());
|
const todayIso = toIsoDate(new Date());
|
||||||
absenceStart.value = todayIso;
|
absenceStart.value = todayIso;
|
||||||
@@ -65,6 +111,351 @@ import {
|
|||||||
|
|
||||||
loadSchedule();
|
loadSchedule();
|
||||||
loadAbsences();
|
loadAbsences();
|
||||||
|
loadPreferenceMeta();
|
||||||
|
loadChangeRequests();
|
||||||
|
|
||||||
|
function switchWorkspace(name) {
|
||||||
|
document.querySelectorAll('[data-workspace-tab]').forEach(button => {
|
||||||
|
button.classList.toggle('active', button.dataset.workspaceTab === name);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-workspace-view]').forEach(view => {
|
||||||
|
const active = view.dataset.workspaceView === name;
|
||||||
|
view.hidden = !active;
|
||||||
|
view.classList.toggle('active', active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreferenceMeta() {
|
||||||
|
try {
|
||||||
|
preferenceMeta = await fetchJson('/api/teacher-preferences/meta');
|
||||||
|
const semesters = preferenceMeta.semesters || [];
|
||||||
|
preferenceSemester.innerHTML = semesters.map(semester => `
|
||||||
|
<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>
|
||||||
|
`).join('');
|
||||||
|
const today = toIsoDate(new Date());
|
||||||
|
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
|
||||||
|
if (current) preferenceSemester.value = String(current.id);
|
||||||
|
await loadPreferences();
|
||||||
|
} catch (error) {
|
||||||
|
showPreferenceMessage(error.message, true);
|
||||||
|
preferenceGrid.innerHTML = '<div class="preference-empty">Не удалось загрузить параметры семестра</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreferences() {
|
||||||
|
if (!preferenceSemester.value) return;
|
||||||
|
preferenceGrid.innerHTML = '<div class="preference-empty">Загрузка календаря...</div>';
|
||||||
|
try {
|
||||||
|
preferences = await fetchJson(`/api/teacher-preferences?semesterId=${encodeURIComponent(preferenceSemester.value)}`);
|
||||||
|
const semester = selectedSemester();
|
||||||
|
preferenceDate.min = semester?.startDate || '';
|
||||||
|
preferenceDate.max = semester?.endDate || '';
|
||||||
|
if (!preferenceDate.value || preferenceDate.value < preferenceDate.min || preferenceDate.value > preferenceDate.max) {
|
||||||
|
preferenceDate.value = semester?.startDate || '';
|
||||||
|
}
|
||||||
|
renderPreferenceGrid();
|
||||||
|
renderPreferenceDates();
|
||||||
|
syncCompactToggles();
|
||||||
|
showPreferenceMessage('', false);
|
||||||
|
} catch (error) {
|
||||||
|
preferenceGrid.innerHTML = `<div class="preference-empty error">${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreferenceGrid() {
|
||||||
|
const slots = preferenceMeta.timeSlots || [];
|
||||||
|
if (!slots.length) {
|
||||||
|
preferenceGrid.innerHTML = '<div class="preference-empty">Базовая сетка времени не настроена</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = preferences.filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
|
||||||
|
preferenceGrid.innerHTML = [
|
||||||
|
'<div class="preference-grid-corner">Пара</div>',
|
||||||
|
...['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => `<div class="preference-grid-day">${day}</div>`),
|
||||||
|
...slots.flatMap(slot => [
|
||||||
|
`<div class="preference-grid-time"><strong>${slot.orderNumber}</strong><span>${trimTime(slot.startTime)}–${trimTime(slot.endTime)}</span></div>`,
|
||||||
|
...Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const item = active.find(candidate => candidate.dayOfWeek === index + 1 && candidate.timeSlotId === slot.id);
|
||||||
|
const typeClass = preferenceTypeClass(item?.preferenceType);
|
||||||
|
const pendingClass = item?.status === 'PENDING' ? ' is-pending' : '';
|
||||||
|
const title = item
|
||||||
|
? `${preferenceTypeLabel(item.preferenceType)} · ${preferenceStatusLabel(item.status)}`
|
||||||
|
: 'Добавить отметку';
|
||||||
|
return `<button type="button" class="preference-cell ${typeClass}${pendingClass}"
|
||||||
|
data-day="${index + 1}" data-slot="${slot.id}" data-preference-id="${item?.id || ''}"
|
||||||
|
data-preference-status="${item?.status || ''}" title="${escapeHtml(title)}">
|
||||||
|
<span>${item ? preferenceTypeSymbol(item.preferenceType) : '+'}</span>
|
||||||
|
</button>`;
|
||||||
|
})
|
||||||
|
])
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreferenceCell(event) {
|
||||||
|
const button = event.target.closest('.preference-cell');
|
||||||
|
if (!button) return;
|
||||||
|
if (button.dataset.preferenceId) {
|
||||||
|
if (button.dataset.preferenceStatus === 'APPROVED') {
|
||||||
|
showPreferenceMessage('Согласованную отметку может снять кафедра или учебный отдел', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deletePreference(button.dataset.preferenceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await fetchJson('/api/teacher-preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
semesterId: Number(preferenceSemester.value),
|
||||||
|
preferenceType: document.getElementById('preference-brush').value,
|
||||||
|
dayOfWeek: Number(button.dataset.day),
|
||||||
|
timeSlotId: Number(button.dataset.slot)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
showPreferenceMessage('Отметка отправлена на согласование', false);
|
||||||
|
await loadPreferences();
|
||||||
|
} catch (error) {
|
||||||
|
showPreferenceMessage(error.message, true);
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUnavailableDate(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!preferenceDate.value) return;
|
||||||
|
try {
|
||||||
|
await fetchJson('/api/teacher-preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
semesterId: Number(preferenceSemester.value),
|
||||||
|
preferenceType: 'HARD_UNAVAILABLE',
|
||||||
|
preferenceDate: preferenceDate.value,
|
||||||
|
comment: preferenceDateComment.value.trim() || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
preferenceDateComment.value = '';
|
||||||
|
showPreferenceMessage('Дата недоступности отправлена на согласование', false);
|
||||||
|
await loadPreferences();
|
||||||
|
} catch (error) {
|
||||||
|
showPreferenceMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreferenceDates() {
|
||||||
|
const dates = preferences.filter(item => item.preferenceDate && item.status !== 'CANCELLED');
|
||||||
|
preferenceDateList.innerHTML = dates.length ? dates.map(item => `
|
||||||
|
<article>
|
||||||
|
<span class="preference-date-mark ${item.status === 'APPROVED' ? 'approved' : ''}"></span>
|
||||||
|
<div><strong>${escapeHtml(formatIsoDate(item.preferenceDate))}</strong><small>${escapeHtml(item.comment || 'Весь день')}</small></div>
|
||||||
|
<span class="preference-status">${escapeHtml(preferenceStatusLabel(item.status))}</span>
|
||||||
|
${item.status === 'PENDING' ? `<button type="button" class="btn btn-sm btn-danger-subtle" data-cancel-preference="${item.id}">Отменить</button>` : ''}
|
||||||
|
</article>
|
||||||
|
`).join('') : '<div class="preference-empty">Даты полной недоступности не добавлены</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPreference(event) {
|
||||||
|
const button = event.target.closest('[data-cancel-preference]');
|
||||||
|
if (button) await deletePreference(button.dataset.cancelPreference);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePreference(id) {
|
||||||
|
try {
|
||||||
|
await fetchJson(`/api/teacher-preferences/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
showPreferenceMessage('Ожидающее пожелание отменено', false);
|
||||||
|
await loadPreferences();
|
||||||
|
} catch (error) {
|
||||||
|
showPreferenceMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCompactToggles() {
|
||||||
|
syncCompactToggle(preferenceConsecutive, 'CONSECUTIVE');
|
||||||
|
syncCompactToggle(preferenceNoGaps, 'NO_GAPS');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCompactToggle(input, type) {
|
||||||
|
const item = preferences.find(candidate => candidate.preferenceType === type && ['PENDING', 'APPROVED'].includes(candidate.status));
|
||||||
|
input.checked = Boolean(item);
|
||||||
|
input.dataset.preferenceId = item?.id || '';
|
||||||
|
input.disabled = item?.status === 'APPROVED';
|
||||||
|
input.closest('.preference-toggle-card')?.classList.toggle('approved', item?.status === 'APPROVED');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCompactPreference(type, input) {
|
||||||
|
try {
|
||||||
|
if (!input.checked && input.dataset.preferenceId) {
|
||||||
|
await fetchJson(`/api/teacher-preferences/${encodeURIComponent(input.dataset.preferenceId)}`, { method: 'DELETE' });
|
||||||
|
} else if (input.checked) {
|
||||||
|
await fetchJson('/api/teacher-preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ semesterId: Number(preferenceSemester.value), preferenceType: type })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadPreferences();
|
||||||
|
} catch (error) {
|
||||||
|
showPreferenceMessage(error.message, true);
|
||||||
|
await loadPreferences();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreferenceMessage(message, isError) {
|
||||||
|
preferenceMessage.textContent = message || '';
|
||||||
|
preferenceMessage.classList.toggle('error', Boolean(isError));
|
||||||
|
preferenceMessage.classList.toggle('success', Boolean(message) && !isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChangeRequests() {
|
||||||
|
requestList.innerHTML = '<div class="request-empty">Загрузка заявок...</div>';
|
||||||
|
try {
|
||||||
|
const items = await fetchJson('/api/teacher-change-requests');
|
||||||
|
document.getElementById('change-request-count').textContent = String(items.filter(item => item.status === 'PENDING').length);
|
||||||
|
requestList.innerHTML = items.length ? items.map(renderChangeRequest).join('')
|
||||||
|
: '<div class="request-empty">Заявок на изменение пока нет. Откройте занятие во вкладке «Расписание».</div>';
|
||||||
|
} catch (error) {
|
||||||
|
requestList.innerHTML = `<div class="request-empty error">${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChangeRequest(item) {
|
||||||
|
const target = item.requestType === 'MOVE'
|
||||||
|
? `${formatIsoDate(item.targetLessonDate || item.lessonDate)}, ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`
|
||||||
|
: item.requestType === 'CHANGE_CLASSROOM'
|
||||||
|
? item.requestedClassroomName
|
||||||
|
: 'Отмена занятия';
|
||||||
|
const history = (item.history || []).map(entry => `
|
||||||
|
<li><time>${formatInstant(entry.createdAt)}</time><strong>${preferenceStatusLabel(entry.status)}</strong><span>${escapeHtml(entry.comment || 'Без комментария')}</span></li>
|
||||||
|
`).join('');
|
||||||
|
return `<article class="request-ledger-item">
|
||||||
|
<div class="request-ledger-stripe ${String(item.status || '').toLowerCase()}"></div>
|
||||||
|
<div class="request-ledger-main">
|
||||||
|
<span>${escapeHtml(formatIsoDate(item.lessonDate))} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)}</span>
|
||||||
|
<h3>${escapeHtml(item.subjectName)}</h3>
|
||||||
|
<p>${escapeHtml((item.groupNames || []).join(', '))} · ${escapeHtml(item.sourceClassroomName)}</p>
|
||||||
|
<div class="request-change-line"><b>${escapeHtml(changeTypeLabel(item.requestType))}</b><i>→</i><strong>${escapeHtml(target)}</strong></div>
|
||||||
|
<p class="request-reason">${escapeHtml(item.reason)}</p>
|
||||||
|
${item.reviewComment ? `<p class="request-review-comment">Решение: ${escapeHtml(item.reviewComment)}</p>` : ''}
|
||||||
|
<details><summary>История решения · ${(item.history || []).length}</summary><ol>${history}</ol></details>
|
||||||
|
</div>
|
||||||
|
<div class="request-ledger-side">
|
||||||
|
<span class="request-status ${String(item.status || '').toLowerCase()}">${escapeHtml(preferenceStatusLabel(item.status))}</span>
|
||||||
|
${item.status === 'PENDING' ? `<button type="button" class="btn btn-sm btn-danger-subtle" data-cancel-request="${item.id}">Отозвать</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRequestDialog(key) {
|
||||||
|
activeRequestLesson = scheduleLessons.get(key);
|
||||||
|
if (!activeRequestLesson) return;
|
||||||
|
requestOverlay.hidden = false;
|
||||||
|
document.body.classList.add('dialog-open');
|
||||||
|
document.getElementById('request-dialog-lesson').textContent = `${activeRequestLesson.subjectName} · ${formatIsoDate(activeRequestLesson.date)} · ${trimTime(activeRequestLesson.startTime)}–${trimTime(activeRequestLesson.endTime)}`;
|
||||||
|
requestReason.value = '';
|
||||||
|
requestType.value = 'MOVE';
|
||||||
|
setRequestMessage('Проверяем допустимые варианты...', false);
|
||||||
|
try {
|
||||||
|
activeCandidates = await fetchJson(`/api/teacher-change-requests/candidates?baseRuleSlotId=${encodeURIComponent(activeRequestLesson.scheduleRuleSlotId)}&lessonDate=${encodeURIComponent(activeRequestLesson.date)}`);
|
||||||
|
renderCandidateDates();
|
||||||
|
renderCandidateOptions();
|
||||||
|
updateRequestFields();
|
||||||
|
setRequestMessage('Доступны только варианты, прошедшие проверку конфликтов', false);
|
||||||
|
} catch (error) {
|
||||||
|
setRequestMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRequestDialog() {
|
||||||
|
requestOverlay.hidden = true;
|
||||||
|
document.body.classList.remove('dialog-open');
|
||||||
|
activeRequestLesson = null;
|
||||||
|
activeCandidates = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCandidateDates() {
|
||||||
|
requestDate.innerHTML = (activeCandidates?.availableDates || []).map(date => `
|
||||||
|
<option value="${date}" ${date === activeCandidates.checkedDate ? 'selected' : ''}>${formatIsoDate(date)}</option>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCandidateOptions() {
|
||||||
|
requestTime.innerHTML = candidateOptions(activeCandidates?.timeSlots, 'Нет свободных временных слотов');
|
||||||
|
requestClassroom.innerHTML = candidateOptions(activeCandidates?.classrooms, 'Нет свободных аудиторий');
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateOptions(items, emptyLabel) {
|
||||||
|
const available = (items || []).filter(item => item.available);
|
||||||
|
return available.length
|
||||||
|
? available.map(item => `<option value="${item.id}">${escapeHtml(item.label)}</option>`).join('')
|
||||||
|
: `<option value="">${emptyLabel}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadRequestCandidates() {
|
||||||
|
if (!activeRequestLesson || !requestDate.value) return;
|
||||||
|
setRequestMessage('Проверяем выбранную дату...', false);
|
||||||
|
try {
|
||||||
|
activeCandidates = await fetchJson(`/api/teacher-change-requests/candidates?baseRuleSlotId=${encodeURIComponent(activeRequestLesson.scheduleRuleSlotId)}&lessonDate=${encodeURIComponent(activeRequestLesson.date)}&targetDate=${encodeURIComponent(requestDate.value)}`);
|
||||||
|
renderCandidateOptions();
|
||||||
|
setRequestMessage('Предварительная проверка завершена', false);
|
||||||
|
} catch (error) {
|
||||||
|
setRequestMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRequestFields() {
|
||||||
|
document.querySelector('[data-request-field="date"]').hidden = requestType.value !== 'MOVE';
|
||||||
|
document.querySelector('[data-request-field="time"]').hidden = requestType.value !== 'MOVE';
|
||||||
|
document.querySelector('[data-request-field="classroom"]').hidden = requestType.value !== 'CHANGE_CLASSROOM';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChangeRequest(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeRequestLesson) return;
|
||||||
|
if (!requestReason.value.trim()) {
|
||||||
|
setRequestMessage('Укажите обоснование заявки', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = requestType.value;
|
||||||
|
const payload = {
|
||||||
|
baseRuleSlotId: activeRequestLesson.scheduleRuleSlotId,
|
||||||
|
lessonDate: activeRequestLesson.date,
|
||||||
|
requestType: type,
|
||||||
|
targetLessonDate: type === 'MOVE' ? requestDate.value : null,
|
||||||
|
requestedTimeSlotId: type === 'MOVE' ? Number(requestTime.value) || null : null,
|
||||||
|
requestedClassroomId: type === 'CHANGE_CLASSROOM' ? Number(requestClassroom.value) || null : null,
|
||||||
|
reason: requestReason.value.trim()
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fetchJson('/api/teacher-change-requests', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
closeRequestDialog();
|
||||||
|
await loadChangeRequests();
|
||||||
|
switchWorkspace('requests');
|
||||||
|
} catch (error) {
|
||||||
|
setRequestMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelChangeRequest(event) {
|
||||||
|
const button = event.target.closest('[data-cancel-request]');
|
||||||
|
if (!button) return;
|
||||||
|
try {
|
||||||
|
await fetchJson(`/api/teacher-change-requests/${encodeURIComponent(button.dataset.cancelRequest)}`, { method: 'DELETE' });
|
||||||
|
await loadChangeRequests();
|
||||||
|
} catch (error) {
|
||||||
|
requestList.insertAdjacentHTML('afterbegin', `<div class="request-empty error">${escapeHtml(error.message)}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRequestMessage(message, isError) {
|
||||||
|
requestMessage.textContent = message || '';
|
||||||
|
requestMessage.classList.toggle('error', Boolean(isError));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAbsences() {
|
async function loadAbsences() {
|
||||||
absenceList.innerHTML = '<div class="teacher-absence-empty">Загрузка заявок...</div>';
|
absenceList.innerHTML = '<div class="teacher-absence-empty">Загрузка заявок...</div>';
|
||||||
@@ -152,6 +543,7 @@ import {
|
|||||||
renderEmptyWeek('Загрузка...');
|
renderEmptyWeek('Загрузка...');
|
||||||
try {
|
try {
|
||||||
const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`);
|
const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`);
|
||||||
|
scheduleLessons = new Map(lessons.map(lesson => [lessonKey(lesson), lesson]));
|
||||||
renderWeek(lessons);
|
renderWeek(lessons);
|
||||||
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
|
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -249,10 +641,19 @@ import {
|
|||||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||||
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId ? `
|
||||||
|
<button class="lesson-request-button" type="button" data-request-lesson="${escapeHtml(lessonKey(lesson))}">
|
||||||
|
Запросить изменение
|
||||||
|
</button>
|
||||||
|
` : '<span class="lesson-change-note">Занятие уже изменено учебным отделом</span>'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lessonKey(lesson) {
|
||||||
|
return `${lesson.scheduleRuleSlotId}:${lesson.date}`;
|
||||||
|
}
|
||||||
|
|
||||||
function collectSlots(lessons) {
|
function collectSlots(lessons) {
|
||||||
const slots = new Map();
|
const slots = new Map();
|
||||||
lessons.forEach(lesson => {
|
lessons.forEach(lesson => {
|
||||||
@@ -335,6 +736,59 @@ import {
|
|||||||
return `is-${String(status || '').toLowerCase()}`;
|
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) {
|
function dayName(date) {
|
||||||
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Расписание преподавателя</title>
|
<title>Кабинет преподавателя</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<h1>Расписание преподавателя</h1>
|
<h1>Кабинет преподавателя</h1>
|
||||||
<span id="week-label">Загрузка периода...</span>
|
<span id="week-label">Загрузка периода...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -23,6 +23,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav class="workspace-nav" aria-label="Разделы кабинета">
|
||||||
|
<button class="workspace-tab active" type="button" data-workspace-tab="schedule">Расписание</button>
|
||||||
|
<button class="workspace-tab" type="button" data-workspace-tab="preferences">Мои пожелания</button>
|
||||||
|
<button class="workspace-tab" type="button" data-workspace-tab="requests">
|
||||||
|
Заявки на изменение <span id="change-request-count">0</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="workspace-view active" data-workspace-view="schedule">
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
|
<button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
|
||||||
@@ -71,8 +80,133 @@
|
|||||||
<div class="teacher-absence-list" id="teacher-absence-list" aria-live="polite"></div>
|
<div class="teacher-absence-list" id="teacher-absence-list" aria-live="polite"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workspace-view" data-workspace-view="preferences" hidden>
|
||||||
|
<section class="preference-hero">
|
||||||
|
<div>
|
||||||
|
<span class="preference-kicker">Контур доступности</span>
|
||||||
|
<h2>Соберите ритм семестра до составления расписания</h2>
|
||||||
|
<p>Строгая недоступность блокирует назначение. Мягкие отметки и пожелания к компактности помогают учебному отделу выбрать удобный вариант.</p>
|
||||||
|
</div>
|
||||||
|
<label class="preference-semester-field">
|
||||||
|
<span>Семестр</span>
|
||||||
|
<select id="preference-semester"></select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="preference-board">
|
||||||
|
<header class="preference-board-head">
|
||||||
|
<div>
|
||||||
|
<span>Недельный шаблон</span>
|
||||||
|
<h3>Отмечайте интервалы одним касанием</h3>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Режим отметки</span>
|
||||||
|
<select id="preference-brush">
|
||||||
|
<option value="SOFT_PREFERRED">Предпочтительно</option>
|
||||||
|
<option value="SOFT_UNWANTED">Нежелательно</option>
|
||||||
|
<option value="HARD_UNAVAILABLE">Строго недоступен</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
<div class="preference-legend" aria-label="Легенда">
|
||||||
|
<span class="is-preferred">Предпочтительно</span>
|
||||||
|
<span class="is-unwanted">Нежелательно</span>
|
||||||
|
<span class="is-hard">Строгий запрет</span>
|
||||||
|
<span class="is-pending">Ожидает согласования</span>
|
||||||
|
</div>
|
||||||
|
<div class="preference-grid-wrap">
|
||||||
|
<div class="preference-grid" id="preference-grid" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div class="preference-message" id="preference-message" role="status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="preference-lower-grid">
|
||||||
|
<section class="preference-card">
|
||||||
|
<span class="preference-card-number">01</span>
|
||||||
|
<h3>Полная недоступность</h3>
|
||||||
|
<p>Добавьте отдельную дату внутри семестра — все пары этого дня будут считаться строгим ограничением.</p>
|
||||||
|
<form id="preference-date-form" class="preference-date-form">
|
||||||
|
<input type="date" id="preference-date" required>
|
||||||
|
<input type="text" id="preference-date-comment" maxlength="2000" placeholder="Комментарий (необязательно)">
|
||||||
|
<button class="btn btn-md btn-primary" type="submit">Добавить дату</button>
|
||||||
|
</form>
|
||||||
|
<div class="preference-date-list" id="preference-date-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="preference-card">
|
||||||
|
<span class="preference-card-number">02</span>
|
||||||
|
<h3>Компактность недели</h3>
|
||||||
|
<p>Эти мягкие пожелания не отменяют занятия автоматически, но видны при составлении правил.</p>
|
||||||
|
<label class="preference-toggle-card">
|
||||||
|
<input type="checkbox" id="preference-consecutive">
|
||||||
|
<span><strong>Пары подряд</strong><small>Собирать занятия последовательными блоками</small></span>
|
||||||
|
</label>
|
||||||
|
<label class="preference-toggle-card">
|
||||||
|
<input type="checkbox" id="preference-no-gaps">
|
||||||
|
<span><strong>Без окон</strong><small>Не оставлять пустые интервалы между занятиями</small></span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workspace-view" data-workspace-view="requests" hidden>
|
||||||
|
<section class="request-ledger-head">
|
||||||
|
<div>
|
||||||
|
<span>Журнал решений</span>
|
||||||
|
<h2>Заявки на изменение занятий</h2>
|
||||||
|
<p>Откройте занятие в расписании, чтобы запросить перенос, другую аудиторию или отмену. Здесь сохраняются статус, комментарий и вся история решения.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-md btn-secondary" id="change-request-refresh" type="button">Обновить</button>
|
||||||
|
</section>
|
||||||
|
<div class="request-ledger" id="change-request-list" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="request-dialog-overlay" id="request-dialog-overlay" hidden>
|
||||||
|
<section class="request-dialog" role="dialog" aria-modal="true" aria-labelledby="request-dialog-title">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span>Предварительная проверка</span>
|
||||||
|
<h2 id="request-dialog-title">Заявка на изменение</h2>
|
||||||
|
<p id="request-dialog-lesson">Загрузка занятия...</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-icon-md btn-secondary" id="request-dialog-close" type="button" aria-label="Закрыть">×</button>
|
||||||
|
</header>
|
||||||
|
<form id="change-request-form">
|
||||||
|
<label>
|
||||||
|
<span>Что изменить</span>
|
||||||
|
<select id="change-request-type">
|
||||||
|
<option value="MOVE">Перенести дату или время</option>
|
||||||
|
<option value="CHANGE_CLASSROOM">Сменить аудиторию</option>
|
||||||
|
<option value="CANCEL">Отменить занятие</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label data-request-field="date">
|
||||||
|
<span>Новая дата</span>
|
||||||
|
<select id="change-request-date"></select>
|
||||||
|
</label>
|
||||||
|
<label data-request-field="time">
|
||||||
|
<span>Новая пара</span>
|
||||||
|
<select id="change-request-time"></select>
|
||||||
|
</label>
|
||||||
|
<label data-request-field="classroom" hidden>
|
||||||
|
<span>Новая аудитория</span>
|
||||||
|
<select id="change-request-classroom"></select>
|
||||||
|
</label>
|
||||||
|
<label class="request-reason-field">
|
||||||
|
<span>Обоснование</span>
|
||||||
|
<textarea id="change-request-reason" maxlength="2000" rows="4" placeholder="Почему требуется изменение" required></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="request-dialog-message" id="request-dialog-message" role="alert"></div>
|
||||||
|
<footer>
|
||||||
|
<button class="btn btn-md btn-secondary" id="request-dialog-cancel" type="button">Закрыть</button>
|
||||||
|
<button class="btn btn-md btn-primary" type="submit">Отправить в учебный отдел</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -185,6 +185,8 @@
|
|||||||
--green: #047857;
|
--green: #047857;
|
||||||
--orange: #b45309;
|
--orange: #b45309;
|
||||||
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
|
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
|
||||||
|
--card: var(--panel);
|
||||||
|
--border: var(--panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
@@ -419,6 +421,456 @@ body {
|
|||||||
background: var(--panel);
|
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 {
|
.empty {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -471,5 +923,26 @@ body {
|
|||||||
.week-nav .btn {
|
.week-nav .btn {
|
||||||
flex: 1;
|
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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
frontend/tests/teacher-preferences.test.mjs
Normal file
44
frontend/tests/teacher-preferences.test.mjs
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user