1 Задача
This commit is contained in:
@@ -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<Map<String, Object>> 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();
|
||||
};
|
||||
|
||||
@@ -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<TeacherAbsenceDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TeacherAbsenceRequest(
|
||||
Long teacherId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record TeacherAbsenceResolutionRequest(
|
||||
List<Decision> decisions
|
||||
) {
|
||||
public record Decision(
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
String resolution,
|
||||
LocalDate targetLessonDate,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TeacherAbsenceReviewRequest(
|
||||
Boolean approved,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
@@ -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<AffectedLesson> lessons,
|
||||
List<DecisionLog> 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<String> groupNames,
|
||||
Long existingOverrideId,
|
||||
String decisionStatus,
|
||||
List<Candidate> teacherCandidates,
|
||||
List<MoveCandidate> moveCandidates,
|
||||
List<Candidate> classroomCandidates,
|
||||
boolean cancellationAvailable
|
||||
) {
|
||||
}
|
||||
|
||||
public record DecisionLog(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
String resolution,
|
||||
String status,
|
||||
Long scheduleOverrideId,
|
||||
String comment,
|
||||
Long decidedBy,
|
||||
Instant decidedAt
|
||||
) {
|
||||
}
|
||||
}
|
||||
141
backend/src/main/java/com/magistr/app/model/TeacherAbsence.java
Normal file
141
backend/src/main/java/com/magistr/app/model/TeacherAbsence.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<TeacherAbsenceDecision, Long> {
|
||||
|
||||
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<TeacherAbsenceDecision> findByAbsenceIdWithDetails(@Param("absenceId") Long absenceId);
|
||||
|
||||
}
|
||||
@@ -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<TeacherAbsence, Long> {
|
||||
|
||||
@Query("""
|
||||
select absence
|
||||
from TeacherAbsence absence
|
||||
join fetch absence.teacher
|
||||
order by absence.startDate desc, absence.id desc
|
||||
""")
|
||||
List<TeacherAbsence> findAllWithTeacher();
|
||||
|
||||
@Query("""
|
||||
select absence
|
||||
from TeacherAbsence absence
|
||||
join fetch absence.teacher
|
||||
where absence.id = :id
|
||||
""")
|
||||
Optional<TeacherAbsence> findByIdWithTeacher(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select absence
|
||||
from TeacherAbsence absence
|
||||
join fetch absence.teacher
|
||||
where absence.id = :id
|
||||
""")
|
||||
Optional<TeacherAbsence> 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<String> 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);
|
||||
}
|
||||
@@ -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<TeacherSubject, TeacherSubjectId> {
|
||||
List<TeacherSubject> findByUserId(Long userId);
|
||||
List<TeacherSubject> 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<Long> findQualifiedTeacherIds(
|
||||
@Param("subjectId") Long subjectId,
|
||||
@Param("lessonTypeId") Long lessonTypeId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
|
||||
List<RenderedLessonDto> 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);
|
||||
|
||||
@@ -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<String> ACTIVE_ABSENCE_STATUSES = Set.of("PENDING", "APPROVED", "RESOLVED");
|
||||
private static final Set<String> ALL_STATUSES = Set.of(
|
||||
"PENDING", "APPROVED", "REJECTED", "RESOLVED", "CANCELLED"
|
||||
);
|
||||
private static final Set<String> 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<TeacherAbsenceDto> 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<TeacherAbsenceDecision> decisions = decisionRepository.findByAbsenceIdWithDetails(id);
|
||||
Map<LessonKey, TeacherAbsenceDecision> decisionsByLesson = decisions.stream()
|
||||
.collect(Collectors.toMap(
|
||||
decision -> new LessonKey(
|
||||
decision.getBaseRuleSlot().getId(),
|
||||
decision.getLessonDate()
|
||||
),
|
||||
decision -> decision,
|
||||
(first, second) -> second,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
List<RenderedLessonDto> lessons = affectedLessons(absence);
|
||||
List<TeacherAbsenceWizardDto.AffectedLesson> 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<TeacherAbsenceResolutionRequest.Decision> requested = request == null
|
||||
|| request.decisions() == null
|
||||
? List.of()
|
||||
: request.decisions();
|
||||
if (requested.size() > MAX_DECISIONS) {
|
||||
throw new IllegalArgumentException("За один раз можно обработать не более 100 занятий");
|
||||
}
|
||||
|
||||
Map<LessonKey, RenderedLessonDto> affectedByKey = affectedLessons(absence).stream()
|
||||
.collect(Collectors.toMap(
|
||||
lesson -> new LessonKey(lesson.scheduleRuleSlotId(), lesson.date()),
|
||||
lesson -> lesson,
|
||||
(first, second) -> first,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
Set<LessonKey> 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<LessonKey> 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<Long> 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<TeacherAbsenceWizardDto.Candidate> teacherCandidates(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson
|
||||
) {
|
||||
List<Long> 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<Long> qualifiedTeacherIds(RenderedLessonDto lesson) {
|
||||
if (lesson.subjectId() == null || lesson.lessonTypeId() == null) {
|
||||
return List.of();
|
||||
}
|
||||
return teacherSubjectRepository.findQualifiedTeacherIds(
|
||||
lesson.subjectId(),
|
||||
lesson.lessonTypeId()
|
||||
);
|
||||
}
|
||||
|
||||
private List<TeacherAbsenceWizardDto.MoveCandidate> moveCandidates(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson
|
||||
) {
|
||||
List<LocalDate> dates;
|
||||
try {
|
||||
dates = scheduleOverrideService.getAvailability(
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date()
|
||||
).availableDates();
|
||||
} catch (RuntimeException exception) {
|
||||
log.debug(
|
||||
"Не удалось построить даты переноса для слота {}: {}",
|
||||
lesson.scheduleRuleSlotId(),
|
||||
exception.getMessage()
|
||||
);
|
||||
return List.of();
|
||||
}
|
||||
List<LocalDate> 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<TimeSlot> slots = timeSlotRepository.findAllForSchedule();
|
||||
List<TeacherAbsenceWizardDto.MoveCandidate> 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<TeacherAbsenceWizardDto.Candidate> 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<RenderedLessonDto> 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> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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 'Выбранный способ разрешения затронутого занятия';
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
71
docs/API.md
71
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 | Назначение |
|
||||
|
||||
@@ -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 реализованы. Дальнейшее развитие этого контура:
|
||||
|
||||
- автоматическая рассылка уведомлений группам и преподавателям;
|
||||
- пакетная обработка нескольких одновременных отсутствий;
|
||||
- ранжирование вариантов по окнам и дополнительной нагрузке.
|
||||
|
||||
@@ -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-схема.
|
||||
|
||||
### Полный сброс БД (локально)
|
||||
|
||||
@@ -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. **Демонстрационный эффект:**
|
||||
максимальный.
|
||||
максимальный.
|
||||
|
||||
@@ -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`, страница возвращает пользователя на вход.
|
||||
|
||||
|
||||
556
frontend/admin/css/teacher-absences.css
Normal file
556
frontend/admin/css/teacher-absences.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="css/modals.css">
|
||||
<link rel="stylesheet" href="css/departments-data.css">
|
||||
<link rel="stylesheet" href="css/auditorium-workload.css">
|
||||
<link rel="stylesheet" href="css/teacher-absences.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -112,6 +113,16 @@
|
||||
</svg>
|
||||
<span>Просмотр расписаний</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="teacher-absences">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
|
||||
<path d="M18 8l3 3" />
|
||||
<path d="M21 8l-3 3" />
|
||||
</svg>
|
||||
<span>Отсутствия и замены</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Субъекты и ресурсы -->
|
||||
<div class="sidebar-section-divider"></div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: []
|
||||
},
|
||||
|
||||
367
frontend/admin/js/views/teacher-absences.js
Normal file
367
frontend/admin/js/views/teacher-absences.js
Normal file
@@ -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 = '<option value="">Выберите преподавателя</option>' + teachers.map(item => `
|
||||
<option value="${escapeHtml(item.id)}">${escapeHtml(item.fullName || item.username || `ID ${item.id}`)}</option>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
teacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Не удалось загрузить преподавателей')}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAbsences() {
|
||||
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
try {
|
||||
const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
|
||||
absences = await api.get(`/api/teacher-absences${params}`);
|
||||
renderRegistry();
|
||||
updateStats(absences);
|
||||
} catch (error) {
|
||||
registry.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="absence-empty"><strong>Инцидентов не найдено</strong><span>Измените фильтр или зарегистрируйте отсутствие.</span></div>';
|
||||
return;
|
||||
}
|
||||
registry.innerHTML = absences.map(item => `
|
||||
<article class="absence-registry-item" data-status="${escapeHtml(item.status)}">
|
||||
<div class="absence-date-rail">
|
||||
<strong>${escapeHtml(dayOfMonth(item.startDate))}</strong>
|
||||
<span>${escapeHtml(monthShort(item.startDate))}</span>
|
||||
<i></i>
|
||||
<strong>${escapeHtml(dayOfMonth(item.endDate))}</strong>
|
||||
<span>${escapeHtml(monthShort(item.endDate))}</span>
|
||||
</div>
|
||||
<div class="absence-registry-main">
|
||||
<div class="absence-registry-title">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.teacherName || 'Преподаватель не указан')}</h3>
|
||||
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
|
||||
</div>
|
||||
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(statusLabel(item.status))}</span>
|
||||
</div>
|
||||
<div class="absence-meta-row">
|
||||
<span>${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))}</span>
|
||||
<span>${escapeHtml(decisionCountLabel(item.decisionCount || 0))}</span>
|
||||
${item.reviewComment ? `<span>${escapeHtml(item.reviewComment)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absence-registry-buttons">
|
||||
${item.status === 'PENDING' ? `
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="approve" data-id="${item.id}">Подтвердить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle" data-action="reject" data-id="${item.id}">Отклонить</button>
|
||||
` : ''}
|
||||
${['APPROVED', 'RESOLVED'].includes(item.status) ? `
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="wizard" data-id="${item.id}">
|
||||
${item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
|
||||
</button>
|
||||
` : ''}
|
||||
${['PENDING', 'APPROVED'].includes(item.status) ? `
|
||||
<button type="button" class="btn btn-sm btn-ghost" data-action="cancel" data-id="${item.id}">Отменить</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`).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 = '<div class="loading-row">Подбираем свободные ресурсы...</div>';
|
||||
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('')
|
||||
: '<div class="absence-empty"><strong>Затронутых занятий нет</strong><span>Инцидент можно завершить без точечных изменений.</span></div>';
|
||||
renderJournal(currentWizard.journal || []);
|
||||
}
|
||||
|
||||
function renderLessonDecision(lesson, index, absenceStatus) {
|
||||
const options = decisionOptions(lesson);
|
||||
const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus);
|
||||
return `
|
||||
<article class="absence-lesson" data-index="${index}" data-slot-id="${lesson.baseRuleSlotId}" data-date="${lesson.lessonDate}">
|
||||
<div class="absence-lesson-time">
|
||||
<strong>${escapeHtml(trimTime(lesson.startTime))}</strong>
|
||||
<span>${escapeHtml(trimTime(lesson.endTime))}</span>
|
||||
<small>${escapeHtml(formatDate(lesson.lessonDate))}</small>
|
||||
</div>
|
||||
<div class="absence-lesson-copy">
|
||||
<span class="absence-lesson-type">${escapeHtml(lesson.lessonTypeName || 'Занятие')}</span>
|
||||
<h3>${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}</h3>
|
||||
<p>${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}</p>
|
||||
${lesson.existingOverrideId ? '<small class="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>' : ''}
|
||||
</div>
|
||||
<div class="absence-lesson-decision">
|
||||
<label for="absence-decision-${index}">Решение</label>
|
||||
<select id="absence-decision-${index}" class="absence-decision-select" ${locked ? 'disabled' : ''}>
|
||||
<option value="">Оставить без изменений</option>
|
||||
${options.map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`).join('')}
|
||||
</select>
|
||||
${lesson.decisionStatus ? `<span class="absence-decision-fixed">${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}</span>` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
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 => `
|
||||
<article>
|
||||
<span class="absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}"></span>
|
||||
<div><strong>${escapeHtml(resolutionLabel(item.resolution))}</strong><small>${escapeHtml(formatDate(item.lessonDate))}</small></div>
|
||||
<span>${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''}</span>
|
||||
</article>
|
||||
`).join('') : '<div class="loading-row">Решений пока нет</div>';
|
||||
}
|
||||
|
||||
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} решений`;
|
||||
}
|
||||
102
frontend/admin/views/teacher-absences.html
Normal file
102
frontend/admin/views/teacher-absences.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<section class="absence-command-card">
|
||||
<div class="absence-command-copy">
|
||||
<span class="absence-kicker">Оперативный контур расписания</span>
|
||||
<h2>От регистрации инцидента до готовой замены</h2>
|
||||
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
|
||||
</div>
|
||||
<div class="absence-command-stats" aria-label="Сводка отсутствий">
|
||||
<article><strong id="absence-stat-active">0</strong><span>требуют внимания</span></article>
|
||||
<article><strong id="absence-stat-pending">0</strong><span>ждут подтверждения</span></article>
|
||||
<article><strong id="absence-stat-resolved">0</strong><span>разрешены</span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card absence-create-card">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<span class="absence-section-number">01</span>
|
||||
<h2>Зарегистрировать отсутствие</h2>
|
||||
</div>
|
||||
<span class="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
|
||||
</div>
|
||||
<form id="teacher-absence-form" novalidate>
|
||||
<div class="absence-form-grid">
|
||||
<div class="form-group absence-teacher-field">
|
||||
<label for="teacher-absence-teacher">Преподаватель</label>
|
||||
<select id="teacher-absence-teacher" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-absence-start">Начало</label>
|
||||
<input type="date" id="teacher-absence-start" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-absence-end">Окончание</label>
|
||||
<input type="date" id="teacher-absence-end" required>
|
||||
</div>
|
||||
<div class="form-group absence-reason-field">
|
||||
<label for="teacher-absence-reason">Причина</label>
|
||||
<input type="text" id="teacher-absence-reason" maxlength="2000"
|
||||
placeholder="Болезнь, командировка или другая причина" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary absence-create-submit">Зарегистрировать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="teacher-absence-form-alert" role="alert"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card absence-registry-card">
|
||||
<div class="card-header-row absence-registry-head">
|
||||
<div>
|
||||
<span class="absence-section-number">02</span>
|
||||
<h2>Реестр инцидентов</h2>
|
||||
</div>
|
||||
<div class="absence-registry-actions">
|
||||
<select id="teacher-absence-status-filter" aria-label="Фильтр по статусу">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="PENDING">На подтверждении</option>
|
||||
<option value="APPROVED">Согласованы</option>
|
||||
<option value="RESOLVED">Разрешены</option>
|
||||
<option value="REJECTED">Отклонены</option>
|
||||
<option value="CANCELLED">Отменены</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="teacher-absence-refresh">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absence-registry" id="teacher-absence-registry" aria-live="polite">
|
||||
<div class="loading-row">Загрузка...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="absence-wizard-overlay" id="absence-wizard-overlay" hidden>
|
||||
<section class="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
|
||||
<header class="absence-wizard-header">
|
||||
<div>
|
||||
<span class="absence-kicker">Мастер замены</span>
|
||||
<h2 id="absence-wizard-title">Затронутые занятия</h2>
|
||||
<p id="absence-wizard-summary">Загрузка...</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon-md btn-ghost" id="absence-wizard-close" aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
<div class="absence-wizard-progress" aria-hidden="true">
|
||||
<span class="done">Инцидент</span><i></i><span class="done">Занятия</span><i></i><span>Решения</span>
|
||||
</div>
|
||||
<div class="form-alert" id="absence-wizard-alert" role="alert"></div>
|
||||
<div class="absence-wizard-lessons" id="absence-wizard-lessons"></div>
|
||||
<section class="absence-journal">
|
||||
<div class="card-header-row">
|
||||
<h3>Журнал решений</h3>
|
||||
<span class="muted" id="absence-journal-count">0 записей</span>
|
||||
</div>
|
||||
<div id="absence-journal-list" class="absence-journal-list"></div>
|
||||
</section>
|
||||
<footer class="absence-wizard-footer">
|
||||
<span class="muted">Пустые строки останутся без изменений</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="absence-wizard-cancel">Закрыть</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="absence-wizard-apply">Применить выбранные</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = '<div class="teacher-absence-empty">Загрузка заявок...</div>';
|
||||
try {
|
||||
const items = await fetchJson('/api/teacher-absences');
|
||||
absenceList.innerHTML = items.length ? items.map(item => `
|
||||
<article class="teacher-absence-item">
|
||||
<span class="teacher-absence-marker ${absenceStatusClass(item.status)}"></span>
|
||||
<div>
|
||||
<strong>${escapeHtml(formatIsoDate(item.startDate))} — ${escapeHtml(formatIsoDate(item.endDate))}</strong>
|
||||
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
|
||||
</div>
|
||||
<span class="teacher-absence-badge ${absenceStatusClass(item.status)}">${escapeHtml(absenceStatusLabel(item.status))}</span>
|
||||
${item.status === 'PENDING' ? `<button class="btn btn-sm btn-danger-subtle" type="button" data-cancel-absence="${item.id}">Отменить</button>` : ''}
|
||||
</article>
|
||||
`).join('') : '<div class="teacher-absence-empty">Заявок об отсутствии пока нет</div>';
|
||||
} catch (error) {
|
||||
absenceList.innerHTML = `<div class="teacher-absence-empty error">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -41,6 +41,35 @@
|
||||
</section>
|
||||
|
||||
<div class="status" id="status-line"></div>
|
||||
<section class="teacher-absence-panel">
|
||||
<div class="teacher-absence-heading">
|
||||
<div>
|
||||
<span>Изменение планов</span>
|
||||
<h2>Сообщить об отсутствии</h2>
|
||||
<p>Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.</p>
|
||||
</div>
|
||||
<button class="btn btn-md btn-secondary" id="absence-form-toggle" type="button" aria-expanded="false">
|
||||
Новая заявка
|
||||
</button>
|
||||
</div>
|
||||
<form class="teacher-absence-form" id="teacher-absence-form" hidden>
|
||||
<label>
|
||||
<span>Начало</span>
|
||||
<input class="date-input" id="teacher-absence-start" type="date" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Окончание</span>
|
||||
<input class="date-input" id="teacher-absence-end" type="date" required>
|
||||
</label>
|
||||
<label class="teacher-absence-reason">
|
||||
<span>Причина</span>
|
||||
<input id="teacher-absence-reason" type="text" maxlength="2000" placeholder="Например, командировка" required>
|
||||
</label>
|
||||
<button class="btn btn-md btn-primary" type="submit">Отправить кафедре</button>
|
||||
</form>
|
||||
<div class="teacher-absence-status" id="teacher-absence-status"></div>
|
||||
<div class="teacher-absence-list" id="teacher-absence-list" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
57
frontend/tests/teacher-absences.test.mjs
Normal file
57
frontend/tests/teacher-absences.test.mjs
Normal file
@@ -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"/);
|
||||
});
|
||||
Reference in New Issue
Block a user