diff --git a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java index d1feae0..5f1a8ff 100644 --- a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ResponseStatusException; import java.time.Instant; import java.util.LinkedHashMap; @@ -51,6 +52,19 @@ public class GlobalExceptionHandler { return error(HttpStatus.NOT_FOUND, message, request); } + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus( + ResponseStatusException exception, + HttpServletRequest request + ) { + HttpStatus status = HttpStatus.resolve(exception.getStatusCode().value()); + HttpStatus resolvedStatus = status == null ? HttpStatus.INTERNAL_SERVER_ERROR : status; + String message = exception.getReason() == null || exception.getReason().isBlank() + ? "Операция недоступна" + : exception.getReason(); + return error(resolvedStatus, message, request); + } + @ExceptionHandler({ HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class @@ -100,6 +114,8 @@ public class GlobalExceptionHandler { case BAD_REQUEST -> "Некорректный запрос"; case CONFLICT -> "Конфликт"; case NOT_FOUND -> "Не найдено"; + case FORBIDDEN -> "Доступ запрещён"; + case UNAUTHORIZED -> "Требуется вход"; case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера"; default -> status.name(); }; diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherAbsenceController.java b/backend/src/main/java/com/magistr/app/controller/TeacherAbsenceController.java new file mode 100644 index 0000000..61e9deb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TeacherAbsenceController.java @@ -0,0 +1,68 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.auth.RequireRoles; +import com.magistr.app.dto.TeacherAbsenceDto; +import com.magistr.app.dto.TeacherAbsenceRequest; +import com.magistr.app.dto.TeacherAbsenceResolutionRequest; +import com.magistr.app.dto.TeacherAbsenceReviewRequest; +import com.magistr.app.dto.TeacherAbsenceWizardDto; +import com.magistr.app.model.Role; +import com.magistr.app.service.TeacherAbsenceService; +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-absences") +@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER}) +public class TeacherAbsenceController { + + private final TeacherAbsenceService absenceService; + + public TeacherAbsenceController(TeacherAbsenceService absenceService) { + this.absenceService = absenceService; + } + + @GetMapping + public List getAll(@RequestParam(required = false) String status) { + return absenceService.getAll(status); + } + + @PostMapping + public TeacherAbsenceDto create(@RequestBody TeacherAbsenceRequest request) { + return absenceService.create(request); + } + + @PostMapping("/{id}/review") + public TeacherAbsenceDto review( + @PathVariable Long id, + @RequestBody TeacherAbsenceReviewRequest request + ) { + return absenceService.review(id, request); + } + + @GetMapping("/{id}/wizard") + public TeacherAbsenceWizardDto getWizard(@PathVariable Long id) { + return absenceService.getWizard(id); + } + + @PostMapping("/{id}/resolve") + public TeacherAbsenceWizardDto resolve( + @PathVariable Long id, + @RequestBody TeacherAbsenceResolutionRequest request + ) { + return absenceService.resolve(id, request); + } + + @DeleteMapping("/{id}") + public TeacherAbsenceDto cancel(@PathVariable Long id) { + return absenceService.cancel(id); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceDto.java new file mode 100644 index 0000000..cd522bf --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceDto.java @@ -0,0 +1,23 @@ +package com.magistr.app.dto; + +import java.time.Instant; +import java.time.LocalDate; + +public record TeacherAbsenceDto( + Long id, + Long teacherId, + String teacherName, + Long teacherDepartmentId, + LocalDate startDate, + LocalDate endDate, + String reason, + String status, + Long requestedBy, + Long reviewedBy, + String reviewComment, + Instant reviewedAt, + Instant createdAt, + Instant updatedAt, + long decisionCount +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceRequest.java b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceRequest.java new file mode 100644 index 0000000..69d6682 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceRequest.java @@ -0,0 +1,11 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record TeacherAbsenceRequest( + Long teacherId, + LocalDate startDate, + LocalDate endDate, + String reason +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceResolutionRequest.java b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceResolutionRequest.java new file mode 100644 index 0000000..ab8ed83 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceResolutionRequest.java @@ -0,0 +1,20 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; +import java.util.List; + +public record TeacherAbsenceResolutionRequest( + List decisions +) { + public record Decision( + Long baseRuleSlotId, + LocalDate lessonDate, + String resolution, + LocalDate targetLessonDate, + Long newTimeSlotId, + Long newClassroomId, + Long newTeacherId, + String comment + ) { + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceReviewRequest.java b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceReviewRequest.java new file mode 100644 index 0000000..a0c1d1c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceReviewRequest.java @@ -0,0 +1,7 @@ +package com.magistr.app.dto; + +public record TeacherAbsenceReviewRequest( + Boolean approved, + String comment +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceWizardDto.java b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceWizardDto.java new file mode 100644 index 0000000..9a75308 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherAbsenceWizardDto.java @@ -0,0 +1,59 @@ +package com.magistr.app.dto; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public record TeacherAbsenceWizardDto( + TeacherAbsenceDto absence, + List lessons, + List journal +) { + public record Candidate(Long id, String name, String description) { + } + + public record MoveCandidate( + LocalDate targetDate, + Long timeSlotId, + Integer orderNumber, + LocalTime startTime, + LocalTime endTime, + String description + ) { + } + + public record AffectedLesson( + Long baseRuleSlotId, + LocalDate lessonDate, + LocalTime startTime, + LocalTime endTime, + Long subjectId, + String subjectName, + Long lessonTypeId, + String lessonTypeName, + Long classroomId, + String classroomName, + List groupNames, + Long existingOverrideId, + String decisionStatus, + List teacherCandidates, + List moveCandidates, + List classroomCandidates, + boolean cancellationAvailable + ) { + } + + public record DecisionLog( + Long id, + Long baseRuleSlotId, + LocalDate lessonDate, + String resolution, + String status, + Long scheduleOverrideId, + String comment, + Long decidedBy, + Instant decidedAt + ) { + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherAbsence.java b/backend/src/main/java/com/magistr/app/model/TeacherAbsence.java new file mode 100644 index 0000000..7bdf127 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherAbsence.java @@ -0,0 +1,141 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "teacher_absences") +public class TeacherAbsence { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(nullable = false, columnDefinition = "TEXT") + private String reason; + + @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 LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } + + 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 getRequestedBy() { + return requestedBy; + } + + public void setRequestedBy(Long requestedBy) { + this.requestedBy = requestedBy; + } + + public Long getReviewedBy() { + return reviewedBy; + } + + public void setReviewedBy(Long reviewedBy) { + this.reviewedBy = reviewedBy; + } + + public String getReviewComment() { + return reviewComment; + } + + public void setReviewComment(String reviewComment) { + this.reviewComment = reviewComment; + } + + public Instant getReviewedAt() { + return reviewedAt; + } + + public void setReviewedAt(Instant reviewedAt) { + this.reviewedAt = reviewedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherAbsenceDecision.java b/backend/src/main/java/com/magistr/app/model/TeacherAbsenceDecision.java new file mode 100644 index 0000000..ca9b49a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherAbsenceDecision.java @@ -0,0 +1,121 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "teacher_absence_decisions") +public class TeacherAbsenceDecision { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "absence_id", nullable = false) + private TeacherAbsence absence; + + @ManyToOne(optional = false) + @JoinColumn(name = "base_rule_slot_id", nullable = false) + private ScheduleRuleSlot baseRuleSlot; + + @Column(name = "lesson_date", nullable = false) + private LocalDate lessonDate; + + @Column(nullable = false, length = 30) + private String resolution; + + @Column(name = "decision_status", nullable = false, length = 20) + private String decisionStatus; + + @ManyToOne + @JoinColumn(name = "schedule_override_id") + private ScheduleOverride scheduleOverride; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(name = "decided_by") + private Long decidedBy; + + @Column(name = "decided_at", nullable = false) + private Instant decidedAt; + + public Long getId() { + return id; + } + + public TeacherAbsence getAbsence() { + return absence; + } + + public void setAbsence(TeacherAbsence absence) { + this.absence = absence; + } + + 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 getResolution() { + return resolution; + } + + public void setResolution(String resolution) { + this.resolution = resolution; + } + + public String getDecisionStatus() { + return decisionStatus; + } + + public void setDecisionStatus(String decisionStatus) { + this.decisionStatus = decisionStatus; + } + + public ScheduleOverride getScheduleOverride() { + return scheduleOverride; + } + + public void setScheduleOverride(ScheduleOverride scheduleOverride) { + this.scheduleOverride = scheduleOverride; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public Long getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(Long decidedBy) { + this.decidedBy = decidedBy; + } + + public Instant getDecidedAt() { + return decidedAt; + } + + public void setDecidedAt(Instant decidedAt) { + this.decidedAt = decidedAt; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherAbsenceDecisionRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherAbsenceDecisionRepository.java new file mode 100644 index 0000000..c718220 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherAbsenceDecisionRepository.java @@ -0,0 +1,24 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherAbsenceDecision; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface TeacherAbsenceDecisionRepository extends JpaRepository { + + long countByAbsenceId(Long absenceId); + + @Query(""" + select decision + from TeacherAbsenceDecision decision + join fetch decision.baseRuleSlot baseSlot + left join fetch decision.scheduleOverride + where decision.absence.id = :absenceId + order by decision.lessonDate, decision.id + """) + List findByAbsenceIdWithDetails(@Param("absenceId") Long absenceId); + +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherAbsenceRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherAbsenceRepository.java new file mode 100644 index 0000000..fdb5db4 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherAbsenceRepository.java @@ -0,0 +1,66 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherAbsence; +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 TeacherAbsenceRepository extends JpaRepository { + + @Query(""" + select absence + from TeacherAbsence absence + join fetch absence.teacher + order by absence.startDate desc, absence.id desc + """) + List findAllWithTeacher(); + + @Query(""" + select absence + from TeacherAbsence absence + join fetch absence.teacher + where absence.id = :id + """) + Optional findByIdWithTeacher(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select absence + from TeacherAbsence absence + join fetch absence.teacher + where absence.id = :id + """) + Optional findByIdForUpdate(@Param("id") Long id); + + @Query(""" + select count(absence) > 0 + from TeacherAbsence absence + where absence.teacher.id = :teacherId + and absence.status in :statuses + and absence.startDate <= :endDate + and absence.endDate >= :startDate + """) + boolean existsOverlapping( + @Param("teacherId") Long teacherId, + @Param("statuses") Collection statuses, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + @Query(""" + select count(absence) > 0 + from TeacherAbsence absence + where absence.teacher.id = :teacherId + and absence.status in ('APPROVED', 'RESOLVED') + and absence.startDate <= :date + and absence.endDate >= :date + """) + boolean isTeacherAbsentOn(@Param("teacherId") Long teacherId, @Param("date") LocalDate date); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java index 8f90553..05c439c 100755 --- a/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java @@ -3,10 +3,38 @@ package com.magistr.app.repository; import com.magistr.app.model.TeacherSubject; import com.magistr.app.model.TeacherSubjectId; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface TeacherSubjectRepository extends JpaRepository { List findByUserId(Long userId); List findBySubjectId(Long subjectId); + + @Query(value = """ + select subject_link.user_id + from teacher_subjects subject_link + where subject_link.subject_id = :subjectId + and ( + not exists ( + select 1 + from teacher_lesson_types configured_type + where configured_type.user_id = subject_link.user_id + and configured_type.subject_id = subject_link.subject_id + ) + or exists ( + select 1 + from teacher_lesson_types supported_type + where supported_type.user_id = subject_link.user_id + and supported_type.subject_id = subject_link.subject_id + and supported_type.lesson_type_id = :lessonTypeId + ) + ) + order by subject_link.user_id + """, nativeQuery = true) + List findQualifiedTeacherIds( + @Param("subjectId") Long subjectId, + @Param("lessonTypeId") Long lessonTypeId + ); } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java index 320f396..e2aff88 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java @@ -184,6 +184,43 @@ public class ScheduleOverrideService { return validateAndPersist(null, command); } + /** + * Проверяет кандидат мастера замены тем же механизмом, что и обычное точечное изменение, + * но не блокирует строки и не сохраняет результат. + */ + @Transactional(readOnly = true) + public void validateCandidate(ScheduleOverrideDto request) { + OverrideCommand command = validateAndNormalize(request); + if (scheduleOverrideRepository + .findByBaseRuleSlotIdAndLessonDate(command.baseRuleSlotId(), command.lessonDate()) + .isPresent()) { + throw new ScheduleConflictException( + "Для выбранной базовой пары и даты уже существует точечное изменение" + ); + } + List baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate()); + List sourceOccurrences = baseDay.stream() + .filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())) + .filter(lesson -> Objects.equals(lesson.date(), command.lessonDate())) + .toList(); + if (sourceOccurrences.isEmpty()) { + throw new IllegalArgumentException( + "На выбранную дату базовая пара не формируется по правилам расписания" + ); + } + ResolvedReferences references = resolveReferences(command, sourceOccurrences); + validateTargetDate(command, references.baseRuleSlot(), sourceOccurrences); + if (!"CANCEL".equals(command.action())) { + validateEffectiveResult( + null, + command, + buildCandidate(command, references), + baseDay, + sourceOccurrences + ); + } + } + @Transactional public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) { OverrideCommand command = validateAndNormalize(request); diff --git a/backend/src/main/java/com/magistr/app/service/TeacherAbsenceService.java b/backend/src/main/java/com/magistr/app/service/TeacherAbsenceService.java new file mode 100644 index 0000000..eec0ab8 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/TeacherAbsenceService.java @@ -0,0 +1,837 @@ +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.TeacherAbsenceDto; +import com.magistr.app.dto.TeacherAbsenceRequest; +import com.magistr.app.dto.TeacherAbsenceResolutionRequest; +import com.magistr.app.dto.TeacherAbsenceReviewRequest; +import com.magistr.app.dto.TeacherAbsenceWizardDto; +import com.magistr.app.model.Classroom; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Role; +import com.magistr.app.model.ScheduleOverride; +import com.magistr.app.model.ScheduleRuleSlot; +import com.magistr.app.model.TeacherAbsence; +import com.magistr.app.model.TeacherAbsenceDecision; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.model.User; +import com.magistr.app.repository.ClassroomRepository; +import com.magistr.app.repository.ScheduleOverrideRepository; +import com.magistr.app.repository.ScheduleRuleSlotRepository; +import com.magistr.app.repository.TeacherAbsenceDecisionRepository; +import com.magistr.app.repository.TeacherAbsenceRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; +import com.magistr.app.repository.TeacherSubjectRepository; +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.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class TeacherAbsenceService { + + private static final Logger log = LoggerFactory.getLogger(TeacherAbsenceService.class); + private static final Set ACTIVE_ABSENCE_STATUSES = Set.of("PENDING", "APPROVED", "RESOLVED"); + private static final Set ALL_STATUSES = Set.of( + "PENDING", "APPROVED", "REJECTED", "RESOLVED", "CANCELLED" + ); + private static final Set RESOLUTIONS = Set.of( + "REPLACE_TEACHER", "MOVE_TIME", "CHANGE_CLASSROOM", "CANCEL", "REJECT" + ); + private static final int MAX_RANGE_DAYS = 120; + private static final int MAX_DECISIONS = 100; + private static final int CANDIDATE_LIMIT = 5; + + private final TeacherAbsenceRepository absenceRepository; + private final TeacherAbsenceDecisionRepository decisionRepository; + private final UserRepository userRepository; + private final TeacherSubjectRepository teacherSubjectRepository; + private final TeacherDepartmentAssignmentRepository assignmentRepository; + private final ClassroomRepository classroomRepository; + private final TimeSlotRepository timeSlotRepository; + private final ScheduleRuleSlotRepository scheduleRuleSlotRepository; + private final ScheduleOverrideRepository scheduleOverrideRepository; + private final ScheduleQueryService scheduleQueryService; + private final ScheduleOverrideService scheduleOverrideService; + private final BusinessTimeService businessTime; + + public TeacherAbsenceService( + TeacherAbsenceRepository absenceRepository, + TeacherAbsenceDecisionRepository decisionRepository, + UserRepository userRepository, + TeacherSubjectRepository teacherSubjectRepository, + TeacherDepartmentAssignmentRepository assignmentRepository, + ClassroomRepository classroomRepository, + TimeSlotRepository timeSlotRepository, + ScheduleRuleSlotRepository scheduleRuleSlotRepository, + ScheduleOverrideRepository scheduleOverrideRepository, + ScheduleQueryService scheduleQueryService, + ScheduleOverrideService scheduleOverrideService, + BusinessTimeService businessTime + ) { + this.absenceRepository = absenceRepository; + this.decisionRepository = decisionRepository; + this.userRepository = userRepository; + this.teacherSubjectRepository = teacherSubjectRepository; + this.assignmentRepository = assignmentRepository; + this.classroomRepository = classroomRepository; + this.timeSlotRepository = timeSlotRepository; + this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; + this.scheduleOverrideRepository = scheduleOverrideRepository; + this.scheduleQueryService = scheduleQueryService; + this.scheduleOverrideService = scheduleOverrideService; + this.businessTime = businessTime; + } + + @Transactional(readOnly = true) + public List getAll(String status) { + String normalizedStatus = cleanUpper(status); + if (normalizedStatus != null && !ALL_STATUSES.contains(normalizedStatus)) { + throw new IllegalArgumentException("Неизвестный статус отсутствия"); + } + return absenceRepository.findAllWithTeacher().stream() + .filter(this::canView) + .filter(absence -> normalizedStatus == null || normalizedStatus.equals(absence.getStatus())) + .map(this::toDto) + .toList(); + } + + @Transactional + public TeacherAbsenceDto create(TeacherAbsenceRequest request) { + AuthenticatedUser current = currentUser(); + if (request == null) { + throw new IllegalArgumentException("Передайте данные отсутствия преподавателя"); + } + validateRange(request.startDate(), request.endDate()); + String reason = clean(request.reason()); + if (reason == null) { + throw new IllegalArgumentException("Причина отсутствия обязательна"); + } + if (reason.length() > 2000) { + throw new IllegalArgumentException("Причина отсутствия не может превышать 2000 символов"); + } + + 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.findByIdForUpdate(teacherId) + .filter(user -> user.getRole() == Role.TEACHER) + .filter(user -> !user.isArchivedRecord()) + .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); + ensureDepartmentScope(teacher, request.startDate(), request.endDate(), current); + if (absenceRepository.existsOverlapping( + teacherId, + ACTIVE_ABSENCE_STATUSES, + request.startDate(), + request.endDate() + )) { + throw new ScheduleConflictException( + "Для преподавателя уже зарегистрировано отсутствие, пересекающееся с выбранным периодом" + ); + } + + var now = businessTime.now(); + TeacherAbsence absence = new TeacherAbsence(); + absence.setTeacher(teacher); + absence.setStartDate(request.startDate()); + absence.setEndDate(request.endDate()); + absence.setReason(reason); + absence.setRequestedBy(current.id()); + absence.setCreatedAt(now); + absence.setUpdatedAt(now); + if (current.role() == Role.TEACHER) { + absence.setStatus("PENDING"); + } else { + absence.setStatus("APPROVED"); + absence.setReviewedBy(current.id()); + absence.setReviewedAt(now); + absence.setReviewComment("Зарегистрировано ответственным сотрудником"); + } + TeacherAbsence saved = absenceRepository.saveAndFlush(absence); + log.info( + "Зарегистрировано отсутствие преподавателя {} на период {}–{}, статус {}", + teacher.getId(), + request.startDate(), + request.endDate(), + saved.getStatus() + ); + return toDto(saved); + } + + @Transactional + public TeacherAbsenceDto review(Long id, TeacherAbsenceReviewRequest request) { + AuthenticatedUser current = currentUser(); + if (current.role() == Role.TEACHER) { + throw forbidden("Преподаватель не может согласовывать собственное отсутствие"); + } + if (request == null || request.approved() == null) { + throw new IllegalArgumentException("Укажите решение по заявке на отсутствие"); + } + TeacherAbsence absence = getForUpdate(id); + ensureCanManage(absence, current); + if (!"PENDING".equals(absence.getStatus())) { + throw new IllegalArgumentException("Рассмотреть можно только отсутствие со статусом PENDING"); + } + var now = businessTime.now(); + absence.setStatus(Boolean.TRUE.equals(request.approved()) ? "APPROVED" : "REJECTED"); + absence.setReviewedBy(current.id()); + absence.setReviewedAt(now); + absence.setReviewComment(clean(request.comment())); + absence.setUpdatedAt(now); + TeacherAbsence saved = absenceRepository.saveAndFlush(absence); + log.info("Отсутствие {} переведено в статус {}", id, saved.getStatus()); + return toDto(saved); + } + + @Transactional + public TeacherAbsenceDto cancel(Long id) { + AuthenticatedUser current = currentUser(); + TeacherAbsence absence = getForUpdate(id); + if (current.role() == Role.TEACHER) { + if (!Objects.equals(current.id(), absence.getTeacher().getId())) { + throw forbidden("Нельзя отменить отсутствие другого преподавателя"); + } + if (!"PENDING".equals(absence.getStatus())) { + throw new IllegalArgumentException("Преподаватель может отменить только ожидающую заявку"); + } + } else { + ensureCanManage(absence, current); + if (!Set.of("PENDING", "APPROVED").contains(absence.getStatus())) { + throw new IllegalArgumentException("Это отсутствие уже нельзя отменить"); + } + if (decisionRepository.countByAbsenceId(id) > 0) { + throw new IllegalArgumentException("Нельзя отменить отсутствие после применения решений"); + } + } + absence.setStatus("CANCELLED"); + absence.setUpdatedAt(businessTime.now()); + TeacherAbsence saved = absenceRepository.saveAndFlush(absence); + log.info("Отсутствие {} отменено", id); + return toDto(saved); + } + + @Transactional(readOnly = true) + public TeacherAbsenceWizardDto getWizard(Long id) { + TeacherAbsence absence = getVisible(id); + List decisions = decisionRepository.findByAbsenceIdWithDetails(id); + Map decisionsByLesson = decisions.stream() + .collect(Collectors.toMap( + decision -> new LessonKey( + decision.getBaseRuleSlot().getId(), + decision.getLessonDate() + ), + decision -> decision, + (first, second) -> second, + LinkedHashMap::new + )); + List lessons = affectedLessons(absence); + List affected = lessons.stream() + .map(lesson -> toAffectedLesson( + absence, + lesson, + decisionsByLesson.get(new LessonKey(lesson.scheduleRuleSlotId(), lesson.date())) + )) + .toList(); + return new TeacherAbsenceWizardDto( + toDto(absence), + affected, + decisions.stream().map(this::toDecisionLog).toList() + ); + } + + @Transactional + public TeacherAbsenceWizardDto resolve(Long id, TeacherAbsenceResolutionRequest request) { + AuthenticatedUser current = currentUser(); + if (current.role() == Role.TEACHER) { + throw forbidden("Преподаватель не может применять решения мастера замены"); + } + TeacherAbsence absence = getForUpdate(id); + ensureCanManage(absence, current); + if (!"APPROVED".equals(absence.getStatus())) { + throw new IllegalArgumentException("Мастер замены доступен только для согласованного отсутствия"); + } + + List requested = request == null + || request.decisions() == null + ? List.of() + : request.decisions(); + if (requested.size() > MAX_DECISIONS) { + throw new IllegalArgumentException("За один раз можно обработать не более 100 занятий"); + } + + Map affectedByKey = affectedLessons(absence).stream() + .collect(Collectors.toMap( + lesson -> new LessonKey(lesson.scheduleRuleSlotId(), lesson.date()), + lesson -> lesson, + (first, second) -> first, + LinkedHashMap::new + )); + Set processedKeys = decisionRepository.findByAbsenceIdWithDetails(id).stream() + .map(decision -> new LessonKey( + decision.getBaseRuleSlot().getId(), + decision.getLessonDate() + )) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (affectedByKey.isEmpty() && requested.isEmpty()) { + absence.setStatus("RESOLVED"); + absence.setUpdatedAt(businessTime.now()); + absenceRepository.saveAndFlush(absence); + return getWizard(id); + } + if (requested.isEmpty()) { + throw new IllegalArgumentException("Выберите хотя бы одно решение"); + } + + Set requestKeys = new LinkedHashSet<>(); + for (TeacherAbsenceResolutionRequest.Decision item : requested) { + validateDecisionKey(item); + LessonKey key = new LessonKey(item.baseRuleSlotId(), item.lessonDate()); + if (!requestKeys.add(key)) { + throw new IllegalArgumentException("Одно занятие нельзя обработать дважды"); + } + RenderedLessonDto lesson = affectedByKey.get(key); + if (lesson == null) { + throw new IllegalArgumentException("Занятие не относится к выбранному отсутствию"); + } + if (processedKeys.contains(key)) { + throw new ScheduleConflictException("Для занятия решение уже зафиксировано в журнале"); + } + applyDecision(absence, lesson, item, current.id()); + processedKeys.add(key); + } + + if (affectedByKey.keySet().stream().allMatch(processedKeys::contains)) { + absence.setStatus("RESOLVED"); + } + absence.setUpdatedAt(businessTime.now()); + absenceRepository.saveAndFlush(absence); + log.info( + "Для отсутствия {} обработано {} занятий, итоговый статус {}", + id, + requested.size(), + absence.getStatus() + ); + return getWizard(id); + } + + private void applyDecision( + TeacherAbsence absence, + RenderedLessonDto lesson, + TeacherAbsenceResolutionRequest.Decision request, + Long userId + ) { + String resolution = cleanUpper(request.resolution()); + if (resolution == null || !RESOLUTIONS.contains(resolution)) { + throw new IllegalArgumentException("Неизвестный способ разрешения занятия"); + } + if (lesson.scheduleOverrideId() != null && !"REJECT".equals(resolution)) { + throw new ScheduleConflictException( + "У занятия уже есть точечное изменение; откройте его в реестре или отклоните предложение" + ); + } + + ScheduleOverride savedOverride = null; + if (!"REJECT".equals(resolution)) { + ScheduleOverrideDto overrideRequest = buildOverrideRequest(absence, lesson, request, resolution); + validateQualifiedReplacement(absence, lesson, overrideRequest, resolution); + ScheduleOverrideDto saved = scheduleOverrideService.create(overrideRequest); + savedOverride = scheduleOverrideRepository.findById(saved.id()) + .orElseThrow(() -> new IllegalStateException( + "Созданное точечное изменение не найдено для записи в журнал" + )); + } + + ScheduleRuleSlot baseSlot = scheduleRuleSlotRepository.findById(request.baseRuleSlotId()) + .orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден")); + TeacherAbsenceDecision decision = new TeacherAbsenceDecision(); + decision.setAbsence(absence); + decision.setBaseRuleSlot(baseSlot); + decision.setLessonDate(request.lessonDate()); + decision.setResolution(resolution); + decision.setDecisionStatus("REJECT".equals(resolution) ? "REJECTED" : "APPLIED"); + decision.setScheduleOverride(savedOverride); + decision.setComment(clean(request.comment())); + decision.setDecidedBy(userId); + decision.setDecidedAt(businessTime.now()); + decisionRepository.saveAndFlush(decision); + } + + private ScheduleOverrideDto buildOverrideRequest( + TeacherAbsence absence, + RenderedLessonDto lesson, + TeacherAbsenceResolutionRequest.Decision request, + String resolution + ) { + String action; + LocalDate targetDate = null; + Long timeSlotId = null; + Long classroomId = null; + Long teacherId = null; + switch (resolution) { + case "REPLACE_TEACHER" -> { + action = "REPLACE"; + teacherId = required(request.newTeacherId(), "Выберите преподавателя для замены"); + classroomId = request.newClassroomId(); + } + case "CHANGE_CLASSROOM" -> { + action = "REPLACE"; + classroomId = required(request.newClassroomId(), "Выберите новую аудиторию"); + } + case "MOVE_TIME" -> { + action = "MOVE"; + timeSlotId = required(request.newTimeSlotId(), "Выберите новое время занятия"); + targetDate = request.targetLessonDate(); + classroomId = request.newClassroomId(); + if (targetDate == null + || (!targetDate.isBefore(absence.getStartDate()) + && !targetDate.isAfter(absence.getEndDate()))) { + throw new IllegalArgumentException( + "Перенесите занятие на дату вне периода отсутствия преподавателя" + ); + } + } + case "CANCEL" -> action = "CANCEL"; + default -> throw new IllegalArgumentException("Неизвестный способ разрешения занятия"); + } + return new ScheduleOverrideDto( + null, + lesson.scheduleRuleSlotId(), + lesson.date(), + targetDate, + action, + timeSlotId, + classroomId, + teacherId, + null, + "Отсутствие №" + absence.getId() + ": " + absence.getReason(), + null, + null + ); + } + + private void validateQualifiedReplacement( + TeacherAbsence absence, + RenderedLessonDto lesson, + ScheduleOverrideDto override, + String resolution + ) { + if (!"REPLACE_TEACHER".equals(resolution)) { + return; + } + Long teacherId = override.newTeacherId(); + List qualifiedIds = qualifiedTeacherIds(lesson); + if (!qualifiedIds.contains(teacherId)) { + throw new IllegalArgumentException( + "Выбранный преподаватель не подтверждён для дисциплины и типа занятия" + ); + } + User replacement = userRepository.findById(teacherId) + .filter(user -> user.getRole() == Role.TEACHER) + .filter(user -> user.isActiveOn(lesson.date())) + .orElseThrow(() -> new IllegalArgumentException("Активный преподаватель для замены не найден")); + if (Objects.equals(replacement.getId(), absence.getTeacher().getId()) + || absenceRepository.isTeacherAbsentOn(replacement.getId(), lesson.date())) { + throw new IllegalArgumentException("Выбранный преподаватель отсутствует в дату занятия"); + } + } + + private TeacherAbsenceWizardDto.AffectedLesson toAffectedLesson( + TeacherAbsence absence, + RenderedLessonDto lesson, + TeacherAbsenceDecision decision + ) { + AuthenticatedUser current = currentUser(); + boolean canSuggest = "APPROVED".equals(absence.getStatus()) + && current.role() != Role.TEACHER + && decision == null + && lesson.scheduleOverrideId() == null; + return new TeacherAbsenceWizardDto.AffectedLesson( + lesson.scheduleRuleSlotId(), + lesson.date(), + lesson.startTime(), + lesson.endTime(), + lesson.subjectId(), + lesson.subjectName(), + lesson.lessonTypeId(), + lesson.lessonTypeName(), + lesson.classroomId(), + lesson.classroomName(), + lesson.groupNames() == null ? List.of() : lesson.groupNames(), + lesson.scheduleOverrideId(), + decision == null ? null : decision.getDecisionStatus(), + canSuggest ? teacherCandidates(absence, lesson) : List.of(), + canSuggest ? moveCandidates(absence, lesson) : List.of(), + canSuggest ? classroomCandidates(absence, lesson) : List.of(), + decision == null + ); + } + + private List teacherCandidates( + TeacherAbsence absence, + RenderedLessonDto lesson + ) { + List ids = qualifiedTeacherIds(lesson); + if (ids.isEmpty()) { + return List.of(); + } + return userRepository.findAllById(ids).stream() + .filter(user -> user.getRole() == Role.TEACHER) + .filter(user -> user.isActiveOn(lesson.date())) + .filter(user -> !Objects.equals(user.getId(), absence.getTeacher().getId())) + .filter(user -> !absenceRepository.isTeacherAbsentOn(user.getId(), lesson.date())) + .sorted(Comparator.comparing(User::getFullName, Comparator.nullsLast(String::compareTo))) + .filter(user -> candidateIsValid(new ScheduleOverrideDto( + null, + lesson.scheduleRuleSlotId(), + lesson.date(), + "REPLACE", + null, + null, + user.getId(), + null, + "Предпросмотр замены преподавателя", + null, + null + ))) + .limit(CANDIDATE_LIMIT) + .map(user -> new TeacherAbsenceWizardDto.Candidate( + user.getId(), + displayName(user), + user.getJobTitle() + )) + .toList(); + } + + private List qualifiedTeacherIds(RenderedLessonDto lesson) { + if (lesson.subjectId() == null || lesson.lessonTypeId() == null) { + return List.of(); + } + return teacherSubjectRepository.findQualifiedTeacherIds( + lesson.subjectId(), + lesson.lessonTypeId() + ); + } + + private List moveCandidates( + TeacherAbsence absence, + RenderedLessonDto lesson + ) { + List dates; + try { + dates = scheduleOverrideService.getAvailability( + lesson.scheduleRuleSlotId(), + lesson.date() + ).availableDates(); + } catch (RuntimeException exception) { + log.debug( + "Не удалось построить даты переноса для слота {}: {}", + lesson.scheduleRuleSlotId(), + exception.getMessage() + ); + return List.of(); + } + List nearestDates = dates.stream() + .filter(date -> date.isBefore(absence.getStartDate()) || date.isAfter(absence.getEndDate())) + .sorted(Comparator + .comparingLong((LocalDate date) -> Math.abs(ChronoUnit.DAYS.between(lesson.date(), date))) + .thenComparing(LocalDate::compareTo)) + .limit(7) + .toList(); + List slots = timeSlotRepository.findAllForSchedule(); + List result = new ArrayList<>(); + for (LocalDate date : nearestDates) { + for (TimeSlot slot : slots) { + if (candidateIsValid(new ScheduleOverrideDto( + null, + lesson.scheduleRuleSlotId(), + lesson.date(), + date, + "MOVE", + slot.getId(), + null, + null, + null, + "Предпросмотр переноса из-за отсутствия преподавателя", + null, + null + ))) { + result.add(new TeacherAbsenceWizardDto.MoveCandidate( + date, + slot.getId(), + slot.getOrderNumber(), + slot.getStartTime(), + slot.getEndTime(), + "Ближайшее допустимое время" + )); + if (result.size() >= CANDIDATE_LIMIT) { + return result; + } + } + } + } + return result; + } + + private List classroomCandidates( + TeacherAbsence absence, + RenderedLessonDto lesson + ) { + return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream() + .filter(classroom -> !Objects.equals(classroom.getId(), lesson.classroomId())) + .filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable())) + .filter(classroom -> classroom.isActiveOn(lesson.date())) + .sorted(Comparator.comparing(Classroom::getName, Comparator.nullsLast(String::compareTo))) + .filter(classroom -> candidateIsValid(new ScheduleOverrideDto( + null, + lesson.scheduleRuleSlotId(), + lesson.date(), + "REPLACE", + null, + classroom.getId(), + null, + null, + "Предпросмотр замены аудитории", + null, + null + ))) + .limit(CANDIDATE_LIMIT) + .map(classroom -> new TeacherAbsenceWizardDto.Candidate( + classroom.getId(), + classroom.getName(), + classroom.getBuilding() + )) + .toList(); + } + + private boolean candidateIsValid(ScheduleOverrideDto request) { + try { + scheduleOverrideService.validateCandidate(request); + return true; + } catch (RuntimeException exception) { + return false; + } + } + + private List affectedLessons(TeacherAbsence absence) { + return scheduleQueryService.search( + null, + absence.getTeacher().getId(), + null, + null, + null, + null, + null, + null, + absence.getStartDate(), + absence.getEndDate() + ).stream() + .filter(lesson -> lesson.scheduleRuleSlotId() != null) + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing( + RenderedLessonDto::startTime, + Comparator.nullsLast(Comparator.naturalOrder()) + ) + .thenComparing( + RenderedLessonDto::subjectName, + Comparator.nullsLast(String::compareTo) + )) + .toList(); + } + + private TeacherAbsence getVisible(Long id) { + TeacherAbsence absence = absenceRepository.findByIdWithTeacher(id) + .orElseThrow(() -> notFound(id)); + if (!canView(absence)) { + throw forbidden("Недостаточно прав для просмотра отсутствия"); + } + return absence; + } + + private TeacherAbsence getForUpdate(Long id) { + return absenceRepository.findByIdForUpdate(id) + .orElseThrow(() -> notFound(id)); + } + + private boolean canView(TeacherAbsence absence) { + AuthenticatedUser current = currentUser(); + if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) { + return true; + } + if (current.role() == Role.TEACHER) { + return Objects.equals(current.id(), absence.getTeacher().getId()); + } + return current.role() == Role.DEPARTMENT + && belongsToDepartment(absence, current.departmentId()); + } + + private void ensureCanManage(TeacherAbsence absence, AuthenticatedUser current) { + if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) { + return; + } + if (current.role() == Role.DEPARTMENT && belongsToDepartment(absence, current.departmentId())) { + return; + } + throw forbidden("Недостаточно прав для изменения отсутствия"); + } + + private void ensureDepartmentScope( + User teacher, + LocalDate startDate, + LocalDate endDate, + AuthenticatedUser current + ) { + if (current.role() != Role.DEPARTMENT) { + return; + } + if (current.departmentId() == null + || assignmentRepository.findForTeachersBetween( + Set.of(teacher.getId()), + startDate, + endDate + ).stream().noneMatch(assignment -> Objects.equals( + assignment.getDepartment().getId(), + current.departmentId() + ))) { + throw forbidden("Кафедра может регистрировать отсутствие только своего преподавателя"); + } + } + + private boolean belongsToDepartment(TeacherAbsence absence, Long departmentId) { + return departmentId != null + && assignmentRepository.findForTeachersBetween( + Set.of(absence.getTeacher().getId()), + absence.getStartDate(), + absence.getEndDate() + ).stream().anyMatch(assignment -> Objects.equals( + assignment.getDepartment().getId(), + departmentId + )); + } + + private TeacherAbsenceDto toDto(TeacherAbsence absence) { + User teacher = absence.getTeacher(); + return new TeacherAbsenceDto( + absence.getId(), + teacher == null ? null : teacher.getId(), + teacher == null ? null : displayName(teacher), + teacher == null ? null : teacher.getDepartmentId(), + absence.getStartDate(), + absence.getEndDate(), + absence.getReason(), + absence.getStatus(), + absence.getRequestedBy(), + absence.getReviewedBy(), + absence.getReviewComment(), + absence.getReviewedAt(), + absence.getCreatedAt(), + absence.getUpdatedAt(), + absence.getId() == null ? 0 : decisionRepository.countByAbsenceId(absence.getId()) + ); + } + + private TeacherAbsenceWizardDto.DecisionLog toDecisionLog(TeacherAbsenceDecision decision) { + return new TeacherAbsenceWizardDto.DecisionLog( + decision.getId(), + decision.getBaseRuleSlot().getId(), + decision.getLessonDate(), + decision.getResolution(), + decision.getDecisionStatus(), + decision.getScheduleOverride() == null ? null : decision.getScheduleOverride().getId(), + decision.getComment(), + decision.getDecidedBy(), + decision.getDecidedAt() + ); + } + + private void validateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("Дата начала и дата окончания отсутствия обязательны"); + } + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("Дата окончания отсутствия не может быть раньше даты начала"); + } + if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) { + throw new IllegalArgumentException("Период одного отсутствия не может превышать 120 дней"); + } + } + + private void validateDecisionKey(TeacherAbsenceResolutionRequest.Decision decision) { + if (decision == null || decision.baseRuleSlotId() == null || decision.lessonDate() == null) { + throw new IllegalArgumentException("Для решения укажите занятие и исходную дату"); + } + } + + 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 NoSuchElementException notFound(Long id) { + return new NoSuchElementException("Отсутствие преподавателя с идентификатором " + id + " не найдено"); + } + + private T required(T value, String message) { + if (value == null) { + throw new IllegalArgumentException(message); + } + return value; + } + + 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 String displayName(User user) { + if (user == null) { + return "Преподаватель не указан"; + } + if (user.getFullName() != null && !user.getFullName().isBlank()) { + return user.getFullName(); + } + return user.getUsername(); + } + + private record LessonKey(Long baseRuleSlotId, LocalDate lessonDate) { + } +} diff --git a/backend/src/main/resources/db/migration/V2__teacher_absences_and_replacement_wizard.sql b/backend/src/main/resources/db/migration/V2__teacher_absences_and_replacement_wizard.sql new file mode 100644 index 0000000..7e4333b --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__teacher_absences_and_replacement_wizard.sql @@ -0,0 +1,59 @@ +-- Реестр временных отсутствий преподавателей и журнал решений мастера замены. + +CREATE TABLE teacher_absences ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id), + start_date DATE NOT NULL, + end_date DATE NOT NULL, + reason TEXT NOT NULL, + 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_absences_period CHECK (end_date >= start_date), + CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000), + CONSTRAINT chk_teacher_absences_status CHECK ( + status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED') + ) +); + +CREATE INDEX idx_teacher_absences_teacher_period + ON teacher_absences(teacher_id, start_date, end_date); + +CREATE INDEX idx_teacher_absences_status + ON teacher_absences(status); + +CREATE TABLE teacher_absence_decisions ( + id BIGSERIAL PRIMARY KEY, + absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE, + base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id), + lesson_date DATE NOT NULL, + resolution VARCHAR(30) NOT NULL, + decision_status VARCHAR(20) NOT NULL, + schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL, + comment TEXT, + decided_by BIGINT REFERENCES users(id), + decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE ( + absence_id, + base_rule_slot_id, + lesson_date + ), + CONSTRAINT chk_teacher_absence_decision_resolution CHECK ( + resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT') + ), + CONSTRAINT chk_teacher_absence_decision_status CHECK ( + decision_status IN ('APPLIED', 'REJECTED') + ) +); + +CREATE INDEX idx_teacher_absence_decisions_absence + ON teacher_absence_decisions(absence_id, lesson_date); + +COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой'; +COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED'; +COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены'; +COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия'; diff --git a/backend/src/test/java/com/magistr/app/controller/TeacherAbsenceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/TeacherAbsenceControllerTest.java new file mode 100644 index 0000000..446ad2e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/TeacherAbsenceControllerTest.java @@ -0,0 +1,136 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.TeacherAbsenceDto; +import com.magistr.app.dto.TeacherAbsenceWizardDto; +import com.magistr.app.service.TeacherAbsenceService; +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.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 TeacherAbsenceControllerTest { + + private TeacherAbsenceService service; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + service = mock(TeacherAbsenceService.class); + mockMvc = standaloneSetup(new TeacherAbsenceController(service)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void createsAbsenceWithRussianReason() throws Exception { + when(service.create(any())).thenReturn(absence("APPROVED")); + + mockMvc.perform(post("/api/teacher-absences") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "teacherId": 11, + "startDate": "2026-09-10", + "endDate": "2026-09-12", + "reason": "Командировка" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.teacherName").value("Иванов Иван Иванович")) + .andExpect(jsonPath("$.status").value("APPROVED")); + } + + @Test + void returnsWizardWithAffectedLessonsAndJournal() throws Exception { + TeacherAbsenceWizardDto wizard = new TeacherAbsenceWizardDto( + absence("APPROVED"), + List.of(new TeacherAbsenceWizardDto.AffectedLesson( + 501L, + LocalDate.of(2026, 9, 10), + java.time.LocalTime.of(9, 0), + java.time.LocalTime.of(10, 30), + 40L, + "Базы данных", + 3L, + "Лекция", + 8L, + "101", + List.of("ИВТ-21"), + null, + null, + List.of(), + List.of(), + List.of(), + true + )), + List.of() + ); + when(service.getWizard(101L)).thenReturn(wizard); + + mockMvc.perform(get("/api/teacher-absences/101/wizard")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessons[0].subjectName").value("Базы данных")) + .andExpect(jsonPath("$.lessons[0].cancellationAvailable").value(true)); + + verify(service).getWizard(101L); + } + + @Test + void appliesSelectedDecisionsAsBatch() throws Exception { + TeacherAbsenceWizardDto wizard = new TeacherAbsenceWizardDto( + absence("RESOLVED"), + List.of(), + List.of() + ); + when(service.resolve(eq(101L), any())).thenReturn(wizard); + + mockMvc.perform(post("/api/teacher-absences/101/resolve") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "decisions": [{ + "baseRuleSlotId": 501, + "lessonDate": "2026-09-10", + "resolution": "CANCEL" + }] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.absence.status").value("RESOLVED")); + } + + private TeacherAbsenceDto absence(String status) { + return new TeacherAbsenceDto( + 101L, + 11L, + "Иванов Иван Иванович", + 7L, + LocalDate.of(2026, 9, 10), + LocalDate.of(2026, 9, 12), + "Командировка", + status, + 1L, + 1L, + null, + Instant.parse("2026-08-06T10:00:00Z"), + Instant.parse("2026-08-06T10:00:00Z"), + Instant.parse("2026-08-06T10:00:00Z"), + 0 + ); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/TeacherAbsenceServiceTest.java b/backend/src/test/java/com/magistr/app/service/TeacherAbsenceServiceTest.java new file mode 100644 index 0000000..ab4da1a --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/TeacherAbsenceServiceTest.java @@ -0,0 +1,292 @@ +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.TeacherAbsenceDto; +import com.magistr.app.dto.TeacherAbsenceRequest; +import com.magistr.app.dto.TeacherAbsenceResolutionRequest; +import com.magistr.app.model.Role; +import com.magistr.app.model.ScheduleParity; +import com.magistr.app.model.ScheduleRuleSlot; +import com.magistr.app.model.TeacherAbsence; +import com.magistr.app.model.TeacherAbsenceDecision; +import com.magistr.app.model.User; +import com.magistr.app.repository.ClassroomRepository; +import com.magistr.app.repository.ScheduleOverrideRepository; +import com.magistr.app.repository.ScheduleRuleSlotRepository; +import com.magistr.app.repository.TeacherAbsenceDecisionRepository; +import com.magistr.app.repository.TeacherAbsenceRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; +import com.magistr.app.repository.TeacherSubjectRepository; +import com.magistr.app.repository.TimeSlotRepository; +import com.magistr.app.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TeacherAbsenceServiceTest { + + private static final LocalDate START = LocalDate.of(2026, 9, 10); + private static final LocalDate END = LocalDate.of(2026, 9, 12); + private static final Instant NOW = Instant.parse("2026-08-06T10:00:00Z"); + + @Mock + private TeacherAbsenceRepository absenceRepository; + @Mock + private TeacherAbsenceDecisionRepository decisionRepository; + @Mock + private UserRepository userRepository; + @Mock + private TeacherSubjectRepository teacherSubjectRepository; + @Mock + private TeacherDepartmentAssignmentRepository assignmentRepository; + @Mock + private ClassroomRepository classroomRepository; + @Mock + private TimeSlotRepository timeSlotRepository; + @Mock + private ScheduleRuleSlotRepository scheduleRuleSlotRepository; + @Mock + private ScheduleOverrideRepository scheduleOverrideRepository; + @Mock + private ScheduleQueryService scheduleQueryService; + @Mock + private ScheduleOverrideService scheduleOverrideService; + + private TeacherAbsenceService service; + private User teacher; + + @BeforeEach + void setUp() { + teacher = new User(); + teacher.setId(11L); + teacher.setUsername("teacher"); + teacher.setFullName("Иванов Иван Иванович"); + teacher.setRole(Role.TEACHER); + teacher.setDepartmentId(7L); + + BusinessTimeService time = new BusinessTimeService( + Clock.fixed(NOW, ZoneOffset.UTC), + BusinessTimeService.DEFAULT_ZONE + ); + service = new TeacherAbsenceService( + absenceRepository, + decisionRepository, + userRepository, + teacherSubjectRepository, + assignmentRepository, + classroomRepository, + timeSlotRepository, + scheduleRuleSlotRepository, + scheduleOverrideRepository, + scheduleQueryService, + scheduleOverrideService, + time + ); + when(userRepository.findByIdForUpdate(teacher.getId())).thenReturn(Optional.of(teacher)); + when(absenceRepository.existsOverlapping(any(), anyCollection(), any(), any())).thenReturn(false); + when(absenceRepository.saveAndFlush(any(TeacherAbsence.class))).thenAnswer(invocation -> { + TeacherAbsence absence = invocation.getArgument(0); + ReflectionTestUtils.setField(absence, "id", 101L); + return absence; + }); + } + + @AfterEach + void tearDown() { + AuthContext.clear(); + } + + @Test + void teacherCreatesPendingRequestOnlyForSelf() { + AuthContext.setCurrentUser(new AuthenticatedUser(11L, "teacher", Role.TEACHER, 7L)); + + TeacherAbsenceDto result = service.create(new TeacherAbsenceRequest( + null, + START, + END, + " Болезнь " + )); + + assertThat(result.status()).isEqualTo("PENDING"); + assertThat(result.teacherId()).isEqualTo(11L); + assertThat(result.reason()).isEqualTo("Болезнь"); + assertThat(result.requestedBy()).isEqualTo(11L); + verify(userRepository).findByIdForUpdate(11L); + } + + @Test + void responsibleEmployeeCreatesApprovedAbsence() { + AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null)); + + TeacherAbsenceDto result = service.create(new TeacherAbsenceRequest( + 11L, + START, + END, + "Командировка" + )); + + assertThat(result.status()).isEqualTo("APPROVED"); + assertThat(result.reviewedBy()).isEqualTo(1L); + assertThat(result.reviewedAt()).isEqualTo(NOW); + } + + @Test + void rejectsOverlappingActiveAbsence() { + AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null)); + when(absenceRepository.existsOverlapping(any(), anyCollection(), any(), any())).thenReturn(true); + + assertThatThrownBy(() -> service.create(new TeacherAbsenceRequest( + 11L, + START, + END, + "Командировка" + ))) + .isInstanceOf(ScheduleConflictException.class) + .hasMessageContaining("пересекающееся"); + } + + @Test + void teacherCannotResolveReplacementWizard() { + AuthContext.setCurrentUser(new AuthenticatedUser(11L, "teacher", Role.TEACHER, 7L)); + + assertThatThrownBy(() -> service.resolve( + 101L, + new TeacherAbsenceResolutionRequest(List.of()) + )) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("не может применять решения"); + } + + @Test + void absenceWithoutAffectedLessonsCanBeCompleted() { + AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null)); + TeacherAbsence absence = absence("APPROVED"); + when(absenceRepository.findByIdForUpdate(101L)).thenReturn(Optional.of(absence)); + when(absenceRepository.findByIdWithTeacher(101L)).thenReturn(Optional.of(absence)); + when(decisionRepository.findByAbsenceIdWithDetails(101L)).thenReturn(List.of()); + when(scheduleQueryService.search( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any() + )).thenReturn(List.of()); + + var result = service.resolve(101L, new TeacherAbsenceResolutionRequest(List.of())); + + assertThat(result.absence().status()).isEqualTo("RESOLVED"); + verify(absenceRepository).saveAndFlush(absence); + } + + @Test + void previousRejectedDecisionCountsWhenRemainingLessonsAreResolved() { + AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null)); + TeacherAbsence absence = absence("APPROVED"); + ScheduleRuleSlot rejectedSlot = new ScheduleRuleSlot(); + rejectedSlot.setId(201L); + TeacherAbsenceDecision rejected = new TeacherAbsenceDecision(); + rejected.setBaseRuleSlot(rejectedSlot); + rejected.setLessonDate(START); + rejected.setResolution("REJECT"); + rejected.setDecisionStatus("REJECTED"); + rejected.setDecidedAt(NOW); + ScheduleRuleSlot remainingSlot = new ScheduleRuleSlot(); + remainingSlot.setId(202L); + + when(absenceRepository.findByIdForUpdate(101L)).thenReturn(Optional.of(absence)); + when(absenceRepository.findByIdWithTeacher(101L)).thenReturn(Optional.of(absence)); + when(decisionRepository.findByAbsenceIdWithDetails(101L)).thenReturn(List.of(rejected)); + when(scheduleRuleSlotRepository.findById(202L)).thenReturn(Optional.of(remainingSlot)); + when(scheduleQueryService.search( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any() + )).thenReturn(List.of(lesson(201L, START), lesson(202L, END))); + + var result = service.resolve(101L, new TeacherAbsenceResolutionRequest(List.of( + new TeacherAbsenceResolutionRequest.Decision( + 202L, + END, + "REJECT", + null, + null, + null, + null, + "Решение будет принято вручную" + ) + ))); + + assertThat(result.absence().status()).isEqualTo("RESOLVED"); + verify(decisionRepository).saveAndFlush(any(TeacherAbsenceDecision.class)); + } + + private RenderedLessonDto lesson(Long slotId, LocalDate date) { + return new RenderedLessonDto( + 301L, + slotId, + date, + date.getDayOfWeek().getValue(), + "День недели", + 2, + ScheduleParity.BOTH, + 401L, + 1, + LocalTime.of(9, 0), + LocalTime.of(10, 30), + 501L, + "Математика", + teacher.getId(), + teacher.getFullName(), + 601L, + "101", + 701L, + "Лекция", + "Очно", + null, + null, + List.of(), + List.of(), + List.of(801L), + List.of("ИВТ-101"), + "EDUCATION", + 2, + 0, + 2, + ScheduleParity.BOTH + ); + } + + private TeacherAbsence absence(String status) { + TeacherAbsence absence = new TeacherAbsence(); + ReflectionTestUtils.setField(absence, "id", 101L); + absence.setTeacher(teacher); + absence.setStartDate(START); + absence.setEndDate(END); + absence.setReason("Болезнь"); + absence.setStatus(status); + absence.setRequestedBy(1L); + absence.setCreatedAt(NOW); + absence.setUpdatedAt(NOW); + return absence; + } +} diff --git a/docs/API.md b/docs/API.md index 85394ba..9f3358e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -841,6 +841,77 @@ API возвращает `409 Conflict`; соседние интервалы и } ``` +### Отсутствия преподавателей и мастер замены + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/teacher-absences?status=` | Доступный текущей роли реестр отсутствий | +| `POST` | `/api/teacher-absences` | Зарегистрировать отсутствие или отправить преподавательскую заявку | +| `POST` | `/api/teacher-absences/{id}/review` | Подтвердить или отклонить заявку преподавателя | +| `DELETE` | `/api/teacher-absences/{id}` | Отменить ожидающее или ещё не обработанное отсутствие | +| `GET` | `/api/teacher-absences/{id}/wizard` | Затронутые занятия, проверенные кандидаты и журнал решений | +| `POST` | `/api/teacher-absences/{id}/resolve` | Атомарно применить выбранные решения | + +Доступ имеют `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT` и `TEACHER`. Преподаватель видит и +создаёт только собственные заявки; они получают статус `PENDING`. Кафедра работает только с +преподавателями, назначенными ей на период отсутствия. Ответственный сотрудник создаёт +сразу согласованную запись `APPROVED`, а кафедра, учебный отдел или администратор могут +перевести преподавательскую заявку в `APPROVED` либо `REJECTED`. + +```json +{ + "teacherId": 15, + "startDate": "2026-09-10", + "endDate": "2026-09-12", + "reason": "Командировка" +} +``` + +Для роли `TEACHER` поле `teacherId` игнорируется в пользу ID текущего пользователя. Период +включительный, не длиннее 120 дней и не может пересекаться с другой активной записью того +же преподавателя. + +`GET /{id}/wizard` ищет фактические занятия через `ScheduleQueryService`. Кандидат на замену +должен иметь связь с дисциплиной; если для этой связи настроены `teacher_lesson_types`, тип +занятия также обязан совпасть. Из списка исключаются архивные, занятые и отсутствующие +преподаватели. Варианты переноса выбираются среди ближайших учебных дат вне периода +отсутствия, аудитории — среди активных и свободных. Каждый кандидат заранее проходит тот же +валидатор `ScheduleOverrideService`, что и обычная разовая правка. + +Пример группового подтверждения: + +```json +{ + "decisions": [ + { + "baseRuleSlotId": 31, + "lessonDate": "2026-09-10", + "resolution": "REPLACE_TEACHER", + "newTeacherId": 28, + "comment": "Согласовано кафедрой" + }, + { + "baseRuleSlotId": 44, + "lessonDate": "2026-09-11", + "resolution": "MOVE_TIME", + "targetLessonDate": "2026-09-14", + "newTimeSlotId": 3 + }, + { + "baseRuleSlotId": 52, + "lessonDate": "2026-09-12", + "resolution": "REJECT" + } + ] +} +``` + +Допустимые решения: `REPLACE_TEACHER`, `MOVE_TIME`, `CHANGE_CLASSROOM`, `CANCEL`, `REJECT`. +Первые четыре создают обычный `schedule_override`; `REJECT` фиксирует осознанное отклонение +без изменения расписания. Весь пакет выполняется в одной транзакции: конфликт любого +выбранного действия возвращает `409` и откатывает остальные. Незаполненные занятия не +меняются. Когда обработаны все оставшиеся занятия, отсутствие получает статус `RESOLVED`. + ## Загруженность | Метод | URL | Назначение | diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index c27634e..52f3d93 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -312,6 +312,35 @@ constraint. При чтении API разворачивает периоды о для backend-pod, поэтому два конкурентных переноса с разных исходных дат на одну целевую дату и одинаковые ресурсы проверяются последовательно. +### Отсутствия преподавателей и мастер замены + +Отсутствие хранится отдельно от базовых правил расписания и проходит жизненный цикл +`PENDING → APPROVED → RESOLVED`; отклонённые и отменённые записи получают соответственно +`REJECTED` и `CANCELLED`. Преподаватель создаёт заявку только для себя со статусом +`PENDING`. Администратор, учебный отдел и кафедра могут регистрировать согласованное +отсутствие сразу. Кафедра видит и подтверждает только преподавателей, назначенных ей на +выбранный период через `teacher_department_assignments`. + +Для согласованного отсутствия `ScheduleQueryService` строит фактические занятия +преподавателя в пределах периода. Мастер предлагает не более пяти проверенных вариантов +каждого типа: + +- преподавателей со связью по дисциплине и допустимому типу занятия; +- ближайшие учебные даты вне периода отсутствия и эффективные временные слоты; +- активные свободные аудитории; +- отмену занятия или явное отклонение предложений. + +Если у связи преподавателя с дисциплиной нет настроенных строк `teacher_lesson_types`, она +считается разрешающей все типы; при наличии настроек требуется точное совпадение типа. +Кандидаты-замены исключаются, если архивированы, заняты или сами отсутствуют в дату пары. +Каждый вариант до показа проходит read-only проверку `ScheduleOverrideService`, а выбранный +пакет повторно валидируется и сохраняется в одной транзакции. Поэтому время, аудитория, +группы и подгруппы проверяются тем же механизмом, что и ручные точечные изменения. + +Применённые действия создают обычные `schedule_overrides`; базовое правило семестра не +изменяется. Таблица `teacher_absence_decisions` фиксирует как применённые, так и отклонённые +решения. Незаполненные строки мастер не меняет, и к ним можно вернуться позже. + --- ## Привязка преподаватель ↔ дисциплина @@ -335,6 +364,9 @@ constraint. При чтении API разворачивает периоды о - **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории ### Управление инцидентами -- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода -- Автоматическая подсветка конфликтующих пар (Red Zone) -- Resolution Wizard: предложение замены преподавателя или переноса занятия + +Регистрация отсутствий и Resolution Wizard реализованы. Дальнейшее развитие этого контура: + +- автоматическая рассылка уведомлений группам и преподавателям; +- пакетная обработка нескольких одновременных отсутствий; +- ранжирование вариантов по окнам и дополнительной нагрузке. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 86fba7c..b06978c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -334,6 +334,34 @@ erDiagram TEXT comment } + teacher_absences { + BIGSERIAL id PK + BIGINT teacher_id FK + DATE start_date + DATE end_date + TEXT reason + VARCHAR status + BIGINT requested_by FK + BIGINT reviewed_by FK + TEXT review_comment + TIMESTAMPTZ reviewed_at + TIMESTAMPTZ created_at + TIMESTAMPTZ updated_at + } + + teacher_absence_decisions { + BIGSERIAL id PK + BIGINT absence_id FK + BIGINT base_rule_slot_id FK + DATE lesson_date + VARCHAR resolution + VARCHAR decision_status + BIGINT schedule_override_id FK + TEXT comment + BIGINT decided_by FK + TIMESTAMPTZ decided_at + } + schedule_rule_slot_subgroups { BIGINT schedule_rule_slot_id FK,PK BIGINT subgroup_id FK,PK @@ -385,6 +413,11 @@ erDiagram time_slots ||--o{ schedule_overrides : "new_time_slot_id" classrooms ||--o{ schedule_overrides : "new_classroom_id" users ||--o{ schedule_overrides : "new_teacher_id" + users ||--o{ teacher_absences : "teacher_id/requested_by/reviewed_by" + teacher_absences ||--o{ teacher_absence_decisions : "absence_id" + schedule_rule_slots ||--o{ teacher_absence_decisions : "base_rule_slot_id" + schedule_overrides ||--o{ teacher_absence_decisions : "schedule_override_id" + users ||--o{ teacher_absence_decisions : "decided_by" time_slot_scopes ||--o{ time_slots : "time_slot_scope_id" time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id" time_slots ||--o{ schedule_rule_slots : "time_slot_id" @@ -905,6 +938,44 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр lifecycle ресурсов, эффективная сетка целевого дня, реальность изменения и ресурсные конфликты проверяются транзакционным сервисом, а не SQL CHECK. +#### `teacher_absences` — Отсутствия преподавателей + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID отсутствия | +| `teacher_id` | BIGINT FK → users | Отсутствующий преподаватель | +| `start_date`, `end_date` | DATE | Включительные границы периода | +| `reason` | TEXT | Причина длиной 1–2000 символов | +| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `RESOLVED`, `CANCELLED` | +| `requested_by` | BIGINT FK → users | Автор регистрации или заявки | +| `reviewed_by` | BIGINT FK → users | Подтвердивший или отклонивший сотрудник | +| `review_comment` | TEXT | Комментарий согласования | +| `reviewed_at` | TIMESTAMPTZ | Время согласования | +| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла | + +CHECK запрещает обратный период, пустую или слишком длинную причину и неизвестный статус. +Индекс `(teacher_id, start_date, end_date)` ускоряет поиск пересекающихся отсутствий и +проверку кандидатов мастера. + +#### `teacher_absence_decisions` — Журнал мастера замены + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID записи журнала | +| `absence_id` | BIGINT FK → teacher_absences | Инцидент отсутствия | +| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот занятия | +| `lesson_date` | DATE | Исходная дата занятия | +| `resolution` | VARCHAR(30) | Замена, перенос, аудитория, отмена или отклонение | +| `decision_status` | VARCHAR(20) | `APPLIED` либо `REJECTED` | +| `schedule_override_id` | BIGINT FK → schedule_overrides | Созданная разовая правка, если есть | +| `comment` | TEXT | Комментарий ответственного сотрудника | +| `decided_by` | BIGINT FK → users | Автор решения | +| `decided_at` | TIMESTAMPTZ | Время решения | + +Уникальность `(absence_id, base_rule_slot_id, lesson_date)` не позволяет повторно обработать +одно занятие одного инцидента. При удалении override ссылка обнуляется, но аудиторская запись +сохраняется. + --- ## Flyway миграции @@ -924,12 +995,15 @@ lifecycle ресурсов, эффективная сетка целевого | Файл | Описание | |------|----------| | `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | +| `V2__teacher_absences_and_replacement_wizard.sql` | Реестр отсутствий преподавателей, статусы согласования и журнал применённых/отклонённых решений со ссылками на обычные `schedule_overrides` | ### Этап разработки -По прямому решению владельца проекта разработческие миграции V2–V7 объединены в baseline -`V1`. Интервальное хранение активностей и правильная нумерация недель календарного графика -также входят непосредственно в V1. +Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были +объединены в baseline `V1`. После фиксации baseline нумерация начата заново: текущая `V2` +добавляет отсутствия и мастер замены, не изменяя контрольную сумму `V1`. +Интервальное хранение активностей и правильная нумерация недель календарного графика входят +непосредственно в V1. Перед применением этой редакции требуется полностью пустая tenant-схема. ### Полный сброс БД (локально) diff --git a/docs/FEATURE_IDEAS.md b/docs/FEATURE_IDEAS.md index a2405b1..24aaf0f 100644 --- a/docs/FEATURE_IDEAS.md +++ b/docs/FEATURE_IDEAS.md @@ -53,6 +53,10 @@ ## 1. Отсутствия преподавателей и мастер замены +**Статус: реализовано в MVP.** Реестр, согласование преподавательских заявок, поиск +затронутых занятий, проверенные варианты, групповое применение `schedule_overrides` и журнал +решений доступны в системе. + ### Проблема и пользователи Болезнь, командировка или другое временное отсутствие преподавателя затрагивает сразу @@ -60,9 +64,8 @@ доступность ресурсов и создать каждое точечное изменение. Функция нужна учебному отделу, кафедрам и преподавателям. -Регистрация отсутствий и мастер разрешения инцидентов уже обозначены как планируемые -бизнес-правила в [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md), но отдельной модели и рабочего -сценария пока нет. Существующая Red Zone уже выявляет конфликты сформированного расписания; +Регистрация отсутствий и мастер разрешения инцидентов реализуют правила из +[BUSINESS_LOGIC.md](BUSINESS_LOGIC.md). Существующая Red Zone уже выявляет конфликты сформированного расписания; новизна этого направления заключается в реестре причин, массовом поиске затронутых занятий и управляемом подборе решений. @@ -504,4 +507,4 @@ Red Zone уже выявляет накладки и превышение вме **Ожидаемый результат:** новый самостоятельный контур учебного процесса на основе уже существующих справочников и календаря. **Сложность:** L. **Демонстрационный эффект:** -максимальный. \ No newline at end of file +максимальный. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index ea9ac1e..cd86d9b 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -34,6 +34,7 @@ frontend/ │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов │ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки │ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра +│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям │ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile ├── index.html # 🔐 Страница авторизации (общая) ├── script.js # Логика авторизации @@ -49,7 +50,8 @@ frontend/ │ │ ├── components.css # Кнопки, таблицы, карточки, формы │ │ ├── modals.css # Модальные окна │ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности -│ │ └── departments-data.css # Стили создания кафедры/специальности +│ │ ├── departments-data.css # Стили создания кафедры/специальности +│ │ └── teacher-absences.css # Реестр инцидентов и полноэкранный мастер замены │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям @@ -68,6 +70,7 @@ frontend/ │ │ ├── department-workspace.js # Кабинет кафедры в общей панели │ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки │ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений +│ │ ├── teacher-absences.js # Реестр, согласование и групповой мастер замены │ │ ├── schedule.js # Конструктор правил расписания │ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки │ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года @@ -83,6 +86,7 @@ frontend/ │ │ ├── university-structure.html │ │ ├── department-workspace.html │ │ ├── schedule-view.html +│ │ ├── teacher-absences.html │ │ ├── schedule.html │ │ ├── academic-calendar.html │ │ └── auditorium-workload.html @@ -142,8 +146,8 @@ frontend/ | Роль | Доступные вкладки | |------|-------------------| | `ADMIN` | Все вкладки | -| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения | -| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний | +| `EDUCATION_OFFICE` | Просмотр и конструктор расписаний, отсутствия и замены, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения | +| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний, отсутствия и подтверждение заявок своих преподавателей | | `SCHEDULE_VIEWER` | Только просмотр расписаний | Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели. @@ -166,6 +170,7 @@ frontend/ | `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` | | `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` | +| `teacher-absences` | Реестр отсутствий, подтверждение преподавательских заявок, проверенные варианты замен/переносов и журнал решений | `/api/teacher-absences`, `/api/users/teachers` | | `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `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` | @@ -181,6 +186,11 @@ frontend/ - Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице. +- Вкладка `teacher-absences` доступна администратору, учебному отделу и кафедре. Верхний + командный блок показывает очередь инцидентов, форма регистрирует преподавателя, период и + причину, а реестр разделяет статусы согласования. Полноэкранный мастер выводит каждое + затронутое занятие отдельной строкой и предлагает только кандидатов, уже проверенных + backend. Пустая строка не отправляется; выбранные решения применяются одним пакетом. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. @@ -325,6 +335,9 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) { - выбор даты через `input[type="date"]`; - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; - отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила. +- форма собственной заявки на отсутствие через `POST /api/teacher-absences`; +- список статусов заявок и отмена ещё не подтверждённой записи через + `DELETE /api/teacher-absences/{id}`. Если refresh-cookie недействительна или роль не `TEACHER`, страница возвращает пользователя на вход. diff --git a/frontend/admin/css/teacher-absences.css b/frontend/admin/css/teacher-absences.css new file mode 100644 index 0000000..5d569d8 --- /dev/null +++ b/frontend/admin/css/teacher-absences.css @@ -0,0 +1,556 @@ +.absence-command-card { + position: relative; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr); + gap: 2rem; + padding: 2rem; + border: 1px solid color-mix(in srgb, #f59e0b 30%, var(--bg-card-border)); + border-radius: var(--radius-lg); + background: + linear-gradient(125deg, color-mix(in srgb, #f59e0b 13%, transparent), transparent 48%), + var(--bg-card); + box-shadow: 0 22px 70px color-mix(in srgb, #0f172a 22%, transparent); +} + +.absence-command-card::after { + content: ''; + position: absolute; + width: 280px; + height: 280px; + right: -120px; + top: -160px; + border: 42px solid color-mix(in srgb, #f59e0b 14%, transparent); + border-radius: 50%; + pointer-events: none; +} + +.absence-command-copy { + position: relative; + z-index: 1; +} + +.absence-kicker, +.absence-section-number { + color: #f59e0b; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.absence-command-copy h2 { + max-width: 720px; + margin: 0.45rem 0 0.65rem; + font-size: clamp(1.55rem, 3vw, 2.35rem); + line-height: 1.08; + letter-spacing: -0.04em; +} + +.absence-command-copy p { + max-width: 680px; + color: var(--text-secondary); + line-height: 1.65; +} + +.absence-command-stats { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.65rem; + align-self: stretch; +} + +.absence-command-stats article { + display: flex; + min-height: 118px; + flex-direction: column; + justify-content: flex-end; + padding: 1rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-input) 76%, transparent); +} + +.absence-command-stats strong { + font-size: 2rem; + line-height: 1; +} + +.absence-command-stats span { + margin-top: 0.5rem; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.absence-create-card, +.absence-registry-card { + border-radius: var(--radius-lg); +} + +.absence-create-card .card-header-row > div:first-child, +.absence-registry-head > div:first-child { + display: flex; + align-items: baseline; + gap: 0.75rem; +} + +.absence-form-grid { + display: grid; + grid-template-columns: minmax(220px, 1.25fr) repeat(2, minmax(160px, 0.7fr)) minmax(260px, 1.5fr) auto; + gap: 1rem; + align-items: end; +} + +.absence-create-submit { + min-height: 44px; + white-space: nowrap; +} + +.absence-registry-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.absence-registry-actions .custom-select-wrapper, +.absence-registry-actions > select { + min-width: 210px; +} + +.absence-registry { + display: grid; + gap: 0.75rem; +} + +.absence-registry-item { + display: grid; + grid-template-columns: 68px minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 1rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-input) 64%, transparent); + transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease; +} + +.absence-registry-item:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, #f59e0b 36%, var(--bg-card-border)); + background: color-mix(in srgb, #f59e0b 5%, var(--bg-input)); +} + +.absence-date-rail { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 0 0.3rem; + min-height: 68px; + padding-right: 0.85rem; + border-right: 1px solid var(--bg-card-border); +} + +.absence-date-rail strong { + font-size: 1.15rem; +} + +.absence-date-rail span { + color: var(--text-secondary); + font-size: 0.7rem; + text-transform: uppercase; +} + +.absence-date-rail i { + grid-column: 1 / -1; + width: 100%; + height: 1px; + margin: 0.25rem 0; + background: linear-gradient(90deg, #f59e0b, transparent); +} + +.absence-registry-title, +.absence-meta-row, +.absence-registry-buttons { + display: flex; + align-items: center; +} + +.absence-registry-title { + justify-content: space-between; + gap: 1rem; +} + +.absence-registry-title h3 { + margin: 0; + font-size: 1rem; +} + +.absence-registry-title p { + margin: 0.25rem 0 0; + color: var(--text-secondary); + font-size: 0.86rem; +} + +.absence-meta-row { + flex-wrap: wrap; + gap: 0.45rem 1rem; + margin-top: 0.65rem; + color: var(--text-secondary); + font-size: 0.76rem; +} + +.absence-meta-row span:not(:last-child)::after { + content: '•'; + margin-left: 1rem; + color: #f59e0b; +} + +.absence-status { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 0.65rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.69rem; + font-weight: 800; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.absence-status.is-pending { color: #f59e0b; } +.absence-status.is-approved { color: #fb7185; } +.absence-status.is-resolved { color: #34d399; } +.absence-status.is-rejected, +.absence-status.is-cancelled { color: var(--text-secondary); } + +.absence-registry-buttons { + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; + max-width: 270px; +} + +.absence-empty { + display: flex; + min-height: 150px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.4rem; + color: var(--text-secondary); + text-align: center; +} + +.absence-empty strong { + color: var(--text-primary); +} + +body.absence-wizard-open { + overflow: hidden; +} + +.absence-wizard-overlay { + position: fixed; + inset: 0; + z-index: 2400; + display: grid; + place-items: center; + padding: 1.25rem; + background: rgba(3, 7, 18, 0.72); + backdrop-filter: blur(10px); +} + +.absence-wizard-overlay[hidden] { + display: none; +} + +.absence-wizard { + display: flex; + width: min(1180px, 100%); + max-height: min(900px, calc(100vh - 2.5rem)); + flex-direction: column; + overflow: hidden; + border: 1px solid color-mix(in srgb, #f59e0b 35%, var(--bg-card-border)); + border-radius: 24px; + background: color-mix(in srgb, var(--bg-card) 94%, #0f172a); + box-shadow: 0 35px 110px rgba(0, 0, 0, 0.48); + animation: absence-wizard-in 0.28s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes absence-wizard-in { + from { opacity: 0; transform: translateY(24px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.absence-wizard-header, +.absence-wizard-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.35rem 1.5rem; +} + +.absence-wizard-header { + border-bottom: 1px solid var(--bg-card-border); +} + +.absence-wizard-header h2 { + margin: 0.25rem 0; +} + +.absence-wizard-header p { + margin: 0; + color: var(--text-secondary); +} + +#absence-wizard-close { + font-size: 1.65rem; +} + +.absence-wizard-progress { + display: flex; + align-items: center; + justify-content: center; + padding: 0.85rem 1.5rem 0; + color: var(--text-secondary); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; +} + +.absence-wizard-progress span.done { + color: #f59e0b; +} + +.absence-wizard-progress i { + width: 56px; + height: 1px; + margin: 0 0.75rem; + background: var(--bg-card-border); +} + +#absence-wizard-alert { + margin: 0.9rem 1.5rem 0; +} + +.absence-wizard-lessons { + display: grid; + gap: 0.65rem; + padding: 1.25rem 1.5rem; + overflow-y: auto; +} + +.absence-lesson { + display: grid; + grid-template-columns: 100px minmax(0, 1fr) minmax(280px, 0.85fr); + gap: 1rem; + align-items: center; + padding: 1rem; + border: 1px solid var(--bg-card-border); + border-radius: 14px; + background: color-mix(in srgb, var(--bg-input) 70%, transparent); +} + +.absence-lesson-time { + display: flex; + flex-direction: column; + padding-right: 1rem; + border-right: 1px solid var(--bg-card-border); +} + +.absence-lesson-time strong { + font-size: 1.15rem; +} + +.absence-lesson-time span, +.absence-lesson-time small, +.absence-lesson-copy p { + color: var(--text-secondary); +} + +.absence-lesson-time small { + margin-top: 0.4rem; +} + +.absence-lesson-type { + color: #f59e0b; + font-size: 0.68rem; + font-weight: 800; + text-transform: uppercase; +} + +.absence-lesson-copy h3 { + margin: 0.2rem 0; + font-size: 1rem; +} + +.absence-lesson-copy p { + margin: 0; + font-size: 0.8rem; +} + +.absence-warning { + display: block; + margin-top: 0.45rem; + color: #fb7185; +} + +.absence-lesson-decision label { + display: block; + margin-bottom: 0.35rem; + color: var(--text-secondary); + font-size: 0.72rem; +} + +.absence-decision-fixed { + display: inline-block; + margin-top: 0.45rem; + color: #34d399; + font-size: 0.75rem; + font-weight: 700; +} + +.absence-journal { + padding: 0 1.5rem 1.25rem; +} + +.absence-journal h3 { + font-size: 0.95rem; +} + +.absence-journal-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 0.5rem; + max-height: 150px; + overflow-y: auto; +} + +.absence-journal-list article { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.65rem; + align-items: center; + padding: 0.7rem; + border: 1px solid var(--bg-card-border); + border-radius: 10px; +} + +.absence-journal-list article div { + display: flex; + flex-direction: column; +} + +.absence-journal-list small, +.absence-journal-list article > span:last-child { + color: var(--text-secondary); + font-size: 0.7rem; +} + +.absence-journal-mark { + width: 9px; + height: 9px; + border-radius: 50%; +} + +.absence-journal-mark.applied { background: #34d399; box-shadow: 0 0 12px rgba(52, 211, 153, 0.6); } +.absence-journal-mark.rejected { background: #94a3b8; } + +.absence-wizard-footer { + margin-top: auto; + border-top: 1px solid var(--bg-card-border); +} + +.absence-wizard-footer > div { + display: flex; + gap: 0.65rem; +} + +@media (max-width: 1100px) { + .absence-command-card, + .absence-form-grid { + grid-template-columns: 1fr 1fr; + } + + .absence-command-stats, + .absence-reason-field { + grid-column: 1 / -1; + } + + .absence-create-submit { + justify-self: start; + } + + .absence-registry-item { + grid-template-columns: 68px minmax(0, 1fr); + } + + .absence-registry-buttons { + grid-column: 2; + justify-content: flex-start; + max-width: none; + } +} + +@media (max-width: 760px) { + .absence-command-card, + .absence-form-grid, + .absence-registry-item, + .absence-lesson { + grid-template-columns: 1fr; + } + + .absence-command-stats { + grid-template-columns: repeat(3, 1fr); + } + + .absence-registry-head, + .absence-registry-actions, + .absence-wizard-header, + .absence-wizard-footer { + align-items: stretch; + flex-direction: column; + } + + .absence-date-rail { + grid-template-columns: auto auto 1fr auto auto; + min-height: auto; + padding: 0 0 0.75rem; + border-right: 0; + border-bottom: 1px solid var(--bg-card-border); + } + + .absence-date-rail i { + grid-column: auto; + width: auto; + margin: 0 0.5rem; + } + + .absence-registry-buttons { + grid-column: auto; + } + + .absence-lesson-time { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 0.4rem; + padding: 0 0 0.65rem; + border-right: 0; + border-bottom: 1px solid var(--bg-card-border); + } + + .absence-wizard-overlay { + padding: 0; + } + + .absence-wizard { + width: 100%; + max-height: 100vh; + min-height: 100vh; + border-radius: 0; + } +} diff --git a/frontend/admin/index.html b/frontend/admin/index.html index d57df64..74f05df 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -12,6 +12,7 @@ + @@ -112,6 +113,16 @@ Просмотр расписаний + + + + + + + + Отсутствия и замены + diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 81487f5..3e29351 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -117,6 +117,14 @@ const ROUTES = { await module.initAuditoriumWorkload(); } }, + 'teacher-absences': { + title: 'Отсутствия и мастер замены', + file: '/admin/views/teacher-absences.html', + init: async () => { + const module = await import('./views/teacher-absences.js'); + await module.initTeacherAbsences(); + } + }, }; let currentTab = null; diff --git a/frontend/admin/js/role-capabilities.js b/frontend/admin/js/role-capabilities.js index bcc5630..be6cc1a 100644 --- a/frontend/admin/js/role-capabilities.js +++ b/frontend/admin/js/role-capabilities.js @@ -1,19 +1,19 @@ const capabilities = { ADMIN: { defaultAdminTab: 'dashboard', - adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'], + adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'], defaultSettingsTab: 'general', settingsTabs: ['general', 'time-slots', 'database', 'edu-forms'] }, EDUCATION_OFFICE: { defaultAdminTab: 'dashboard', - adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms'], + adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'], defaultSettingsTab: 'time-slots', settingsTabs: ['time-slots', 'edu-forms'] }, DEPARTMENT: { defaultAdminTab: 'department-workspace', - adminTabs: ['department-workspace', 'schedule-view'], + adminTabs: ['department-workspace', 'schedule-view', 'teacher-absences'], defaultSettingsTab: null, settingsTabs: [] }, diff --git a/frontend/admin/js/views/teacher-absences.js b/frontend/admin/js/views/teacher-absences.js new file mode 100644 index 0000000..e121d39 --- /dev/null +++ b/frontend/admin/js/views/teacher-absences.js @@ -0,0 +1,367 @@ +import { api, getSession } from '../api.js'; +import { escapeHtml, hideAlert, showAlert } from '../utils.js'; + +const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']); + +export async function initTeacherAbsences() { + const session = getSession(); + const role = session?.role; + if (!MANAGER_ROLES.has(role)) return; + + const form = document.getElementById('teacher-absence-form'); + const teacherSelect = document.getElementById('teacher-absence-teacher'); + const startInput = document.getElementById('teacher-absence-start'); + const endInput = document.getElementById('teacher-absence-end'); + const reasonInput = document.getElementById('teacher-absence-reason'); + const statusFilter = document.getElementById('teacher-absence-status-filter'); + const registry = document.getElementById('teacher-absence-registry'); + const overlay = document.getElementById('absence-wizard-overlay'); + const wizardLessons = document.getElementById('absence-wizard-lessons'); + let absences = []; + let currentWizard = null; + + setDefaultPeriod(startInput, endInput); + form?.addEventListener('submit', createAbsence); + statusFilter?.addEventListener('change', loadAbsences); + document.getElementById('teacher-absence-refresh')?.addEventListener('click', loadAbsences); + document.getElementById('absence-wizard-close')?.addEventListener('click', closeWizard); + document.getElementById('absence-wizard-cancel')?.addEventListener('click', closeWizard); + document.getElementById('absence-wizard-apply')?.addEventListener('click', applyWizard); + overlay?.addEventListener('click', event => { + if (event.target === overlay) closeWizard(); + }); + registry?.addEventListener('click', handleRegistryAction); + + await Promise.all([loadTeachers(), loadAbsences()]); + + async function loadTeachers() { + try { + let teachers = await api.get('/api/users/teachers'); + if (role === 'DEPARTMENT' && session?.departmentId) { + teachers = teachers.filter(item => String(item.departmentId) === String(session.departmentId)); + } + teacherSelect.innerHTML = '' + teachers.map(item => ` + + `).join(''); + } catch (error) { + teacherSelect.innerHTML = ``; + } + } + + async function loadAbsences() { + registry.innerHTML = '
Загрузка...
'; + try { + const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : ''; + absences = await api.get(`/api/teacher-absences${params}`); + renderRegistry(); + updateStats(absences); + } catch (error) { + registry.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}
`; + } + } + + async function createAbsence(event) { + event.preventDefault(); + hideAlert('teacher-absence-form-alert'); + if (!teacherSelect.value || !startInput.value || !endInput.value || !reasonInput.value.trim()) { + showAlert('teacher-absence-form-alert', 'Заполните преподавателя, период и причину', 'error'); + return; + } + if (endInput.value < startInput.value) { + showAlert('teacher-absence-form-alert', 'Дата окончания не может быть раньше даты начала', 'error'); + return; + } + try { + await api.post('/api/teacher-absences', { + teacherId: Number(teacherSelect.value), + startDate: startInput.value, + endDate: endInput.value, + reason: reasonInput.value.trim() + }); + showAlert('teacher-absence-form-alert', 'Отсутствие зарегистрировано', 'success'); + reasonInput.value = ''; + await loadAbsences(); + } catch (error) { + showAlert('teacher-absence-form-alert', error.message || 'Не удалось зарегистрировать отсутствие', 'error'); + } + } + + function renderRegistry() { + if (!absences.length) { + registry.innerHTML = '
Инцидентов не найденоИзмените фильтр или зарегистрируйте отсутствие.
'; + return; + } + registry.innerHTML = absences.map(item => ` +
+
+ ${escapeHtml(dayOfMonth(item.startDate))} + ${escapeHtml(monthShort(item.startDate))} + + ${escapeHtml(dayOfMonth(item.endDate))} + ${escapeHtml(monthShort(item.endDate))} +
+
+
+
+

${escapeHtml(item.teacherName || 'Преподаватель не указан')}

+

${escapeHtml(item.reason || 'Причина не указана')}

+
+ ${escapeHtml(statusLabel(item.status))} +
+
+ ${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))} + ${escapeHtml(decisionCountLabel(item.decisionCount || 0))} + ${item.reviewComment ? `${escapeHtml(item.reviewComment)}` : ''} +
+
+
+ ${item.status === 'PENDING' ? ` + + + ` : ''} + ${['APPROVED', 'RESOLVED'].includes(item.status) ? ` + + ` : ''} + ${['PENDING', 'APPROVED'].includes(item.status) ? ` + + ` : ''} +
+
+ `).join(''); + } + + async function handleRegistryAction(event) { + const button = event.target.closest('[data-action][data-id]'); + if (!button) return; + const id = Number(button.dataset.id); + button.disabled = true; + try { + if (button.dataset.action === 'approve' || button.dataset.action === 'reject') { + await api.post(`/api/teacher-absences/${id}/review`, { + approved: button.dataset.action === 'approve', + comment: button.dataset.action === 'approve' + ? 'Подтверждено кафедрой или ответственным сотрудником' + : 'Заявка отклонена ответственным сотрудником' + }); + await loadAbsences(); + } else if (button.dataset.action === 'cancel') { + await api.delete(`/api/teacher-absences/${id}`); + await loadAbsences(); + } else if (button.dataset.action === 'wizard') { + await openWizard(id); + } + } catch (error) { + window.alert(error.message || 'Не удалось выполнить действие'); + } finally { + button.disabled = false; + } + } + + async function openWizard(id) { + overlay.hidden = false; + document.body.classList.add('absence-wizard-open'); + wizardLessons.innerHTML = '
Подбираем свободные ресурсы...
'; + hideAlert('absence-wizard-alert'); + try { + currentWizard = await api.get(`/api/teacher-absences/${id}/wizard`); + renderWizard(); + } catch (error) { + showAlert('absence-wizard-alert', error.message || 'Не удалось открыть мастер замены', 'error'); + } + } + + function closeWizard() { + overlay.hidden = true; + document.body.classList.remove('absence-wizard-open'); + currentWizard = null; + } + + function renderWizard() { + const absence = currentWizard.absence; + document.getElementById('absence-wizard-title').textContent = absence.teacherName || 'Мастер замены'; + document.getElementById('absence-wizard-summary').textContent = + `${formatDate(absence.startDate)} — ${formatDate(absence.endDate)} · ${absence.reason}`; + const applyButton = document.getElementById('absence-wizard-apply'); + applyButton.hidden = absence.status !== 'APPROVED'; + + wizardLessons.innerHTML = currentWizard.lessons.length + ? currentWizard.lessons.map((lesson, index) => renderLessonDecision(lesson, index, absence.status)).join('') + : '
Затронутых занятий нетИнцидент можно завершить без точечных изменений.
'; + renderJournal(currentWizard.journal || []); + } + + function renderLessonDecision(lesson, index, absenceStatus) { + const options = decisionOptions(lesson); + const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus); + return ` +
+
+ ${escapeHtml(trimTime(lesson.startTime))} + ${escapeHtml(trimTime(lesson.endTime))} + ${escapeHtml(formatDate(lesson.lessonDate))} +
+
+ ${escapeHtml(lesson.lessonTypeName || 'Занятие')} +

${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}

+

${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}

+ ${lesson.existingOverrideId ? 'У пары уже есть точечное изменение — доступна только запись отклонения.' : ''} +
+
+ + + ${lesson.decisionStatus ? `${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}` : ''} +
+
+ `; + } + + async function applyWizard() { + if (!currentWizard) return; + const decisions = Array.from(wizardLessons.querySelectorAll('.absence-lesson')).map(card => { + const value = card.querySelector('.absence-decision-select')?.value; + if (!value) return null; + return { + baseRuleSlotId: Number(card.dataset.slotId), + lessonDate: card.dataset.date, + ...parseDecisionOption(value) + }; + }).filter(Boolean); + if (!decisions.length && currentWizard.lessons.length) { + showAlert('absence-wizard-alert', 'Выберите хотя бы одно решение', 'error'); + return; + } + const button = document.getElementById('absence-wizard-apply'); + button.disabled = true; + hideAlert('absence-wizard-alert'); + try { + currentWizard = await api.post(`/api/teacher-absences/${currentWizard.absence.id}/resolve`, { decisions }); + renderWizard(); + showAlert('absence-wizard-alert', 'Выбранные решения применены к расписанию', 'success'); + await loadAbsences(); + } catch (error) { + showAlert('absence-wizard-alert', error.message || 'Не удалось применить решения', 'error'); + } finally { + button.disabled = false; + } + } +} + +export function decisionOptions(lesson) { + const options = []; + (lesson.teacherCandidates || []).forEach(candidate => options.push({ + value: `REPLACE_TEACHER|${candidate.id}`, + label: `Заменить: ${candidate.name}` + })); + (lesson.moveCandidates || []).forEach(candidate => options.push({ + value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`, + label: `Перенести: ${formatDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}` + })); + (lesson.classroomCandidates || []).forEach(candidate => options.push({ + value: `CHANGE_CLASSROOM|${candidate.id}`, + label: `Другая аудитория: ${candidate.name}` + })); + if (lesson.cancellationAvailable) { + options.push({ value: 'CANCEL', label: 'Отменить занятие' }); + } + options.push({ value: 'REJECT', label: 'Отклонить предложения' }); + return options; +} + +export function parseDecisionOption(value) { + const [resolution, first, second] = String(value || '').split('|'); + if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) }; + if (resolution === 'MOVE_TIME') { + return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) }; + } + if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) }; + if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution }; + throw new Error('Неизвестное решение мастера замены'); +} + +function renderJournal(journal) { + document.getElementById('absence-journal-count').textContent = decisionCountLabel(journal.length); + document.getElementById('absence-journal-list').innerHTML = journal.length ? journal.map(item => ` +
+ +
${escapeHtml(resolutionLabel(item.resolution))}${escapeHtml(formatDate(item.lessonDate))}
+ ${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''} +
+ `).join('') : '
Решений пока нет
'; +} + +function updateStats(items) { + document.getElementById('absence-stat-active').textContent = items.filter(item => item.status === 'APPROVED').length; + document.getElementById('absence-stat-pending').textContent = items.filter(item => item.status === 'PENDING').length; + document.getElementById('absence-stat-resolved').textContent = items.filter(item => item.status === 'RESOLVED').length; +} + +function setDefaultPeriod(startInput, endInput) { + const today = new Date(); + startInput.value = isoDate(today); + endInput.value = isoDate(today); +} + +function isoDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function formatDate(value) { + if (!value) return '—'; + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; +} + +function dayOfMonth(value) { + return String(value || '').split('-')[2] || '—'; +} + +function monthShort(value) { + const month = Number(String(value || '').split('-')[1]); + return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || ''; +} + +function trimTime(value) { + return String(value || '').slice(0, 5); +} + +function statusLabel(status) { + return { + PENDING: 'На подтверждении', + APPROVED: 'Требует решения', + REJECTED: 'Отклонено', + RESOLVED: 'Разрешено', + CANCELLED: 'Отменено' + }[status] || status || '—'; +} + +function statusClass(status) { + return `is-${String(status || '').toLowerCase()}`; +} + +function resolutionLabel(resolution) { + return { + REPLACE_TEACHER: 'Замена преподавателя', + MOVE_TIME: 'Перенос занятия', + CHANGE_CLASSROOM: 'Замена аудитории', + CANCEL: 'Отмена занятия', + REJECT: 'Предложение отклонено' + }[resolution] || resolution || 'Решение'; +} + +function decisionStatusLabel(status) { + return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—'; +} + +function decisionCountLabel(count) { + const value = Number(count) || 0; + const tail = value % 100; + if (tail >= 11 && tail <= 14) return `${value} решений`; + if (value % 10 === 1) return `${value} решение`; + if ([2, 3, 4].includes(value % 10)) return `${value} решения`; + return `${value} решений`; +} diff --git a/frontend/admin/views/teacher-absences.html b/frontend/admin/views/teacher-absences.html new file mode 100644 index 0000000..91e8bb8 --- /dev/null +++ b/frontend/admin/views/teacher-absences.html @@ -0,0 +1,102 @@ +
+
+ Оперативный контур расписания +

От регистрации инцидента до готовой замены

+

Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.

+
+
+
0требуют внимания
+
0ждут подтверждения
+
0разрешены
+
+
+ +
+
+
+ 01 +

Зарегистрировать отсутствие

+
+ Ответственный сотрудник создаёт согласованную запись сразу +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+
+ 02 +

Реестр инцидентов

+
+
+ + +
+
+
+
Загрузка...
+
+
+ + diff --git a/frontend/package.json b/frontend/package.json index a8d0e3d..05baef4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "build": "npm run build:vendor", "build:vendor": "node scripts/build-vendor.mjs", "test": "node --test tests/*.test.mjs", - "check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs" + "check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check admin/js/views/teacher-absences.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs" }, "devDependencies": { "@opentelemetry/api": "1.9.0", diff --git a/frontend/teacher/app.js b/frontend/teacher/app.js index e0c9ddb..9246329 100644 --- a/frontend/teacher/app.js +++ b/frontend/teacher/app.js @@ -16,6 +16,12 @@ import { const statusLine = document.getElementById('status-line'); const weekLabel = document.getElementById('week-label'); const datePicker = document.getElementById('date-picker'); + const absenceForm = document.getElementById('teacher-absence-form'); + const absenceList = document.getElementById('teacher-absence-list'); + const absenceStatus = document.getElementById('teacher-absence-status'); + const absenceStart = document.getElementById('teacher-absence-start'); + const absenceEnd = document.getElementById('teacher-absence-end'); + const absenceReason = document.getElementById('teacher-absence-reason'); let weekStart = startOfWeek(new Date()); document.getElementById('logout-link').addEventListener('click', async (event) => { @@ -44,7 +50,92 @@ import { } }); + document.getElementById('absence-form-toggle').addEventListener('click', event => { + const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true'; + event.currentTarget.setAttribute('aria-expanded', String(!expanded)); + event.currentTarget.textContent = expanded ? 'Новая заявка' : 'Скрыть форму'; + absenceForm.hidden = expanded; + }); + absenceForm.addEventListener('submit', submitAbsence); + absenceList.addEventListener('click', cancelAbsence); + + const todayIso = toIsoDate(new Date()); + absenceStart.value = todayIso; + absenceEnd.value = todayIso; + loadSchedule(); + loadAbsences(); + + async function loadAbsences() { + absenceList.innerHTML = '
Загрузка заявок...
'; + try { + const items = await fetchJson('/api/teacher-absences'); + absenceList.innerHTML = items.length ? items.map(item => ` +
+ +
+ ${escapeHtml(formatIsoDate(item.startDate))} — ${escapeHtml(formatIsoDate(item.endDate))} +

${escapeHtml(item.reason || 'Причина не указана')}

+
+ ${escapeHtml(absenceStatusLabel(item.status))} + ${item.status === 'PENDING' ? `` : ''} +
+ `).join('') : '
Заявок об отсутствии пока нет
'; + } catch (error) { + absenceList.innerHTML = `
${escapeHtml(error.message)}
`; + } + } + + async function submitAbsence(event) { + event.preventDefault(); + showAbsenceStatus('', false); + if (!absenceStart.value || !absenceEnd.value || !absenceReason.value.trim()) { + showAbsenceStatus('Заполните период и причину отсутствия', true); + return; + } + if (absenceEnd.value < absenceStart.value) { + showAbsenceStatus('Дата окончания не может быть раньше даты начала', true); + return; + } + try { + await fetchJson('/api/teacher-absences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + startDate: absenceStart.value, + endDate: absenceEnd.value, + reason: absenceReason.value.trim() + }) + }); + absenceReason.value = ''; + showAbsenceStatus('Заявка отправлена кафедре', false); + await loadAbsences(); + } catch (error) { + showAbsenceStatus(error.message, true); + } + } + + async function cancelAbsence(event) { + const button = event.target.closest('[data-cancel-absence]'); + if (!button) return; + button.disabled = true; + try { + await fetchJson(`/api/teacher-absences/${encodeURIComponent(button.dataset.cancelAbsence)}`, { + method: 'DELETE' + }); + showAbsenceStatus('Заявка отменена', false); + await loadAbsences(); + } catch (error) { + showAbsenceStatus(error.message, true); + button.disabled = false; + } + } + + function showAbsenceStatus(message, isError) { + absenceStatus.textContent = message || ''; + absenceStatus.classList.toggle('error', Boolean(isError)); + absenceStatus.classList.toggle('success', Boolean(message) && !isError); + } async function loadSchedule() { const startDate = toIsoDate(weekStart); @@ -185,8 +276,8 @@ import { return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)); } - async function fetchJson(url) { - const response = await fetchWithAuth(url); + async function fetchJson(url, options = {}) { + const response = await fetchWithAuth(url, options); const data = await response.json().catch(() => null); if (response.status === 401) { clearAuthState(); @@ -225,6 +316,25 @@ import { return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); } + function formatIsoDate(value) { + const [year, month, day] = String(value || '').split('-'); + return year && month && day ? `${day}.${month}.${year}` : '—'; + } + + function absenceStatusLabel(status) { + return { + PENDING: 'На подтверждении', + APPROVED: 'Согласовано', + REJECTED: 'Отклонено', + RESOLVED: 'Замены выполнены', + CANCELLED: 'Отменено' + }[status] || status || '—'; + } + + function absenceStatusClass(status) { + return `is-${String(status || '').toLowerCase()}`; + } + function dayName(date) { const value = date.toLocaleDateString('ru-RU', { weekday: 'long' }); return value.charAt(0).toUpperCase() + value.slice(1); diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index 9e7a1cd..8f8d2ca 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -41,6 +41,35 @@
+
+
+
+ Изменение планов +

Сообщить об отсутствии

+

Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.

+
+ +
+ +
+
+
diff --git a/frontend/teacher/style.css b/frontend/teacher/style.css index 78f8577..adebf31 100644 --- a/frontend/teacher/style.css +++ b/frontend/teacher/style.css @@ -2,6 +2,178 @@ box-sizing: border-box; } +/* Реестр собственных отсутствий преподавателя */ +.teacher-absence-panel { + margin-bottom: 1.25rem; + padding: 1.25rem; + border: 1px solid color-mix(in srgb, #f59e0b 28%, var(--border)); + border-radius: 18px; + background: linear-gradient(125deg, color-mix(in srgb, #f59e0b 9%, transparent), transparent 52%), var(--card); +} + +.teacher-absence-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.teacher-absence-heading span { + color: #d97706; + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.teacher-absence-heading h2 { + margin: 0.25rem 0; + font-size: 1.2rem; +} + +.teacher-absence-heading p { + max-width: 720px; + margin: 0; + color: var(--muted); + font-size: 0.86rem; + line-height: 1.5; +} + +.teacher-absence-form { + display: grid; + grid-template-columns: 170px 170px minmax(260px, 1fr) auto; + gap: 0.75rem; + align-items: end; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.teacher-absence-form[hidden] { + display: none; +} + +.teacher-absence-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: var(--muted); + font-size: 0.76rem; + font-weight: 700; +} + +.teacher-absence-form input { + min-height: 42px; + padding: 0 0.75rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card); + color: var(--text); + font: inherit; +} + +.teacher-absence-status { + min-height: 1.2rem; + margin-top: 0.7rem; + font-size: 0.8rem; +} + +.teacher-absence-status.error { color: #ef4444; } +.teacher-absence-status.success { color: #10b981; } + +.teacher-absence-list { + display: grid; + gap: 0.55rem; + margin-top: 0.35rem; +} + +.teacher-absence-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 0.7rem; + align-items: center; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 12px; + background: color-mix(in srgb, var(--card) 82%, transparent); +} + +.teacher-absence-item p { + margin: 0.2rem 0 0; + color: var(--muted); + font-size: 0.78rem; +} + +.teacher-absence-marker { + width: 8px; + height: 38px; + border-radius: 999px; + background: #94a3b8; +} + +.teacher-absence-marker.is-pending { background: #f59e0b; } +.teacher-absence-marker.is-approved { background: #fb7185; } +.teacher-absence-marker.is-resolved { background: #10b981; } + +.teacher-absence-badge { + padding: 0.3rem 0.6rem; + border: 1px solid currentColor; + border-radius: 999px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 800; + white-space: nowrap; +} + +.teacher-absence-badge.is-pending { color: #d97706; } +.teacher-absence-badge.is-approved { color: #e11d48; } +.teacher-absence-badge.is-resolved { color: #059669; } + +.teacher-absence-empty { + padding: 0.75rem; + color: var(--muted); + text-align: center; + font-size: 0.82rem; +} + +.teacher-absence-empty.error { color: #ef4444; } + +@media (max-width: 900px) { + .teacher-absence-form { + grid-template-columns: 1fr 1fr; + } + + .teacher-absence-reason { + grid-column: 1 / -1; + } +} + +@media (max-width: 640px) { + .teacher-absence-heading, + .teacher-absence-form { + grid-template-columns: 1fr; + } + + .teacher-absence-heading { + align-items: stretch; + flex-direction: column; + } + + .teacher-absence-reason { + grid-column: auto; + } + + .teacher-absence-item { + grid-template-columns: auto minmax(0, 1fr); + } + + .teacher-absence-badge, + .teacher-absence-item button { + grid-column: 2; + justify-self: start; + } +} + :root { --bg: #f5f6f8; --panel: #ffffff; diff --git a/frontend/tests/teacher-absences.test.mjs b/frontend/tests/teacher-absences.test.mjs new file mode 100644 index 0000000..a7951c8 --- /dev/null +++ b/frontend/tests/teacher-absences.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + decisionOptions, + parseDecisionOption +} from '../admin/js/views/teacher-absences.js'; + +test('значение мастера преобразуется в payload точечного изменения', () => { + assert.deepEqual(parseDecisionOption('REPLACE_TEACHER|17'), { + resolution: 'REPLACE_TEACHER', + newTeacherId: 17 + }); + assert.deepEqual(parseDecisionOption('MOVE_TIME|2026-09-21|4'), { + resolution: 'MOVE_TIME', + targetLessonDate: '2026-09-21', + newTimeSlotId: 4 + }); + assert.deepEqual(parseDecisionOption('CANCEL'), { resolution: 'CANCEL' }); +}); + +test('мастер показывает только реально полученные кандидаты и явное отклонение', () => { + const options = decisionOptions({ + teacherCandidates: [{ id: 7, name: 'Петров П. П.' }], + moveCandidates: [{ + targetDate: '2026-09-22', + timeSlotId: 3, + startTime: '10:40:00', + endTime: '12:10:00' + }], + classroomCandidates: [{ id: 9, name: 'А-201' }], + cancellationAvailable: true + }); + + assert.deepEqual(options.map(item => item.value), [ + 'REPLACE_TEACHER|7', + 'MOVE_TIME|2026-09-22|3', + 'CHANGE_CLASSROOM|9', + 'CANCEL', + 'REJECT' + ]); +}); + +test('вкладка отсутствий доступна ответственным ролям и подключена к SPA', async () => { + const [capabilities, main, index] = await Promise.all([ + readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/index.html', import.meta.url), 'utf8') + ]); + + assert.match(capabilities, /ADMIN:[\s\S]*teacher-absences/); + assert.match(capabilities, /EDUCATION_OFFICE:[\s\S]*teacher-absences/); + assert.match(capabilities, /DEPARTMENT:[\s\S]*teacher-absences/); + assert.match(main, /'teacher-absences':[\s\S]*teacher-absences\.js/); + assert.match(index, /data-tab="teacher-absences"/); +});