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.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -51,6 +52,19 @@ public class GlobalExceptionHandler {
|
|||||||
return error(HttpStatus.NOT_FOUND, message, request);
|
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({
|
@ExceptionHandler({
|
||||||
HttpMessageNotReadableException.class,
|
HttpMessageNotReadableException.class,
|
||||||
MethodArgumentTypeMismatchException.class
|
MethodArgumentTypeMismatchException.class
|
||||||
@@ -100,6 +114,8 @@ public class GlobalExceptionHandler {
|
|||||||
case BAD_REQUEST -> "Некорректный запрос";
|
case BAD_REQUEST -> "Некорректный запрос";
|
||||||
case CONFLICT -> "Конфликт";
|
case CONFLICT -> "Конфликт";
|
||||||
case NOT_FOUND -> "Не найдено";
|
case NOT_FOUND -> "Не найдено";
|
||||||
|
case FORBIDDEN -> "Доступ запрещён";
|
||||||
|
case UNAUTHORIZED -> "Требуется вход";
|
||||||
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
||||||
default -> status.name();
|
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.TeacherSubject;
|
||||||
import com.magistr.app.model.TeacherSubjectId;
|
import com.magistr.app.model.TeacherSubjectId;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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;
|
import java.util.List;
|
||||||
|
|
||||||
public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject, TeacherSubjectId> {
|
public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject, TeacherSubjectId> {
|
||||||
List<TeacherSubject> findByUserId(Long userId);
|
List<TeacherSubject> findByUserId(Long userId);
|
||||||
List<TeacherSubject> findBySubjectId(Long subjectId);
|
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);
|
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
|
@Transactional
|
||||||
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
|
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
|
||||||
OverrideCommand command = validateAndNormalize(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 | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|
|||||||
@@ -312,6 +312,35 @@ constraint. При чтении API разворачивает периоды о
|
|||||||
для backend-pod, поэтому два конкурентных переноса с разных исходных дат на одну целевую
|
для 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
|
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 {
|
schedule_rule_slot_subgroups {
|
||||||
BIGINT schedule_rule_slot_id FK,PK
|
BIGINT schedule_rule_slot_id FK,PK
|
||||||
BIGINT subgroup_id FK,PK
|
BIGINT subgroup_id FK,PK
|
||||||
@@ -385,6 +413,11 @@ erDiagram
|
|||||||
time_slots ||--o{ schedule_overrides : "new_time_slot_id"
|
time_slots ||--o{ schedule_overrides : "new_time_slot_id"
|
||||||
classrooms ||--o{ schedule_overrides : "new_classroom_id"
|
classrooms ||--o{ schedule_overrides : "new_classroom_id"
|
||||||
users ||--o{ schedule_overrides : "new_teacher_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_slots : "time_slot_scope_id"
|
||||||
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
|
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
|
||||||
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
||||||
@@ -905,6 +938,44 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
|
|||||||
lifecycle ресурсов, эффективная сетка целевого дня, реальность изменения и ресурсные
|
lifecycle ресурсов, эффективная сетка целевого дня, реальность изменения и ресурсные
|
||||||
конфликты проверяются транзакционным сервисом, а не SQL CHECK.
|
конфликты проверяются транзакционным сервисом, а не 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 миграции
|
## Flyway миграции
|
||||||
@@ -924,12 +995,15 @@ lifecycle ресурсов, эффективная сетка целевого
|
|||||||
| Файл | Описание |
|
| Файл | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||||
|
| `V2__teacher_absences_and_replacement_wizard.sql` | Реестр отсутствий преподавателей, статусы согласования и журнал применённых/отклонённых решений со ссылками на обычные `schedule_overrides` |
|
||||||
|
|
||||||
### Этап разработки
|
### Этап разработки
|
||||||
|
|
||||||
По прямому решению владельца проекта разработческие миграции V2–V7 объединены в baseline
|
Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были
|
||||||
`V1`. Интервальное хранение активностей и правильная нумерация недель календарного графика
|
объединены в baseline `V1`. После фиксации baseline нумерация начата заново: текущая `V2`
|
||||||
также входят непосредственно в V1.
|
добавляет отсутствия и мастер замены, не изменяя контрольную сумму `V1`.
|
||||||
|
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
||||||
|
непосредственно в V1.
|
||||||
Перед применением этой редакции требуется полностью пустая tenant-схема.
|
Перед применением этой редакции требуется полностью пустая tenant-схема.
|
||||||
|
|
||||||
### Полный сброс БД (локально)
|
### Полный сброс БД (локально)
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
|
|
||||||
## 1. Отсутствия преподавателей и мастер замены
|
## 1. Отсутствия преподавателей и мастер замены
|
||||||
|
|
||||||
|
**Статус: реализовано в MVP.** Реестр, согласование преподавательских заявок, поиск
|
||||||
|
затронутых занятий, проверенные варианты, групповое применение `schedule_overrides` и журнал
|
||||||
|
решений доступны в системе.
|
||||||
|
|
||||||
### Проблема и пользователи
|
### Проблема и пользователи
|
||||||
|
|
||||||
Болезнь, командировка или другое временное отсутствие преподавателя затрагивает сразу
|
Болезнь, командировка или другое временное отсутствие преподавателя затрагивает сразу
|
||||||
@@ -60,9 +64,8 @@
|
|||||||
доступность ресурсов и создать каждое точечное изменение. Функция нужна учебному отделу,
|
доступность ресурсов и создать каждое точечное изменение. Функция нужна учебному отделу,
|
||||||
кафедрам и преподавателям.
|
кафедрам и преподавателям.
|
||||||
|
|
||||||
Регистрация отсутствий и мастер разрешения инцидентов уже обозначены как планируемые
|
Регистрация отсутствий и мастер разрешения инцидентов реализуют правила из
|
||||||
бизнес-правила в [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md), но отдельной модели и рабочего
|
[BUSINESS_LOGIC.md](BUSINESS_LOGIC.md). Существующая Red Zone уже выявляет конфликты сформированного расписания;
|
||||||
сценария пока нет. Существующая Red Zone уже выявляет конфликты сформированного расписания;
|
|
||||||
новизна этого направления заключается в реестре причин, массовом поиске затронутых занятий
|
новизна этого направления заключается в реестре причин, массовом поиске затронутых занятий
|
||||||
и управляемом подборе решений.
|
и управляемом подборе решений.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ frontend/
|
|||||||
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
||||||
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
||||||
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
||||||
|
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
|
||||||
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
||||||
├── index.html # 🔐 Страница авторизации (общая)
|
├── index.html # 🔐 Страница авторизации (общая)
|
||||||
├── script.js # Логика авторизации
|
├── script.js # Логика авторизации
|
||||||
@@ -49,7 +50,8 @@ frontend/
|
|||||||
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
|
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
|
||||||
│ │ ├── modals.css # Модальные окна
|
│ │ ├── modals.css # Модальные окна
|
||||||
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
|
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
|
||||||
│ │ └── departments-data.css # Стили создания кафедры/специальности
|
│ │ ├── departments-data.css # Стили создания кафедры/специальности
|
||||||
|
│ │ └── teacher-absences.css # Реестр инцидентов и полноэкранный мастер замены
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
||||||
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
||||||
@@ -68,6 +70,7 @@ frontend/
|
|||||||
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
|
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
|
||||||
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
||||||
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
||||||
|
│ │ ├── teacher-absences.js # Реестр, согласование и групповой мастер замены
|
||||||
│ │ ├── schedule.js # Конструктор правил расписания
|
│ │ ├── schedule.js # Конструктор правил расписания
|
||||||
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
|
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
|
||||||
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
|
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
|
||||||
@@ -83,6 +86,7 @@ frontend/
|
|||||||
│ │ ├── university-structure.html
|
│ │ ├── university-structure.html
|
||||||
│ │ ├── department-workspace.html
|
│ │ ├── department-workspace.html
|
||||||
│ │ ├── schedule-view.html
|
│ │ ├── schedule-view.html
|
||||||
|
│ │ ├── teacher-absences.html
|
||||||
│ │ ├── schedule.html
|
│ │ ├── schedule.html
|
||||||
│ │ ├── academic-calendar.html
|
│ │ ├── academic-calendar.html
|
||||||
│ │ └── auditorium-workload.html
|
│ │ └── auditorium-workload.html
|
||||||
@@ -142,8 +146,8 @@ frontend/
|
|||||||
| Роль | Доступные вкладки |
|
| Роль | Доступные вкладки |
|
||||||
|------|-------------------|
|
|------|-------------------|
|
||||||
| `ADMIN` | Все вкладки |
|
| `ADMIN` | Все вкладки |
|
||||||
| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
|
| `EDUCATION_OFFICE` | Просмотр и конструктор расписаний, отсутствия и замены, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
|
||||||
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний |
|
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний, отсутствия и подтверждение заявок своих преподавателей |
|
||||||
| `SCHEDULE_VIEWER` | Только просмотр расписаний |
|
| `SCHEDULE_VIEWER` | Только просмотр расписаний |
|
||||||
|
|
||||||
Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели.
|
Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели.
|
||||||
@@ -166,6 +170,7 @@ frontend/
|
|||||||
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
|
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
|
||||||
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
|
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
|
||||||
| `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` |
|
| `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` |
|
||||||
|
| `teacher-absences` | Реестр отсутствий, подтверждение преподавательских заявок, проверенные варианты замен/переносов и журнал решений | `/api/teacher-absences`, `/api/users/teachers` |
|
||||||
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
|
| `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` |
|
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
|
||||||
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
|
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
|
||||||
@@ -181,6 +186,11 @@ frontend/
|
|||||||
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
- Вкладка `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` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||||
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
|
- Вкладка `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`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
||||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||||
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
|
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
|
||||||
@@ -325,6 +335,9 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
|
|||||||
- выбор даты через `input[type="date"]`;
|
- выбор даты через `input[type="date"]`;
|
||||||
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||||
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
|
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
|
||||||
|
- форма собственной заявки на отсутствие через `POST /api/teacher-absences`;
|
||||||
|
- список статусов заявок и отмена ещё не подтверждённой записи через
|
||||||
|
`DELETE /api/teacher-absences/{id}`.
|
||||||
|
|
||||||
Если refresh-cookie недействительна или роль не `TEACHER`, страница возвращает пользователя на вход.
|
Если 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/modals.css">
|
||||||
<link rel="stylesheet" href="css/departments-data.css">
|
<link rel="stylesheet" href="css/departments-data.css">
|
||||||
<link rel="stylesheet" href="css/auditorium-workload.css">
|
<link rel="stylesheet" href="css/auditorium-workload.css">
|
||||||
|
<link rel="stylesheet" href="css/teacher-absences.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -112,6 +113,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Просмотр расписаний</span>
|
<span>Просмотр расписаний</span>
|
||||||
</a>
|
</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>
|
<div class="sidebar-section-divider"></div>
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ const ROUTES = {
|
|||||||
await module.initAuditoriumWorkload();
|
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;
|
let currentTab = null;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
const capabilities = {
|
const capabilities = {
|
||||||
ADMIN: {
|
ADMIN: {
|
||||||
defaultAdminTab: 'dashboard',
|
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',
|
defaultSettingsTab: 'general',
|
||||||
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
|
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
|
||||||
},
|
},
|
||||||
EDUCATION_OFFICE: {
|
EDUCATION_OFFICE: {
|
||||||
defaultAdminTab: 'dashboard',
|
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',
|
defaultSettingsTab: 'time-slots',
|
||||||
settingsTabs: ['time-slots', 'edu-forms']
|
settingsTabs: ['time-slots', 'edu-forms']
|
||||||
},
|
},
|
||||||
DEPARTMENT: {
|
DEPARTMENT: {
|
||||||
defaultAdminTab: 'department-workspace',
|
defaultAdminTab: 'department-workspace',
|
||||||
adminTabs: ['department-workspace', 'schedule-view'],
|
adminTabs: ['department-workspace', 'schedule-view', 'teacher-absences'],
|
||||||
defaultSettingsTab: null,
|
defaultSettingsTab: null,
|
||||||
settingsTabs: []
|
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": "npm run build:vendor",
|
||||||
"build:vendor": "node scripts/build-vendor.mjs",
|
"build:vendor": "node scripts/build-vendor.mjs",
|
||||||
"test": "node --test tests/*.test.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": {
|
"devDependencies": {
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.9.0",
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import {
|
|||||||
const statusLine = document.getElementById('status-line');
|
const statusLine = document.getElementById('status-line');
|
||||||
const weekLabel = document.getElementById('week-label');
|
const weekLabel = document.getElementById('week-label');
|
||||||
const datePicker = document.getElementById('date-picker');
|
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());
|
let weekStart = startOfWeek(new Date());
|
||||||
|
|
||||||
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
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();
|
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() {
|
async function loadSchedule() {
|
||||||
const startDate = toIsoDate(weekStart);
|
const startDate = toIsoDate(weekStart);
|
||||||
@@ -185,8 +276,8 @@ import {
|
|||||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url) {
|
async function fetchJson(url, options = {}) {
|
||||||
const response = await fetchWithAuth(url);
|
const response = await fetchWithAuth(url, options);
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearAuthState();
|
clearAuthState();
|
||||||
@@ -225,6 +316,25 @@ import {
|
|||||||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
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) {
|
function dayName(date) {
|
||||||
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
|||||||
@@ -41,6 +41,35 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="status" id="status-line"></div>
|
<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>
|
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,178 @@
|
|||||||
box-sizing: border-box;
|
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 {
|
:root {
|
||||||
--bg: #f5f6f8;
|
--bg: #f5f6f8;
|
||||||
--panel: #ffffff;
|
--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