3 Задача

This commit is contained in:
dipatrik10
2026-08-11 01:55:02 +03:00
parent 5276729a69
commit 519864b962
42 changed files with 4083 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record TeacherChangeReviewRequest(Boolean approved, String comment) {
}

View File

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

View File

@@ -0,0 +1,9 @@
package com.magistr.app.dto;
import java.util.List;
public record TeacherPreferenceMetaDto(
List<SemesterDto> semesters,
List<TimeSlotDto> timeSlots
) {
}

View File

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

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record TeacherPreferenceReviewRequest(Boolean approved, String comment) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 'Неизменяемая история статусов и комментариев по заявке преподавателя';

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 | Назначение |

View File

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

View File

@@ -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-схема.

View File

@@ -162,6 +162,11 @@
## 3. Пожелания преподавателей и заявки на перенос ## 3. Пожелания преподавателей и заявки на перенос
**Статус: реализовано в MVP.** Добавлены согласуемый календарь доступности, строгие и мягкие
пожелания, требования «пары подряд» и «без окон», заявки на перенос, смену аудитории и отмену
с предварительной проверкой конфликтов, историей решений и применением через
`schedule_overrides`.
### Проблема и пользователи ### Проблема и пользователи
Кабинет преподавателя сейчас предназначен только для просмотра недельного расписания. Кабинет преподавателя сейчас предназначен только для просмотра недельного расписания.

View File

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

View File

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

View File

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

View File

@@ -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>
<!-- Раздел: Субъекты и ресурсы --> <!-- Раздел: Субъекты и ресурсы -->

View File

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

View File

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

View File

@@ -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: 'Замена преподавателя',

View File

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

View File

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

View File

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

View File

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

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